-
-
{unavailableStaff.length} Unavailable
+
+ {allRoles.map((roleItem) => {
+ const staff = staffByRole[roleItem.key] || [];
+ const selectedSet = assignments[roleItem.key] || new Set();
+ const available = staff.filter(s => !s.hasConflict);
+
+ return (
+
+
+
+
+
{roleItem.label}
+
{available.length} available staff
+
+
= roleItem.remaining
+ ? 'bg-green-100 text-green-700 border-green-300'
+ : selectedSet.size > 0
+ ? 'bg-blue-100 text-blue-700 border-blue-300'
+ : 'bg-slate-100 text-slate-600 border-slate-300'
+ } border-2 px-4 py-2 text-lg font-bold`}>
+ {selectedSet.size} / {roleItem.remaining}
+
- )}
-
-
-
-
-
-
- {eligibleStaff.length === 0 ? (
-
-
-
No {currentRole.role}s found
-
Try adjusting your search or check staff positions
- ) : (
-
- {/* Available Staff First */}
- {availableStaff.length > 0 && (
- <>
-
-
Available ({availableStaff.length})
-
- {availableStaff.map((staff) => {
- const isSelected = selected.has(staff.id);
+
+
+ {staff.length === 0 ? (
+
+
+
No {roleItem.role.role}s found
+
+ ) : (
+
+ {staff.map((person) => {
+ const isSelected = selectedSet.has(person.id);
return (
toggleSelect(staff.id)}
+ onClick={() => toggleStaffForRole(roleItem.key, person.id, roleItem.remaining, person)}
>
toggleSelect(staff.id)}
- className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
+ onChange={() => toggleStaffForRole(roleItem.key, person.id, roleItem.remaining, person)}
+ className="w-4 h-4 rounded border-2 text-[#0A39DF]"
onClick={(e) => e.stopPropagation()}
/>
-
-
+
+
-
+
-
-
{staff.employee_name}
-
- {Math.round(staff.smartScore)}% Match
-
-
-
-
-
- {staff.reliability}%
-
-
-
- {Math.round(staff.scores.fatigue)}
-
-
-
- {Math.round(staff.scores.compliance)}
-
- {staff.hub_location && (
+
{person.employee_name}
+
+ {person.hub_location && (
- {staff.hub_location}
+ {person.hub_location}
+
+ )}
+
+
+ {person.reliability}%
+
+ {person.otAnalysis && (
+
+ {person.otAnalysis.projectedWeekHours.toFixed(0)}h week
)}
-
-
-
- {staff.shiftCount || 0} shifts
-
-
- Available
-
-
-
- );
- })}
- >
- )}
-
- {/* Unavailable Staff */}
- {unavailableStaff.length > 0 && (
- <>
-
-
-
Unavailable ({unavailableStaff.length})
-
-
Will be notified if assigned
-
-
- {unavailableStaff.map((staff) => {
- const isSelected = selected.has(staff.id);
-
- return (
- toggleSelect(staff.id)}
- >
-
toggleSelect(staff.id)}
- className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
- onClick={(e) => e.stopPropagation()}
- />
-
-
-
-
-
-
-
-
{staff.employee_name}
-
- {Math.round(staff.smartScore)}% Match
+
+
+ {person.hasConflict ? (
+
+
+ Conflict
-
-
-
-
- Time Conflict
+ ) : person.otAnalysis?.status === 'RED' ? (
+
+
+ OT Risk
+
+ ) : person.otAnalysis?.status === 'AMBER' ? (
+
+ Near OT
+
+ ) : (
+
+ Available
+
+ )}
+ {person.otAnalysis?.summary && (
+
+ {person.otAnalysis.summary}
- {staff.hub_location && (
-
-
- {staff.hub_location}
-
- )}
-
-
-
-
-
- {staff.shiftCount || 0} shifts
-
-
- Will Notify
-
+ )}
);
})}
- >
+
)}
- )}
-
-
-
-
-
-
-
-
- setSearchQuery(e.target.value)}
- className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]"
- />
-
-
-
-
-
-
- {availableStaff.length} Available {currentRole.role}s
-
- {unavailableStaff.length > 0 && (
-
-
-
{unavailableStaff.length} Conflicts
-
- )}
+ );
+ })}
+
+
-
-
-
-
- {eligibleStaff.length === 0 ? (
-
-
-
No {currentRole.role}s found
-
Try adjusting your search or filters
-
- ) : (
-
- {eligibleStaff.map((staff) => {
- const isSelected = selected.has(staff.id);
- // In manual mode, we still allow selection of conflicted staff,
- // and the system will notify them.
-
- return (
-
toggleSelect(staff.id)}
- >
-
toggleSelect(staff.id)}
- className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]"
- onClick={(e) => e.stopPropagation()}
- />
-
-
-
-
-
-
-
-
{staff.employee_name}
- {staff.rating && (
-
-
- {staff.rating.toFixed(1)}
-
- )}
-
-
-
- {currentRole.role}
-
- {staff.hub_location && (
-
-
- {staff.hub_location}
-
- )}
-
-
-
-
-
- {staff.shiftCount || 0} shifts
-
- {staff.hasConflict ? (
-
- Conflict (Will Notify)
-
- ) : (
-
- Available
-
- )}
-
-
- );
- })}
-
- )}
-
-
-
-
-
-
-
-
))}
@@ -665,4 +684,4 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
);
-}
+}
\ No newline at end of file
diff --git a/frontend-web/src/components/invoices/AutoInvoiceGenerator.jsx b/frontend-web/src/components/invoices/AutoInvoiceGenerator.jsx
new file mode 100644
index 00000000..a7929560
--- /dev/null
+++ b/frontend-web/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/src/components/invoices/CreateInvoiceModal.jsx b/frontend-web/src/components/invoices/CreateInvoiceModal.jsx
new file mode 100644
index 00000000..ef3f8d0e
--- /dev/null
+++ b/frontend-web/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/src/components/invoices/InvoiceDetailModal.jsx b/frontend-web/src/components/invoices/InvoiceDetailModal.jsx
new file mode 100644
index 00000000..a7505e06
--- /dev/null
+++ b/frontend-web/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/src/components/invoices/InvoiceDetailView.jsx b/frontend-web/src/components/invoices/InvoiceDetailView.jsx
new file mode 100644
index 00000000..f259d691
--- /dev/null
+++ b/frontend-web/src/components/invoices/InvoiceDetailView.jsx
@@ -0,0 +1,390 @@
+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
+} from "lucide-react";
+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 [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 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 canApprove = isClient && invoice.status === "Pending Review";
+ const canDispute = isClient && ["Pending Review", "Approved"].includes(invoice.status);
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
{invoice.invoice_number}
+
+ {invoice.status}
+
+
+
+
+
+
+ Print
+
+ {canDispute && (
+
setShowDisputeDialog(true)}>
+
+ Dispute Invoice
+
+ )}
+ {canApprove && (
+
+
+ Accept Invoice
+
+ )}
+
+
+
+ {/* 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}
+
{invoice.from_company?.address}
+
{invoice.from_company?.email}
+
{invoice.from_company?.phone}
+
+
+
+
+
+
+
{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/src/components/notifications/NotificationPanel.jsx b/frontend-web/src/components/notifications/NotificationPanel.jsx
index ac748282..33379530 100644
--- a/frontend-web/src/components/notifications/NotificationPanel.jsx
+++ b/frontend-web/src/components/notifications/NotificationPanel.jsx
@@ -1,4 +1,3 @@
-
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -16,10 +15,12 @@ import {
AlertCircle,
CheckCircle,
ArrowRight,
- MoreVertical
+ MoreVertical,
+ CheckSquare,
+ Package
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
-import { formatDistanceToNow } from "date-fns";
+import { formatDistanceToNow, format, isToday, isYesterday, isThisWeek, startOfDay } from "date-fns";
const iconMap = {
calendar: Calendar,
@@ -41,6 +42,7 @@ const colorMap = {
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'],
@@ -126,15 +128,96 @@ export default function NotificationPanel({ isOpen, onClose }) {
},
});
- const newNotifications = notifications.filter(n => !n.is_read);
- const olderNotifications = notifications.filter(n => n.is_read);
+ // 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) => {
- if (notification.action_link) {
- navigate(createPageUrl(notification.action_link));
+ // Mark as read when clicking
+ if (!notification.is_read) {
markAsReadMutation.mutate({ id: notification.id });
- onClose();
}
+
+ 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 (
@@ -159,133 +242,376 @@ export default function NotificationPanel({ isOpen, onClose }) {
className="fixed right-0 top-0 h-full w-full sm:w-[440px] bg-white shadow-2xl z-50 flex flex-col"
>
{/* Header */}
-
-
-
-
Notifications
-
-
-
- {user?.full_name?.split(' ').map(n => n[0]).join('').slice(0, 2) || 'U'}
+
+
+
+
+
Notifications
-
-
-
-
-
-
+
+
+
+
+
+
+
+ {/* Filter Tabs */}
+
+ setActiveFilter('all')}
+ className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
+ activeFilter === 'all'
+ ? 'bg-blue-50 text-blue-600'
+ : 'text-slate-600 hover:bg-slate-50'
+ }`}
+ >
+ View all {allCount}
+
+ setActiveFilter('mentions')}
+ className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
+ activeFilter === 'mentions'
+ ? 'bg-blue-50 text-blue-600'
+ : 'text-slate-600 hover:bg-slate-50'
+ }`}
+ >
+ Mentions {mentionsCount}
+
+ setActiveFilter('invites')}
+ className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
+ activeFilter === 'invites'
+ ? 'bg-blue-50 text-blue-600'
+ : 'text-slate-600 hover:bg-slate-50'
+ }`}
+ >
+ Invites {invitesCount}
+
{/* Notifications List */}
- {newNotifications.length > 0 && (
-
-
New
-
- {newNotifications.map((notification) => {
- const Icon = iconMap[notification.icon_type] || AlertCircle;
- const colorClass = colorMap[notification.icon_color] || colorMap.blue;
-
- return (
-
-
-
-
-
-
-
-
-
{notification.title}
-
- {formatDistanceToNow(new Date(notification.created_date), { addSuffix: true })}
-
-
-
{notification.description}
-
- {notification.action_link && (
-
handleAction(notification)}
- className="text-blue-600 hover:text-blue-700 text-sm font-medium flex items-center gap-1"
- >
- {notification.action_label || 'View'}
-
-
- )}
-
markAsReadMutation.mutate({ id: notification.id })}
- className="text-blue-600 hover:text-blue-700 text-sm font-medium"
- >
- Mark as Read
-
-
deleteMutation.mutate({ id: notification.id })}
- className="text-red-600 hover:text-red-700 text-sm font-medium"
- >
- Delete
-
-
-
-
-
- );
- })}
-
-
- )}
-
- {olderNotifications.length > 0 && (
-
-
Older
-
- {olderNotifications.map((notification) => {
- const Icon = iconMap[notification.icon_type] || AlertCircle;
- const colorClass = colorMap[notification.icon_color] || colorMap.blue;
-
- return (
-
-
-
-
-
-
-
{notification.title}
-
- {formatDistanceToNow(new Date(notification.created_date), { addSuffix: true })}
-
-
-
{notification.description}
-
- {notification.action_link && (
-
handleAction(notification)}
- className="text-blue-600 hover:text-blue-700 text-sm font-medium flex items-center gap-1"
- >
- {notification.action_label || 'View'}
-
-
- )}
-
deleteMutation.mutate({ id: notification.id })}
- className="text-red-600 hover:text-red-700 text-sm font-medium"
- >
- Delete
-
-
-
-
- );
- })}
-
-
- )}
-
- {notifications.length === 0 && (
+ {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 && (
+ {
+ e.stopPropagation();
+ markAsReadMutation.mutate({ id: notification.id });
+ }}
+ className="text-blue-600 hover:text-blue-700 font-medium"
+ >
+ Mark as Read
+
+ )}
+ {
+ e.stopPropagation();
+ deleteMutation.mutate({ id: notification.id });
+ }}
+ className="text-red-600 hover:text-red-700 font-medium"
+ >
+ Delete
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* 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 && (
+ {
+ e.stopPropagation();
+ markAsReadMutation.mutate({ id: notification.id });
+ }}
+ className="text-blue-600 hover:text-blue-700 font-medium"
+ >
+ Mark as Read
+
+ )}
+ {
+ e.stopPropagation();
+ deleteMutation.mutate({ id: notification.id });
+ }}
+ className="text-red-600 hover:text-red-700 font-medium"
+ >
+ Delete
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* 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 && (
+ {
+ e.stopPropagation();
+ markAsReadMutation.mutate({ id: notification.id });
+ }}
+ className="text-blue-600 hover:text-blue-700 font-medium"
+ >
+ Mark as Read
+
+ )}
+ {
+ e.stopPropagation();
+ deleteMutation.mutate({ id: notification.id });
+ }}
+ className="text-red-600 hover:text-red-700 font-medium"
+ >
+ Delete
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* 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 && (
+ {
+ e.stopPropagation();
+ markAsReadMutation.mutate({ id: notification.id });
+ }}
+ className="text-blue-600 hover:text-blue-700 font-medium"
+ >
+ Mark as Read
+
+ )}
+ {
+ e.stopPropagation();
+ deleteMutation.mutate({ id: notification.id });
+ }}
+ className="text-red-600 hover:text-red-700 font-medium"
+ >
+ Delete
+
+
+
+
+ );
+ })}
+
+
+ )}
+ >
)}
@@ -293,4 +619,4 @@ export default function NotificationPanel({ isOpen, onClose }) {
)}
);
-}
+}
\ No newline at end of file
diff --git a/frontend-web/src/components/orders/CancellationFeeModal.jsx b/frontend-web/src/components/orders/CancellationFeeModal.jsx
new file mode 100644
index 00000000..50ea256d
--- /dev/null
+++ b/frontend-web/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/src/components/orders/OrderDetailModal.jsx b/frontend-web/src/components/orders/OrderDetailModal.jsx
new file mode 100644
index 00000000..89c777a8
--- /dev/null
+++ b/frontend-web/src/components/orders/OrderDetailModal.jsx
@@ -0,0 +1,336 @@
+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 } 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/src/components/orders/OrderReductionAlert.jsx b/frontend-web/src/components/orders/OrderReductionAlert.jsx
new file mode 100644
index 00000000..f1c1e667
--- /dev/null
+++ b/frontend-web/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}
+
+
+
+
+
+
+ Manually Select Which Staff to Remove
+
+
+ {lowReliabilityStaff.length > 0 && (
+
+
+ Auto-Remove {excessStaff} Lowest Reliability Staff
+
+ )}
+
+
+ {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/src/components/orders/OrderStatusUtils.jsx b/frontend-web/src/components/orders/OrderStatusUtils.jsx
new file mode 100644
index 00000000..3cadf867
--- /dev/null
+++ b/frontend-web/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/src/components/orders/RapidOrderInterface.jsx b/frontend-web/src/components/orders/RapidOrderInterface.jsx
new file mode 100644
index 00000000..abfa14fe
--- /dev/null
+++ b/frontend-web/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) => (
+ handleExampleClick(example.text)}
+ className={`w-full p-3 rounded-lg border-2 text-left transition-all hover:shadow-md text-sm ${example.color}`}
+ >
+ Example: "{example.text}"
+
+ ))}
+
+
+ {/* 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 */}
+
+
+
+ CONFIRM & SEND
+
+
+
+ EDIT
+
+
+
+ ) : (
+
+ )}
+
+ {/* 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/src/components/scheduling/DoubleBookingOverrideDialog.jsx b/frontend-web/src/components/scheduling/DoubleBookingOverrideDialog.jsx
new file mode 100644
index 00000000..51826617
--- /dev/null
+++ b/frontend-web/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/src/components/scheduling/DoubleBookingValidator.jsx b/frontend-web/src/components/scheduling/DoubleBookingValidator.jsx
new file mode 100644
index 00000000..4872bad0
--- /dev/null
+++ b/frontend-web/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/src/components/scheduling/DragDropScheduler.jsx b/frontend-web/src/components/scheduling/DragDropScheduler.jsx
index 9592fa18..10515c6f 100644
--- a/frontend-web/src/components/scheduling/DragDropScheduler.jsx
+++ b/frontend-web/src/components/scheduling/DragDropScheduler.jsx
@@ -5,6 +5,11 @@ 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
@@ -14,6 +19,21 @@ import { format } from "date-fns";
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;
@@ -24,6 +44,39 @@ export default function DragDropScheduler({ events, staff, onAssign, onUnassign
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);
@@ -106,8 +159,34 @@ export default function DragDropScheduler({ events, staff, onAssign, onUnassign
}
};
+ 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 */}
@@ -251,5 +330,14 @@ export default function DragDropScheduler({ events, staff, onAssign, onUnassign
+
+
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/src/components/scheduling/OvertimeCalculator.jsx b/frontend-web/src/components/scheduling/OvertimeCalculator.jsx
new file mode 100644
index 00000000..6173be0c
--- /dev/null
+++ b/frontend-web/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/src/components/tasks/TaskCard.jsx b/frontend-web/src/components/tasks/TaskCard.jsx
index 8635b23d..44b876ea 100644
--- a/frontend-web/src/components/tasks/TaskCard.jsx
+++ b/frontend-web/src/components/tasks/TaskCard.jsx
@@ -18,18 +18,28 @@ const progressColor = (progress) => {
return "bg-slate-400";
};
-export default function TaskCard({ task, provided, onClick }) {
+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}
diff --git a/frontend-web/src/dataconnect-generated/.guides/config.json b/frontend-web/src/dataconnect-generated/.guides/config.json
deleted file mode 100644
index e37ed06f..00000000
--- a/frontend-web/src/dataconnect-generated/.guides/config.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "description": "A set of guides for interacting with the generated firebase dataconnect sdk",
- "mcpServers": {
- "firebase": {
- "command": "npx",
- "args": ["-y", "firebase-tools@latest", "experimental:mcp"]
- }
- }
-}
diff --git a/frontend-web/src/dataconnect-generated/.guides/setup.md b/frontend-web/src/dataconnect-generated/.guides/setup.md
deleted file mode 100644
index 64a49286..00000000
--- a/frontend-web/src/dataconnect-generated/.guides/setup.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# Setup
-
-If the user hasn't already installed the SDK, always run the user's node package manager of choice, and install the package in the directory ../package.json.
-For more information on where the library is located, look at the connector.yaml file.
-
-```ts
-import { initializeApp } from 'firebase/app';
-
-initializeApp({
- // fill in your project config here using the values from your Firebase project or from the `firebase_get_sdk_config` tool from the Firebase MCP server.
-});
-```
-
-Then, you can run the SDK as needed.
-```ts
-import { ... } from '@dataconnect/generated';
-```
-
-
-
-
-## React
-### Setup
-
-The user should make sure to install the `@tanstack/react-query` package, along with `@tanstack-query-firebase/react` and `firebase`.
-
-Then, they should initialize Firebase:
-```ts
-import { initializeApp } from 'firebase/app';
-initializeApp(firebaseConfig); /* your config here. To generate this, you can use the `firebase_sdk_config` MCP tool */
-```
-
-Then, they should add a `QueryClientProvider` to their root of their application.
-
-Here's an example:
-
-```ts
-import { initializeApp } from 'firebase/app';
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-
-const firebaseConfig = {
- /* your config here. To generate this, you can use the `firebase_sdk_config` MCP tool */
-};
-
-// Initialize Firebase
-const app = initializeApp(firebaseConfig);
-
-// Create a TanStack Query client instance
-const queryClient = new QueryClient();
-
-function App() {
- return (
- // Provide the client to your App
-
-
-
- )
-}
-
-render(
, document.getElementById('root'));
-```
-
diff --git a/frontend-web/src/dataconnect-generated/.guides/usage.md b/frontend-web/src/dataconnect-generated/.guides/usage.md
deleted file mode 100644
index 8c0adfb2..00000000
--- a/frontend-web/src/dataconnect-generated/.guides/usage.md
+++ /dev/null
@@ -1,69 +0,0 @@
-# Basic Usage
-
-Always prioritize using a supported framework over using the generated SDK
-directly. Supported frameworks simplify the developer experience and help ensure
-best practices are followed.
-
-
-
-
-### React
-For each operation, there is a wrapper hook that can be used to call the operation.
-
-Here are all of the hooks that get generated:
-```ts
-import { useListEvents, useCreateEvent } from '@dataconnect/generated/react';
-// The types of these hooks are available in react/index.d.ts
-
-const { data, isPending, isSuccess, isError, error } = useListEvents();
-
-const { data, isPending, isSuccess, isError, error } = useCreateEvent(createEventVars);
-
-```
-
-Here's an example from a different generated SDK:
-
-```ts
-import { useListAllMovies } from '@dataconnect/generated/react';
-
-function MyComponent() {
- const { isLoading, data, error } = useListAllMovies();
- if(isLoading) {
- return
Loading...
- }
- if(error) {
- return
An Error Occurred: {error}
- }
-}
-
-// App.tsx
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import MyComponent from './my-component';
-
-function App() {
- const queryClient = new QueryClient();
- return
-
-
-}
-```
-
-
-
-## Advanced Usage
-If a user is not using a supported framework, they can use the generated SDK directly.
-
-Here's an example of how to use it with the first 5 operations:
-
-```js
-import { listEvents, createEvent } from '@dataconnect/generated';
-
-
-// Operation listEvents:
-const { data } = await ListEvents(dataConnect);
-
-// Operation CreateEvent: For variables, look at type CreateEventVars in ../index.d.ts
-const { data } = await CreateEvent(dataConnect, createEventVars);
-
-
-```
\ No newline at end of file
diff --git a/frontend-web/src/dataconnect-generated/README.md b/frontend-web/src/dataconnect-generated/README.md
deleted file mode 100644
index a201a0f7..00000000
--- a/frontend-web/src/dataconnect-generated/README.md
+++ /dev/null
@@ -1,317 +0,0 @@
-# Generated TypeScript README
-This README will guide you through the process of using the generated JavaScript SDK package for the connector `krow-connector`. It will also provide examples on how to use your generated SDK to call your Data Connect queries and mutations.
-
-**If you're looking for the `React README`, you can find it at [`dataconnect-generated/react/README.md`](./react/README.md)**
-
-***NOTE:** This README is generated alongside the generated SDK. If you make changes to this file, they will be overwritten when the SDK is regenerated.*
-
-# Table of Contents
-- [**Overview**](#generated-javascript-readme)
-- [**Accessing the connector**](#accessing-the-connector)
- - [*Connecting to the local Emulator*](#connecting-to-the-local-emulator)
-- [**Queries**](#queries)
- - [*listEvents*](#listevents)
-- [**Mutations**](#mutations)
- - [*CreateEvent*](#createevent)
-
-# Accessing the connector
-A connector is a collection of Queries and Mutations. One SDK is generated for each connector - this SDK is generated for the connector `krow-connector`. You can find more information about connectors in the [Data Connect documentation](https://firebase.google.com/docs/data-connect#how-does).
-
-You can use this generated SDK by importing from the package `@dataconnect/generated` as shown below. Both CommonJS and ESM imports are supported.
-
-You can also follow the instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#set-client).
-
-```typescript
-import { getDataConnect } from 'firebase/data-connect';
-import { connectorConfig } from '@dataconnect/generated';
-
-const dataConnect = getDataConnect(connectorConfig);
-```
-
-## Connecting to the local Emulator
-By default, the connector will connect to the production service.
-
-To connect to the emulator, you can use the following code.
-You can also follow the emulator instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#instrument-clients).
-
-```typescript
-import { connectDataConnectEmulator, getDataConnect } from 'firebase/data-connect';
-import { connectorConfig } from '@dataconnect/generated';
-
-const dataConnect = getDataConnect(connectorConfig);
-connectDataConnectEmulator(dataConnect, 'localhost', 9399);
-```
-
-After it's initialized, you can call your Data Connect [queries](#queries) and [mutations](#mutations) from your generated SDK.
-
-# Queries
-
-There are two ways to execute a Data Connect Query using the generated Web SDK:
-- Using a Query Reference function, which returns a `QueryRef`
- - The `QueryRef` can be used as an argument to `executeQuery()`, which will execute the Query and return a `QueryPromise`
-- Using an action shortcut function, which returns a `QueryPromise`
- - Calling the action shortcut function will execute the Query and return a `QueryPromise`
-
-The following is true for both the action shortcut function and the `QueryRef` function:
-- The `QueryPromise` returned will resolve to the result of the Query once it has finished executing
-- If the Query accepts arguments, both the action shortcut function and the `QueryRef` function accept a single argument: an object that contains all the required variables (and the optional variables) for the Query
-- Both functions can be called with or without passing in a `DataConnect` instance as an argument. If no `DataConnect` argument is passed in, then the generated SDK will call `getDataConnect(connectorConfig)` behind the scenes for you.
-
-Below are examples of how to use the `krow-connector` connector's generated functions to execute each query. You can also follow the examples from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#using-queries).
-
-## listEvents
-You can execute the `listEvents` query using the following action shortcut function, or by calling `executeQuery()` after calling the following `QueryRef` function, both of which are defined in [dataconnect-generated/index.d.ts](./index.d.ts):
-```typescript
-listEvents(): QueryPromise
;
-
-interface ListEventsRef {
- ...
- /* Allow users to create refs without passing in DataConnect */
- (): QueryRef;
-}
-export const listEventsRef: ListEventsRef;
-```
-You can also pass in a `DataConnect` instance to the action shortcut function or `QueryRef` function.
-```typescript
-listEvents(dc: DataConnect): QueryPromise;
-
-interface ListEventsRef {
- ...
- (dc: DataConnect): QueryRef;
-}
-export const listEventsRef: ListEventsRef;
-```
-
-If you need the name of the operation without creating a ref, you can retrieve the operation name by calling the `operationName` property on the listEventsRef:
-```typescript
-const name = listEventsRef.operationName;
-console.log(name);
-```
-
-### Variables
-The `listEvents` query has no variables.
-### Return Type
-Recall that executing the `listEvents` query returns a `QueryPromise` that resolves to an object with a `data` property.
-
-The `data` property is an object of type `ListEventsData`, which is defined in [dataconnect-generated/index.d.ts](./index.d.ts). It has the following fields:
-```typescript
-export interface ListEventsData {
- events: ({
- id: UUIDString;
- eventName: string;
- status: EventStatus;
- date: TimestampString;
- isRecurring: boolean;
- recurrenceType?: RecurrenceType | null;
- businessId: UUIDString;
- vendorId?: UUIDString | null;
- total?: number | null;
- requested?: number | null;
- } & Event_Key)[];
-}
-```
-### Using `listEvents`'s action shortcut function
-
-```typescript
-import { getDataConnect } from 'firebase/data-connect';
-import { connectorConfig, listEvents } from '@dataconnect/generated';
-
-
-// Call the `listEvents()` function to execute the query.
-// You can use the `await` keyword to wait for the promise to resolve.
-const { data } = await listEvents();
-
-// You can also pass in a `DataConnect` instance to the action shortcut function.
-const dataConnect = getDataConnect(connectorConfig);
-const { data } = await listEvents(dataConnect);
-
-console.log(data.events);
-
-// Or, you can use the `Promise` API.
-listEvents().then((response) => {
- const data = response.data;
- console.log(data.events);
-});
-```
-
-### Using `listEvents`'s `QueryRef` function
-
-```typescript
-import { getDataConnect, executeQuery } from 'firebase/data-connect';
-import { connectorConfig, listEventsRef } from '@dataconnect/generated';
-
-
-// Call the `listEventsRef()` function to get a reference to the query.
-const ref = listEventsRef();
-
-// You can also pass in a `DataConnect` instance to the `QueryRef` function.
-const dataConnect = getDataConnect(connectorConfig);
-const ref = listEventsRef(dataConnect);
-
-// Call `executeQuery()` on the reference to execute the query.
-// You can use the `await` keyword to wait for the promise to resolve.
-const { data } = await executeQuery(ref);
-
-console.log(data.events);
-
-// Or, you can use the `Promise` API.
-executeQuery(ref).then((response) => {
- const data = response.data;
- console.log(data.events);
-});
-```
-
-# Mutations
-
-There are two ways to execute a Data Connect Mutation using the generated Web SDK:
-- Using a Mutation Reference function, which returns a `MutationRef`
- - The `MutationRef` can be used as an argument to `executeMutation()`, which will execute the Mutation and return a `MutationPromise`
-- Using an action shortcut function, which returns a `MutationPromise`
- - Calling the action shortcut function will execute the Mutation and return a `MutationPromise`
-
-The following is true for both the action shortcut function and the `MutationRef` function:
-- The `MutationPromise` returned will resolve to the result of the Mutation once it has finished executing
-- If the Mutation accepts arguments, both the action shortcut function and the `MutationRef` function accept a single argument: an object that contains all the required variables (and the optional variables) for the Mutation
-- Both functions can be called with or without passing in a `DataConnect` instance as an argument. If no `DataConnect` argument is passed in, then the generated SDK will call `getDataConnect(connectorConfig)` behind the scenes for you.
-
-Below are examples of how to use the `krow-connector` connector's generated functions to execute each mutation. You can also follow the examples from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#using-mutations).
-
-## CreateEvent
-You can execute the `CreateEvent` mutation using the following action shortcut function, or by calling `executeMutation()` after calling the following `MutationRef` function, both of which are defined in [dataconnect-generated/index.d.ts](./index.d.ts):
-```typescript
-createEvent(vars: CreateEventVariables): MutationPromise;
-
-interface CreateEventRef {
- ...
- /* Allow users to create refs without passing in DataConnect */
- (vars: CreateEventVariables): MutationRef;
-}
-export const createEventRef: CreateEventRef;
-```
-You can also pass in a `DataConnect` instance to the action shortcut function or `MutationRef` function.
-```typescript
-createEvent(dc: DataConnect, vars: CreateEventVariables): MutationPromise;
-
-interface CreateEventRef {
- ...
- (dc: DataConnect, vars: CreateEventVariables): MutationRef;
-}
-export const createEventRef: CreateEventRef;
-```
-
-If you need the name of the operation without creating a ref, you can retrieve the operation name by calling the `operationName` property on the createEventRef:
-```typescript
-const name = createEventRef.operationName;
-console.log(name);
-```
-
-### Variables
-The `CreateEvent` mutation requires an argument of type `CreateEventVariables`, which is defined in [dataconnect-generated/index.d.ts](./index.d.ts). It has the following fields:
-
-```typescript
-export interface CreateEventVariables {
- eventName: string;
- isRecurring: boolean;
- recurrenceType?: RecurrenceType | null;
- businessId: UUIDString;
- vendorId?: UUIDString | null;
- status: EventStatus;
- date: TimestampString;
- shifts?: string | null;
- total?: number | null;
- requested?: number | null;
- assignedStaff?: string | null;
-}
-```
-### Return Type
-Recall that executing the `CreateEvent` mutation returns a `MutationPromise` that resolves to an object with a `data` property.
-
-The `data` property is an object of type `CreateEventData`, which is defined in [dataconnect-generated/index.d.ts](./index.d.ts). It has the following fields:
-```typescript
-export interface CreateEventData {
- event_insert: Event_Key;
-}
-```
-### Using `CreateEvent`'s action shortcut function
-
-```typescript
-import { getDataConnect } from 'firebase/data-connect';
-import { connectorConfig, createEvent, CreateEventVariables } from '@dataconnect/generated';
-
-// The `CreateEvent` mutation requires an argument of type `CreateEventVariables`:
-const createEventVars: CreateEventVariables = {
- eventName: ...,
- isRecurring: ...,
- recurrenceType: ..., // optional
- businessId: ...,
- vendorId: ..., // optional
- status: ...,
- date: ...,
- shifts: ..., // optional
- total: ..., // optional
- requested: ..., // optional
- assignedStaff: ..., // optional
-};
-
-// Call the `createEvent()` function to execute the mutation.
-// You can use the `await` keyword to wait for the promise to resolve.
-const { data } = await createEvent(createEventVars);
-// Variables can be defined inline as well.
-const { data } = await createEvent({ eventName: ..., isRecurring: ..., recurrenceType: ..., businessId: ..., vendorId: ..., status: ..., date: ..., shifts: ..., total: ..., requested: ..., assignedStaff: ..., });
-
-// You can also pass in a `DataConnect` instance to the action shortcut function.
-const dataConnect = getDataConnect(connectorConfig);
-const { data } = await createEvent(dataConnect, createEventVars);
-
-console.log(data.event_insert);
-
-// Or, you can use the `Promise` API.
-createEvent(createEventVars).then((response) => {
- const data = response.data;
- console.log(data.event_insert);
-});
-```
-
-### Using `CreateEvent`'s `MutationRef` function
-
-```typescript
-import { getDataConnect, executeMutation } from 'firebase/data-connect';
-import { connectorConfig, createEventRef, CreateEventVariables } from '@dataconnect/generated';
-
-// The `CreateEvent` mutation requires an argument of type `CreateEventVariables`:
-const createEventVars: CreateEventVariables = {
- eventName: ...,
- isRecurring: ...,
- recurrenceType: ..., // optional
- businessId: ...,
- vendorId: ..., // optional
- status: ...,
- date: ...,
- shifts: ..., // optional
- total: ..., // optional
- requested: ..., // optional
- assignedStaff: ..., // optional
-};
-
-// Call the `createEventRef()` function to get a reference to the mutation.
-const ref = createEventRef(createEventVars);
-// Variables can be defined inline as well.
-const ref = createEventRef({ eventName: ..., isRecurring: ..., recurrenceType: ..., businessId: ..., vendorId: ..., status: ..., date: ..., shifts: ..., total: ..., requested: ..., assignedStaff: ..., });
-
-// You can also pass in a `DataConnect` instance to the `MutationRef` function.
-const dataConnect = getDataConnect(connectorConfig);
-const ref = createEventRef(dataConnect, createEventVars);
-
-// Call `executeMutation()` on the reference to execute the mutation.
-// You can use the `await` keyword to wait for the promise to resolve.
-const { data } = await executeMutation(ref);
-
-console.log(data.event_insert);
-
-// Or, you can use the `Promise` API.
-executeMutation(ref).then((response) => {
- const data = response.data;
- console.log(data.event_insert);
-});
-```
-
diff --git a/frontend-web/src/dataconnect-generated/esm/index.esm.js b/frontend-web/src/dataconnect-generated/esm/index.esm.js
deleted file mode 100644
index 3c0301de..00000000
--- a/frontend-web/src/dataconnect-generated/esm/index.esm.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import { queryRef, executeQuery, mutationRef, executeMutation, validateArgs } from 'firebase/data-connect';
-
-export const EventStatus = {
- DRAFT: "DRAFT",
- ACTIVE: "ACTIVE",
- PENDING: "PENDING",
- ASSIGNED: "ASSIGNED",
- CONFIRMED: "CONFIRMED",
- COMPLETED: "COMPLETED",
- CANCELED: "CANCELED",
-}
-
-export const RecurrenceType = {
- SINGLE: "SINGLE",
- DATE_RANGE: "DATE_RANGE",
- SCATTER: "SCATTER",
-}
-
-export const connectorConfig = {
- connector: 'krow-connector',
- service: 'krow-workforce-db',
- location: 'us-central1'
-};
-
-export const listEventsRef = (dc) => {
- const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
- dcInstance._useGeneratedSdk();
- return queryRef(dcInstance, 'listEvents');
-}
-listEventsRef.operationName = 'listEvents';
-
-export function listEvents(dc) {
- return executeQuery(listEventsRef(dc));
-}
-
-export const createEventRef = (dcOrVars, vars) => {
- const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
- dcInstance._useGeneratedSdk();
- return mutationRef(dcInstance, 'CreateEvent', inputVars);
-}
-createEventRef.operationName = 'CreateEvent';
-
-export function createEvent(dcOrVars, vars) {
- return executeMutation(createEventRef(dcOrVars, vars));
-}
-
diff --git a/frontend-web/src/dataconnect-generated/esm/package.json b/frontend-web/src/dataconnect-generated/esm/package.json
deleted file mode 100644
index 7c34deb5..00000000
--- a/frontend-web/src/dataconnect-generated/esm/package.json
+++ /dev/null
@@ -1 +0,0 @@
-{"type":"module"}
\ No newline at end of file
diff --git a/frontend-web/src/dataconnect-generated/index.cjs.js b/frontend-web/src/dataconnect-generated/index.cjs.js
deleted file mode 100644
index 56e9d088..00000000
--- a/frontend-web/src/dataconnect-generated/index.cjs.js
+++ /dev/null
@@ -1,50 +0,0 @@
-const { queryRef, executeQuery, mutationRef, executeMutation, validateArgs } = require('firebase/data-connect');
-
-const EventStatus = {
- DRAFT: "DRAFT",
- ACTIVE: "ACTIVE",
- PENDING: "PENDING",
- ASSIGNED: "ASSIGNED",
- CONFIRMED: "CONFIRMED",
- COMPLETED: "COMPLETED",
- CANCELED: "CANCELED",
-}
-exports.EventStatus = EventStatus;
-
-const RecurrenceType = {
- SINGLE: "SINGLE",
- DATE_RANGE: "DATE_RANGE",
- SCATTER: "SCATTER",
-}
-exports.RecurrenceType = RecurrenceType;
-
-const connectorConfig = {
- connector: 'krow-connector',
- service: 'krow-workforce-db',
- location: 'us-central1'
-};
-exports.connectorConfig = connectorConfig;
-
-const listEventsRef = (dc) => {
- const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
- dcInstance._useGeneratedSdk();
- return queryRef(dcInstance, 'listEvents');
-}
-listEventsRef.operationName = 'listEvents';
-exports.listEventsRef = listEventsRef;
-
-exports.listEvents = function listEvents(dc) {
- return executeQuery(listEventsRef(dc));
-};
-
-const createEventRef = (dcOrVars, vars) => {
- const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
- dcInstance._useGeneratedSdk();
- return mutationRef(dcInstance, 'CreateEvent', inputVars);
-}
-createEventRef.operationName = 'CreateEvent';
-exports.createEventRef = createEventRef;
-
-exports.createEvent = function createEvent(dcOrVars, vars) {
- return executeMutation(createEventRef(dcOrVars, vars));
-};
diff --git a/frontend-web/src/dataconnect-generated/index.d.ts b/frontend-web/src/dataconnect-generated/index.d.ts
deleted file mode 100644
index a0bac852..00000000
--- a/frontend-web/src/dataconnect-generated/index.d.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { ConnectorConfig, DataConnect, QueryRef, QueryPromise, MutationRef, MutationPromise } from 'firebase/data-connect';
-
-export const connectorConfig: ConnectorConfig;
-
-export type TimestampString = string;
-export type UUIDString = string;
-export type Int64String = string;
-export type DateString = string;
-
-
-export enum EventStatus {
- DRAFT = "DRAFT",
- ACTIVE = "ACTIVE",
- PENDING = "PENDING",
- ASSIGNED = "ASSIGNED",
- CONFIRMED = "CONFIRMED",
- COMPLETED = "COMPLETED",
- CANCELED = "CANCELED",
-};
-
-export enum RecurrenceType {
- SINGLE = "SINGLE",
- DATE_RANGE = "DATE_RANGE",
- SCATTER = "SCATTER",
-};
-
-
-
-export interface CreateEventData {
- event_insert: Event_Key;
-}
-
-export interface CreateEventVariables {
- eventName: string;
- isRecurring: boolean;
- recurrenceType?: RecurrenceType | null;
- businessId: UUIDString;
- vendorId?: UUIDString | null;
- status: EventStatus;
- date: TimestampString;
- shifts?: string | null;
- total?: number | null;
- requested?: number | null;
- assignedStaff?: string | null;
-}
-
-export interface Event_Key {
- id: UUIDString;
- __typename?: 'Event_Key';
-}
-
-export interface ListEventsData {
- events: ({
- id: UUIDString;
- eventName: string;
- status: EventStatus;
- date: TimestampString;
- isRecurring: boolean;
- recurrenceType?: RecurrenceType | null;
- businessId: UUIDString;
- vendorId?: UUIDString | null;
- total?: number | null;
- requested?: number | null;
- } & Event_Key)[];
-}
-
-interface ListEventsRef {
- /* Allow users to create refs without passing in DataConnect */
- (): QueryRef;
- /* Allow users to pass in custom DataConnect instances */
- (dc: DataConnect): QueryRef;
- operationName: string;
-}
-export const listEventsRef: ListEventsRef;
-
-export function listEvents(): QueryPromise;
-export function listEvents(dc: DataConnect): QueryPromise;
-
-interface CreateEventRef {
- /* Allow users to create refs without passing in DataConnect */
- (vars: CreateEventVariables): MutationRef;
- /* Allow users to pass in custom DataConnect instances */
- (dc: DataConnect, vars: CreateEventVariables): MutationRef;
- operationName: string;
-}
-export const createEventRef: CreateEventRef;
-
-export function createEvent(vars: CreateEventVariables): MutationPromise;
-export function createEvent(dc: DataConnect, vars: CreateEventVariables): MutationPromise;
-
diff --git a/frontend-web/src/dataconnect-generated/package.json b/frontend-web/src/dataconnect-generated/package.json
deleted file mode 100644
index b686c0a0..00000000
--- a/frontend-web/src/dataconnect-generated/package.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "name": "@dataconnect/generated",
- "version": "1.0.0",
- "author": "Firebase (https://firebase.google.com/)",
- "description": "Generated SDK For krow-connector",
- "license": "Apache-2.0",
- "engines": {
- "node": " >=18.0"
- },
- "typings": "index.d.ts",
- "module": "esm/index.esm.js",
- "main": "index.cjs.js",
- "browser": "esm/index.esm.js",
- "exports": {
- ".": {
- "types": "./index.d.ts",
- "require": "./index.cjs.js",
- "default": "./esm/index.esm.js"
- },
- "./react": {
- "types": "./react/index.d.ts",
- "require": "./react/index.cjs.js",
- "import": "./react/esm/index.esm.js",
- "default": "./react/esm/index.esm.js"
- },
- "./package.json": "./package.json"
- },
- "peerDependencies": {
- "firebase": "^11.3.0 || ^12.0.0",
- "@tanstack-query-firebase/react": "^2.0.0"
- }
-}
\ No newline at end of file
diff --git a/frontend-web/src/dataconnect-generated/react/README.md b/frontend-web/src/dataconnect-generated/react/README.md
deleted file mode 100644
index 24b8cec3..00000000
--- a/frontend-web/src/dataconnect-generated/react/README.md
+++ /dev/null
@@ -1,332 +0,0 @@
-# Generated React README
-This README will guide you through the process of using the generated React SDK package for the connector `krow-connector`. It will also provide examples on how to use your generated SDK to call your Data Connect queries and mutations.
-
-**If you're looking for the `JavaScript README`, you can find it at [`dataconnect-generated/README.md`](../README.md)**
-
-***NOTE:** This README is generated alongside the generated SDK. If you make changes to this file, they will be overwritten when the SDK is regenerated.*
-
-You can use this generated SDK by importing from the package `@dataconnect/generated/react` as shown below. Both CommonJS and ESM imports are supported.
-
-You can also follow the instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#react).
-
-# Table of Contents
-- [**Overview**](#generated-react-readme)
-- [**TanStack Query Firebase & TanStack React Query**](#tanstack-query-firebase-tanstack-react-query)
- - [*Package Installation*](#installing-tanstack-query-firebase-and-tanstack-react-query-packages)
- - [*Configuring TanStack Query*](#configuring-tanstack-query)
-- [**Accessing the connector**](#accessing-the-connector)
- - [*Connecting to the local Emulator*](#connecting-to-the-local-emulator)
-- [**Queries**](#queries)
- - [*listEvents*](#listevents)
-- [**Mutations**](#mutations)
- - [*CreateEvent*](#createevent)
-
-# TanStack Query Firebase & TanStack React Query
-This SDK provides [React](https://react.dev/) hooks generated specific to your application, for the operations found in the connector `krow-connector`. These hooks are generated using [TanStack Query Firebase](https://react-query-firebase.invertase.dev/) by our partners at Invertase, a library built on top of [TanStack React Query v5](https://tanstack.com/query/v5/docs/framework/react/overview).
-
-***You do not need to be familiar with Tanstack Query or Tanstack Query Firebase to use this SDK.*** However, you may find it useful to learn more about them, as they will empower you as a user of this Generated React SDK.
-
-## Installing TanStack Query Firebase and TanStack React Query Packages
-In order to use the React generated SDK, you must install the `TanStack React Query` and `TanStack Query Firebase` packages.
-```bash
-npm i --save @tanstack/react-query @tanstack-query-firebase/react
-```
-```bash
-npm i --save firebase@latest # Note: React has a peer dependency on ^11.3.0
-```
-
-You can also follow the installation instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#tanstack-install), or the [TanStack Query Firebase documentation](https://react-query-firebase.invertase.dev/react) and [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/installation).
-
-## Configuring TanStack Query
-In order to use the React generated SDK in your application, you must wrap your application's component tree in a `QueryClientProvider` component from TanStack React Query. None of your generated React SDK hooks will work without this provider.
-
-```javascript
-import { QueryClientProvider } from '@tanstack/react-query';
-
-// Create a TanStack Query client instance
-const queryClient = new QueryClient()
-
-function App() {
- return (
- // Provide the client to your App
-
-
-
- )
-}
-```
-
-To learn more about `QueryClientProvider`, see the [TanStack React Query documentation](https://tanstack.com/query/latest/docs/framework/react/quick-start) and the [TanStack Query Firebase documentation](https://invertase.docs.page/tanstack-query-firebase/react#usage).
-
-# Accessing the connector
-A connector is a collection of Queries and Mutations. One SDK is generated for each connector - this SDK is generated for the connector `krow-connector`.
-
-You can find more information about connectors in the [Data Connect documentation](https://firebase.google.com/docs/data-connect#how-does).
-
-```javascript
-import { getDataConnect } from 'firebase/data-connect';
-import { connectorConfig } from '@dataconnect/generated';
-
-const dataConnect = getDataConnect(connectorConfig);
-```
-
-## Connecting to the local Emulator
-By default, the connector will connect to the production service.
-
-To connect to the emulator, you can use the following code.
-You can also follow the emulator instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#emulator-react-angular).
-
-```javascript
-import { connectDataConnectEmulator, getDataConnect } from 'firebase/data-connect';
-import { connectorConfig } from '@dataconnect/generated';
-
-const dataConnect = getDataConnect(connectorConfig);
-connectDataConnectEmulator(dataConnect, 'localhost', 9399);
-```
-
-After it's initialized, you can call your Data Connect [queries](#queries) and [mutations](#mutations) using the hooks provided from your generated React SDK.
-
-# Queries
-
-The React generated SDK provides Query hook functions that call and return [`useDataConnectQuery`](https://react-query-firebase.invertase.dev/react/data-connect/querying) hooks from TanStack Query Firebase.
-
-Calling these hook functions will return a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and the most recent data returned by the Query, among other things. To learn more about these hooks and how to use them, see the [TanStack Query Firebase documentation](https://react-query-firebase.invertase.dev/react/data-connect/querying).
-
-TanStack React Query caches the results of your Queries, so using the same Query hook function in multiple places in your application allows the entire application to automatically see updates to that Query's data.
-
-Query hooks execute their Queries automatically when called, and periodically refresh, unless you change the `queryOptions` for the Query. To learn how to stop a Query from automatically executing, including how to make a query "lazy", see the [TanStack React Query documentation](https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries).
-
-To learn more about TanStack React Query's Queries, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/queries).
-
-## Using Query Hooks
-Here's a general overview of how to use the generated Query hooks in your code:
-
-- If the Query has no variables, the Query hook function does not require arguments.
-- If the Query has any required variables, the Query hook function will require at least one argument: an object that contains all the required variables for the Query.
-- If the Query has some required and some optional variables, only required variables are necessary in the variables argument object, and optional variables may be provided as well.
-- If all of the Query's variables are optional, the Query hook function does not require any arguments.
-- Query hook functions can be called with or without passing in a `DataConnect` instance as an argument. If no `DataConnect` argument is passed in, then the generated SDK will call `getDataConnect(connectorConfig)` behind the scenes for you.
-- Query hooks functions can be called with or without passing in an `options` argument of type `useDataConnectQueryOptions`. To learn more about the `options` argument, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/query-options).
- - ***Special case:*** If the Query has all optional variables and you would like to provide an `options` argument to the Query hook function without providing any variables, you must pass `undefined` where you would normally pass the Query's variables, and then may provide the `options` argument.
-
-Below are examples of how to use the `krow-connector` connector's generated Query hook functions to execute each Query. You can also follow the examples from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#operations-react-angular).
-
-## listEvents
-You can execute the `listEvents` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
-
-```javascript
-useListEvents(dc: DataConnect, options?: useDataConnectQueryOptions): UseDataConnectQueryResult;
-```
-You can also pass in a `DataConnect` instance to the Query hook function.
-```javascript
-useListEvents(options?: useDataConnectQueryOptions): UseDataConnectQueryResult;
-```
-
-### Variables
-The `listEvents` Query has no variables.
-### Return Type
-Recall that calling the `listEvents` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
-
-To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
-
-To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `listEvents` Query is of type `ListEventsData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
-```javascript
-export interface ListEventsData {
- events: ({
- id: UUIDString;
- eventName: string;
- status: EventStatus;
- date: TimestampString;
- isRecurring: boolean;
- recurrenceType?: RecurrenceType | null;
- businessId: UUIDString;
- vendorId?: UUIDString | null;
- total?: number | null;
- requested?: number | null;
- } & Event_Key)[];
-}
-```
-
-To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
-
-### Using `listEvents`'s Query hook function
-
-```javascript
-import { getDataConnect } from 'firebase/data-connect';
-import { connectorConfig } from '@dataconnect/generated';
-import { useListEvents } from '@dataconnect/generated/react'
-
-export default function ListEventsComponent() {
- // You don't have to do anything to "execute" the Query.
- // Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
- const query = useListEvents();
-
- // You can also pass in a `DataConnect` instance to the Query hook function.
- const dataConnect = getDataConnect(connectorConfig);
- const query = useListEvents(dataConnect);
-
- // You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
- const options = { staleTime: 5 * 1000 };
- const query = useListEvents(options);
-
- // You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
- const dataConnect = getDataConnect(connectorConfig);
- const options = { staleTime: 5 * 1000 };
- const query = useListEvents(dataConnect, options);
-
- // Then, you can render your component dynamically based on the status of the Query.
- if (query.isPending) {
- return Loading...
;
- }
-
- if (query.isError) {
- return Error: {query.error.message}
;
- }
-
- // If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
- if (query.isSuccess) {
- console.log(query.data.events);
- }
- return Query execution {query.isSuccess ? 'successful' : 'failed'}!
;
-}
-```
-
-# Mutations
-
-The React generated SDK provides Mutations hook functions that call and return [`useDataConnectMutation`](https://react-query-firebase.invertase.dev/react/data-connect/mutations) hooks from TanStack Query Firebase.
-
-Calling these hook functions will return a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, and the most recent data returned by the Mutation, among other things. To learn more about these hooks and how to use them, see the [TanStack Query Firebase documentation](https://react-query-firebase.invertase.dev/react/data-connect/mutations).
-
-Mutation hooks do not execute their Mutations automatically when called. Rather, after calling the Mutation hook function and getting a `UseMutationResult` object, you must call the `UseMutationResult.mutate()` function to execute the Mutation.
-
-To learn more about TanStack React Query's Mutations, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/mutations).
-
-## Using Mutation Hooks
-Here's a general overview of how to use the generated Mutation hooks in your code:
-
-- Mutation hook functions are not called with the arguments to the Mutation. Instead, arguments are passed to `UseMutationResult.mutate()`.
-- If the Mutation has no variables, the `mutate()` function does not require arguments.
-- If the Mutation has any required variables, the `mutate()` function will require at least one argument: an object that contains all the required variables for the Mutation.
-- If the Mutation has some required and some optional variables, only required variables are necessary in the variables argument object, and optional variables may be provided as well.
-- If all of the Mutation's variables are optional, the Mutation hook function does not require any arguments.
-- Mutation hook functions can be called with or without passing in a `DataConnect` instance as an argument. If no `DataConnect` argument is passed in, then the generated SDK will call `getDataConnect(connectorConfig)` behind the scenes for you.
-- Mutation hooks also accept an `options` argument of type `useDataConnectMutationOptions`. To learn more about the `options` argument, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/mutations#mutation-side-effects).
- - `UseMutationResult.mutate()` also accepts an `options` argument of type `useDataConnectMutationOptions`.
- - ***Special case:*** If the Mutation has no arguments (or all optional arguments and you wish to provide none), and you want to pass `options` to `UseMutationResult.mutate()`, you must pass `undefined` where you would normally pass the Mutation's arguments, and then may provide the options argument.
-
-Below are examples of how to use the `krow-connector` connector's generated Mutation hook functions to execute each Mutation. You can also follow the examples from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#operations-react-angular).
-
-## CreateEvent
-You can execute the `CreateEvent` Mutation using the `UseMutationResult` object returned by the following Mutation hook function (which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts)):
-```javascript
-useCreateEvent(options?: useDataConnectMutationOptions): UseDataConnectMutationResult;
-```
-You can also pass in a `DataConnect` instance to the Mutation hook function.
-```javascript
-useCreateEvent(dc: DataConnect, options?: useDataConnectMutationOptions): UseDataConnectMutationResult;
-```
-
-### Variables
-The `CreateEvent` Mutation requires an argument of type `CreateEventVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
-
-```javascript
-export interface CreateEventVariables {
- eventName: string;
- isRecurring: boolean;
- recurrenceType?: RecurrenceType | null;
- businessId: UUIDString;
- vendorId?: UUIDString | null;
- status: EventStatus;
- date: TimestampString;
- shifts?: string | null;
- total?: number | null;
- requested?: number | null;
- assignedStaff?: string | null;
-}
-```
-### Return Type
-Recall that calling the `CreateEvent` Mutation hook function returns a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, among other things.
-
-To check the status of a Mutation, use the `UseMutationResult.status` field. You can also check for pending / success / error status using the `UseMutationResult.isPending`, `UseMutationResult.isSuccess`, and `UseMutationResult.isError` fields.
-
-To execute the Mutation, call `UseMutationResult.mutate()`. This function executes the Mutation, but does not return the data from the Mutation.
-
-To access the data returned by a Mutation, use the `UseMutationResult.data` field. The data for the `CreateEvent` Mutation is of type `CreateEventData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
-```javascript
-export interface CreateEventData {
- event_insert: Event_Key;
-}
-```
-
-To learn more about the `UseMutationResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useMutation).
-
-### Using `CreateEvent`'s Mutation hook function
-
-```javascript
-import { getDataConnect } from 'firebase/data-connect';
-import { connectorConfig, CreateEventVariables } from '@dataconnect/generated';
-import { useCreateEvent } from '@dataconnect/generated/react'
-
-export default function CreateEventComponent() {
- // Call the Mutation hook function to get a `UseMutationResult` object which holds the state of your Mutation.
- const mutation = useCreateEvent();
-
- // You can also pass in a `DataConnect` instance to the Mutation hook function.
- const dataConnect = getDataConnect(connectorConfig);
- const mutation = useCreateEvent(dataConnect);
-
- // You can also pass in a `useDataConnectMutationOptions` object to the Mutation hook function.
- const options = {
- onSuccess: () => { console.log('Mutation succeeded!'); }
- };
- const mutation = useCreateEvent(options);
-
- // You can also pass both a `DataConnect` instance and a `useDataConnectMutationOptions` object.
- const dataConnect = getDataConnect(connectorConfig);
- const options = {
- onSuccess: () => { console.log('Mutation succeeded!'); }
- };
- const mutation = useCreateEvent(dataConnect, options);
-
- // After calling the Mutation hook function, you must call `UseMutationResult.mutate()` to execute the Mutation.
- // The `useCreateEvent` Mutation requires an argument of type `CreateEventVariables`:
- const createEventVars: CreateEventVariables = {
- eventName: ...,
- isRecurring: ...,
- recurrenceType: ..., // optional
- businessId: ...,
- vendorId: ..., // optional
- status: ...,
- date: ...,
- shifts: ..., // optional
- total: ..., // optional
- requested: ..., // optional
- assignedStaff: ..., // optional
- };
- mutation.mutate(createEventVars);
- // Variables can be defined inline as well.
- mutation.mutate({ eventName: ..., isRecurring: ..., recurrenceType: ..., businessId: ..., vendorId: ..., status: ..., date: ..., shifts: ..., total: ..., requested: ..., assignedStaff: ..., });
-
- // You can also pass in a `useDataConnectMutationOptions` object to `UseMutationResult.mutate()`.
- const options = {
- onSuccess: () => { console.log('Mutation succeeded!'); }
- };
- mutation.mutate(createEventVars, options);
-
- // Then, you can render your component dynamically based on the status of the Mutation.
- if (mutation.isPending) {
- return Loading...
;
- }
-
- if (mutation.isError) {
- return Error: {mutation.error.message}
;
- }
-
- // If the Mutation is successful, you can access the data returned using the `UseMutationResult.data` field.
- if (mutation.isSuccess) {
- console.log(mutation.data.event_insert);
- }
- return Mutation execution {mutation.isSuccess ? 'successful' : 'failed'}!
;
-}
-```
-
diff --git a/frontend-web/src/dataconnect-generated/react/esm/index.esm.js b/frontend-web/src/dataconnect-generated/react/esm/index.esm.js
deleted file mode 100644
index 30b9f593..00000000
--- a/frontend-web/src/dataconnect-generated/react/esm/index.esm.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import { listEventsRef, createEventRef, connectorConfig } from '../../esm/index.esm.js';
-import { validateArgs, CallerSdkTypeEnum } from 'firebase/data-connect';
-import { useDataConnectQuery, useDataConnectMutation, validateReactArgs } from '@tanstack-query-firebase/react/data-connect';
-
-
-export function useListEvents(dcOrOptions, options) {
- const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
- const ref = listEventsRef(dcInstance);
- return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
-}
-export function useCreateEvent(dcOrOptions, options) {
- const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
- function refFactory(vars) {
- return createEventRef(dcInstance, vars);
- }
- return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
-}
diff --git a/frontend-web/src/dataconnect-generated/react/esm/package.json b/frontend-web/src/dataconnect-generated/react/esm/package.json
deleted file mode 100644
index 7c34deb5..00000000
--- a/frontend-web/src/dataconnect-generated/react/esm/package.json
+++ /dev/null
@@ -1 +0,0 @@
-{"type":"module"}
\ No newline at end of file
diff --git a/frontend-web/src/dataconnect-generated/react/index.cjs.js b/frontend-web/src/dataconnect-generated/react/index.cjs.js
deleted file mode 100644
index 7a777110..00000000
--- a/frontend-web/src/dataconnect-generated/react/index.cjs.js
+++ /dev/null
@@ -1,17 +0,0 @@
-const { listEventsRef, createEventRef, connectorConfig } = require('../index.cjs.js');
-const { validateArgs, CallerSdkTypeEnum } = require('firebase/data-connect');
-const { useDataConnectQuery, useDataConnectMutation, validateReactArgs } = require('@tanstack-query-firebase/react/data-connect');
-
-
-exports.useListEvents = function useListEvents(dcOrOptions, options) {
- const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
- const ref = listEventsRef(dcInstance);
- return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
-}
-exports.useCreateEvent = function useCreateEvent(dcOrOptions, options) {
- const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
- function refFactory(vars) {
- return createEventRef(dcInstance, vars);
- }
- return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
-}
diff --git a/frontend-web/src/dataconnect-generated/react/index.d.ts b/frontend-web/src/dataconnect-generated/react/index.d.ts
deleted file mode 100644
index c9a73eb5..00000000
--- a/frontend-web/src/dataconnect-generated/react/index.d.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { ListEventsData, CreateEventData, CreateEventVariables } from '../';
-import { UseDataConnectQueryResult, useDataConnectQueryOptions, UseDataConnectMutationResult, useDataConnectMutationOptions} from '@tanstack-query-firebase/react/data-connect';
-import { UseQueryResult, UseMutationResult} from '@tanstack/react-query';
-import { DataConnect } from 'firebase/data-connect';
-import { FirebaseError } from 'firebase/app';
-
-
-export function useListEvents(options?: useDataConnectQueryOptions): UseDataConnectQueryResult;
-export function useListEvents(dc: DataConnect, options?: useDataConnectQueryOptions): UseDataConnectQueryResult;
-
-export function useCreateEvent(options?: useDataConnectMutationOptions): UseDataConnectMutationResult;
-export function useCreateEvent(dc: DataConnect, options?: useDataConnectMutationOptions): UseDataConnectMutationResult;
diff --git a/frontend-web/src/dataconnect-generated/react/package.json b/frontend-web/src/dataconnect-generated/react/package.json
deleted file mode 100644
index 512c7ec3..00000000
--- a/frontend-web/src/dataconnect-generated/react/package.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "name": "@dataconnect/generated-react",
- "version": "1.0.0",
- "author": "Firebase (https://firebase.google.com/)",
- "description": "Generated SDK For krow-connector",
- "license": "Apache-2.0",
- "engines": {
- "node": " >=18.0"
- },
- "typings": "index.d.ts",
- "main": "index.cjs.js",
- "module": "esm/index.esm.js",
- "browser": "esm/index.esm.js",
- "peerDependencies": {
- "@tanstack-query-firebase/react": "^2.0.0"
- }
-}
\ No newline at end of file
diff --git a/frontend-web/src/lib/firebaseConfig.js b/frontend-web/src/lib/firebaseConfig.js
deleted file mode 100644
index b73269da..00000000
--- a/frontend-web/src/lib/firebaseConfig.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { getApps, initializeApp, getApp } from 'firebase/app';
-import { getDataConnect } from 'firebase/data-connect';
-import { getAuth } from 'firebase/auth';
-import { connectorConfig } from '@dataconnect/generated';
-
-const firebaseConfig = {
- apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
- authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
- projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
- appId: import.meta.env.VITE_FIREBASE_APP_ID,
-};
-
-export function getFirebaseApp() {
- if (getApps().length === 0) {
- return initializeApp(firebaseConfig);
- }
- return getApp();
-}
-
-export const app = getFirebaseApp();
-export const dataConnect = getDataConnect(app, connectorConfig);
-export const auth = getAuth(app);
\ No newline at end of file
diff --git a/frontend-web/src/pages/ClientDashboard.jsx b/frontend-web/src/pages/ClientDashboard.jsx
index 29c1dd00..9ea75f69 100644
--- a/frontend-web/src/pages/ClientDashboard.jsx
+++ b/frontend-web/src/pages/ClientDashboard.jsx
@@ -25,17 +25,23 @@ const COLORS = ['#0A39DF', '#6366f1', '#8b5cf6', '#a855f7', '#c026d3', '#d946ef'
const convertTo12Hour = (time24) => {
if (!time24 || time24 === "—") return time24;
+
try {
const parts = time24.split(':');
if (!parts || parts.length < 2) return time24;
+
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
+
if (isNaN(hours) || isNaN(minutes)) return time24;
+
const period = hours >= 12 ? 'PM' : 'AM';
const hours12 = hours % 12 || 12;
const minutesStr = minutes.toString().padStart(2, '0');
+
return `${hours12}:${minutesStr} ${period}`;
} catch (error) {
+ console.error('Error converting time:', error);
return time24;
}
};
diff --git a/frontend-web/src/pages/ClientOrders.jsx b/frontend-web/src/pages/ClientOrders.jsx
index 65948358..9b80ac03 100644
--- a/frontend-web/src/pages/ClientOrders.jsx
+++ b/frontend-web/src/pages/ClientOrders.jsx
@@ -1,4 +1,3 @@
-
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -24,10 +23,11 @@ import {
} from "@/components/ui/tabs"; // New import
import {
Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2
- Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus
+ Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus, Building2, Bell, Edit3
} from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { format, parseISO, isValid } from "date-fns";
+import OrderDetailModal from "@/components/orders/OrderDetailModal";
const safeParseDate = (dateString) => {
if (!dateString) return null;
@@ -94,6 +94,8 @@ export default function ClientOrders() {
const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open
const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order
+ const [viewOrderModal, setViewOrderModal] = useState(false);
+ const [selectedOrder, setSelectedOrder] = useState(null);
const { data: user } = useQuery({
queryKey: ['current-user-client-orders'],
@@ -180,6 +182,11 @@ export default function ClientOrders() {
setCancelDialogOpen(true); // Updated
};
+ const handleViewOrder = (order) => {
+ setSelectedOrder(order);
+ setViewOrderModal(true);
+ };
+
const confirmCancel = () => {
if (orderToCancel) { // Updated
cancelOrderMutation.mutate(orderToCancel.id); // Updated
@@ -332,118 +339,115 @@ export default function ClientOrders() {
{/* CardContent padding updated */}
- {/* TableRow class updated */}
- Order {/* Updated */}
- Date {/* Updated */}
- Location {/* Updated */}
- Time {/* Updated */}
- Status {/* Updated */}
- Staff {/* Updated */}
- Invoice {/* Updated */}
- Actions {/* Updated */}
+
+ Business
+ Hub
+ Event
+ Date & Time
+ Status
+ Requested
+ Assigned
+ Invoice
+ Actions
- {filteredOrders.length === 0 ? ( // Using filteredOrders
+ {filteredOrders.length === 0 ? (
- {/* Colspan updated */}
- {/* Icon updated */}
+
+
No orders found
) : (
- filteredOrders.map((order) => { // Using filteredOrders, renamed event to order
- const assignment = getAssignmentStatus(order);
+ filteredOrders.map((order) => {
+ const assignedCount = order.assigned_staff?.length || 0;
+ const requestedCount = order.requested || 0;
+ const assignmentProgress = requestedCount > 0 ? Math.round((assignedCount / requestedCount) * 100) : 0;
const { startTime, endTime } = getEventTimes(order);
- const invoiceReady = order.status === "Completed";
- // const eventDate = safeParseDate(order.date); // Not directly used here, safeFormatDate handles it.
return (
- {/* Order cell */}
-
-
{order.event_name}
-
{order.business_name || "—"}
+
+
+
+ {order.business_name || "Primary Location"}
-
{/* Date cell */}
+
+
+
+ {order.hub || "Main Hub"}
+
+
+
+ {order.event_name || "Untitled Event"}
+
+
-
- {safeFormatDate(order.date, 'MMM dd, yyyy')}
-
-
- {safeFormatDate(order.date, 'EEEE')}
+
{safeFormatDate(order.date, 'MM.dd.yyyy')}
+
+
+ {startTime} - {endTime}
- {/* Location cell */}
-
-
- {order.hub || order.event_location || "—"}
-
-
- {/* Time cell */}
-
-
- {startTime} - {endTime}
-
-
- {/* Status cell */}
+
{getStatusBadge(order)}
- {/* Staff cell */}
+
+ {requestedCount}
+
+
-
- {assignment.assigned} / {assignment.requested}
-
-
- {assignment.percentage}%
-
+
+ {assignedCount}
+
+
{assignmentProgress}%
- {/* Invoice cell */}
-
- invoiceReady && navigate(createPageUrl('Invoices'))}
- className={`w-10 h-10 rounded-full flex items-center justify-center ${invoiceReady ? 'bg-blue-100' : 'bg-slate-100'} ${invoiceReady ? 'cursor-pointer hover:bg-blue-200' : 'cursor-not-allowed opacity-50'}`}
- disabled={!invoiceReady}
- title={invoiceReady ? "View Invoice" : "Invoice not available"}
- >
-
-
-
+
+
+
+
- {/* Actions cell */}
-
+
+
navigate(createPageUrl(`EventDetail?id=${order.id}`))}
- className="hover:bg-slate-100"
- title="View details"
+ onClick={() => handleViewOrder(order)}
+ className="h-8 w-8 p-0"
+ title="View"
>
+
+
+
{canEditOrder(order) && (
navigate(createPageUrl(`EditEvent?id=${order.id}`))}
- className="hover:bg-slate-100"
- title="Edit order"
+ className="h-8 w-8 p-0"
+ title="Edit"
>
- {/* Changed from Edit2 */}
+
)}
{canCancelOrder(order) && (
handleCancelOrder(order)} // Updated
- className="hover:bg-red-50 hover:text-red-600"
- title="Cancel order"
+ onClick={() => handleCancelOrder(order)}
+ className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
+ title="Cancel"
>
@@ -460,6 +464,13 @@ export default function ClientOrders() {
+ setViewOrderModal(false)}
+ order={selectedOrder}
+ onCancel={handleCancelOrder}
+ />
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend-web/src/pages/CreateEvent.jsx b/frontend-web/src/pages/CreateEvent.jsx
index c7aa9233..3f90b174 100644
--- a/frontend-web/src/pages/CreateEvent.jsx
+++ b/frontend-web/src/pages/CreateEvent.jsx
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import EventFormWizard from "@/components/events/EventFormWizard";
+import RapidOrderInterface from "@/components/orders/RapidOrderInterface";
import { useToast } from "@/components/ui/use-toast";
import { Button } from "@/components/ui/button";
import { X, AlertTriangle } from "lucide-react";
@@ -16,6 +17,7 @@ export default function CreateEvent() {
const { toast } = useToast();
const [pendingEvent, setPendingEvent] = React.useState(null);
const [showConflictWarning, setShowConflictWarning] = React.useState(false);
+ const [showRapidInterface, setShowRapidInterface] = React.useState(false);
const { data: currentUser } = useQuery({
queryKey: ['current-user-create-event'],
@@ -48,15 +50,56 @@ export default function CreateEvent() {
},
});
+ const handleRapidSubmit = (rapidData) => {
+ // Convert rapid order message to event data
+ const eventData = {
+ event_name: "RAPID Order",
+ order_type: "rapid",
+ date: new Date().toISOString().split('T')[0],
+ status: "Active",
+ notes: rapidData.rawMessage,
+ shifts: [{
+ shift_name: "Shift 1",
+ location_address: "",
+ same_as_billing: true,
+ roles: [{
+ role: "",
+ department: "",
+ count: 1,
+ start_time: "09:00",
+ end_time: "17:00",
+ hours: 8,
+ uniform: "Type 1",
+ break_minutes: 15,
+ rate_per_hour: 0,
+ total_value: 0
+ }]
+ }],
+ requested: 1
+ };
+
+ createEventMutation.mutate(eventData);
+ };
+
const handleSubmit = (eventData) => {
+ // CRITICAL: Calculate total requested count from all roles before creating
+ const totalRequested = eventData.shifts.reduce((sum, shift) => {
+ return sum + shift.roles.reduce((roleSum, role) => roleSum + (parseInt(role.count) || 0), 0);
+ }, 0);
+
+ const eventDataWithRequested = {
+ ...eventData,
+ requested: totalRequested // Set exact requested count
+ };
+
// Detect conflicts before creating
- const conflicts = detectAllConflicts(eventData, allEvents);
+ const conflicts = detectAllConflicts(eventDataWithRequested, allEvents);
if (conflicts.length > 0) {
- setPendingEvent({ ...eventData, detected_conflicts: conflicts });
+ setPendingEvent({ ...eventDataWithRequested, detected_conflicts: conflicts });
setShowConflictWarning(true);
} else {
- createEventMutation.mutate(eventData);
+ createEventMutation.mutate(eventDataWithRequested);
}
};
@@ -137,6 +180,7 @@ export default function CreateEvent() {
navigate(createPageUrl("ClientDashboard"))}
diff --git a/frontend-web/src/pages/EditEvent.jsx b/frontend-web/src/pages/EditEvent.jsx
index fd9e9862..d097f88d 100644
--- a/frontend-web/src/pages/EditEvent.jsx
+++ b/frontend-web/src/pages/EditEvent.jsx
@@ -1,17 +1,29 @@
-import React from "react";
+import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Loader2 } from "lucide-react";
-import EventForm from "@/components/events/EventForm";
+import EventFormWizard from "@/components/events/EventFormWizard";
+import OrderReductionAlert from "@/components/orders/OrderReductionAlert";
+import { useToast } from "@/components/ui/use-toast";
export default function EditEvent() {
const navigate = useNavigate();
const queryClient = useQueryClient();
+ const { toast } = useToast();
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get('id');
+
+ const [showReductionAlert, setShowReductionAlert] = useState(false);
+ const [pendingUpdate, setPendingUpdate] = useState(null);
+ const [originalRequested, setOriginalRequested] = useState(0);
+
+ const { data: user } = useQuery({
+ queryKey: ['current-user-edit-event'],
+ queryFn: () => base44.auth.me(),
+ });
const { data: allEvents, isLoading } = useQuery({
queryKey: ['events'],
@@ -19,7 +31,19 @@ export default function EditEvent() {
initialData: [],
});
+ const { data: allStaff = [] } = useQuery({
+ queryKey: ['staff-for-reduction'],
+ queryFn: () => base44.entities.Staff.list(),
+ initialData: [],
+ });
+
const event = allEvents.find(e => e.id === eventId);
+
+ useEffect(() => {
+ if (event) {
+ setOriginalRequested(event.requested || 0);
+ }
+ }, [event]);
const updateEventMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
@@ -30,7 +54,97 @@ export default function EditEvent() {
});
const handleSubmit = (eventData) => {
- updateEventMutation.mutate({ id: eventId, data: eventData });
+ // CRITICAL: Recalculate requested count from current roles
+ const totalRequested = eventData.shifts.reduce((sum, shift) => {
+ return sum + shift.roles.reduce((roleSum, role) => roleSum + (parseInt(role.count) || 0), 0);
+ }, 0);
+
+ const assignedCount = event.assigned_staff?.length || 0;
+ const isVendor = user?.user_role === 'vendor' || user?.role === 'vendor';
+
+ // If client is reducing headcount and vendor has already assigned staff
+ if (!isVendor && totalRequested < originalRequested && assignedCount > totalRequested) {
+ setPendingUpdate({ ...eventData, requested: totalRequested });
+ setShowReductionAlert(true);
+
+ // Notify vendor via email
+ if (event.vendor_name) {
+ base44.integrations.Core.SendEmail({
+ to: `${event.vendor_name}@example.com`,
+ subject: `⚠️ Order Reduced: ${event.event_name}`,
+ body: `Client has reduced headcount for order: ${event.event_name}\n\nOriginal: ${originalRequested} staff\nNew: ${totalRequested} staff\nCurrently Assigned: ${assignedCount} staff\n\nExcess: ${assignedCount - totalRequested} staff must be removed.\n\nPlease log in to adjust assignments.`
+ }).catch(console.error);
+ }
+
+ toast({
+ title: "⚠️ Headcount Reduced",
+ description: "Vendor has been notified to adjust staff assignments",
+ });
+ return;
+ }
+
+ // Normal update
+ updateEventMutation.mutate({
+ id: eventId,
+ data: {
+ ...eventData,
+ requested: totalRequested
+ }
+ });
+ };
+
+ const handleAutoUnassign = async () => {
+ if (!pendingUpdate) return;
+
+ const assignedStaff = event.assigned_staff || [];
+ const excessCount = assignedStaff.length - pendingUpdate.requested;
+
+ // Calculate reliability scores for assigned staff
+ const staffWithScores = assignedStaff.map(assigned => {
+ const staffData = allStaff.find(s => s.id === assigned.staff_id);
+ return {
+ ...assigned,
+ reliability: staffData?.reliability_score || 50,
+ total_shifts: staffData?.total_shifts || 0,
+ no_shows: staffData?.no_show_count || 0,
+ cancellations: staffData?.cancellation_count || 0
+ };
+ });
+
+ // Sort by reliability (lowest first)
+ staffWithScores.sort((a, b) => a.reliability - b.reliability);
+
+ // Remove lowest reliability staff
+ const staffToKeep = staffWithScores.slice(excessCount);
+
+ await updateEventMutation.mutateAsync({
+ id: eventId,
+ data: {
+ ...pendingUpdate,
+ assigned_staff: staffToKeep.map(s => ({
+ staff_id: s.staff_id,
+ staff_name: s.staff_name,
+ email: s.email,
+ role: s.role
+ }))
+ }
+ });
+
+ setShowReductionAlert(false);
+ setPendingUpdate(null);
+
+ toast({
+ title: "✅ Staff Auto-Unassigned",
+ description: `Removed ${excessCount} lowest reliability staff members`,
+ });
+ };
+
+ const handleManualUnassign = () => {
+ setShowReductionAlert(false);
+ toast({
+ title: "Manual Adjustment Required",
+ description: "Please manually remove excess staff from the order",
+ });
};
if (isLoading) {
@@ -54,7 +168,7 @@ export default function EditEvent() {
return (
-
+
Update information for {event.event_name}
-
+ {
+ const staffData = allStaff.find(s => s.id === assigned.staff_id);
+ return {
+ name: assigned.staff_name,
+ reliability: staffData?.reliability_score || 50
+ };
+ }).sort((a, b) => a.reliability - b.reliability)}
+ />
+
+ )}
+
+
navigate(createPageUrl("Events"))}
/>
diff --git a/frontend-web/src/pages/EventDetail.jsx b/frontend-web/src/pages/EventDetail.jsx
index b7c5b5c3..59dd8e0a 100644
--- a/frontend-web/src/pages/EventDetail.jsx
+++ b/frontend-web/src/pages/EventDetail.jsx
@@ -17,6 +17,7 @@ import {
import { ArrowLeft, Calendar, MapPin, Users, DollarSign, Send, Edit3, X, AlertTriangle } from "lucide-react";
import ShiftCard from "@/components/events/ShiftCard";
import OrderStatusBadge from "@/components/orders/OrderStatusBadge";
+import CancellationFeeModal from "@/components/orders/CancellationFeeModal";
import { useToast } from "@/components/ui/use-toast";
import { format } from "date-fns";
@@ -35,6 +36,7 @@ export default function EventDetail() {
const { toast } = useToast();
const [notifyDialog, setNotifyDialog] = useState(false);
const [cancelDialog, setCancelDialog] = useState(false);
+ const [showCancellationFeeModal, setShowCancellationFeeModal] = useState(false);
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get("id");
@@ -58,11 +60,21 @@ export default function EventDetail() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
+
+ // Notify vendor
+ if (event.vendor_name && event.vendor_id) {
+ base44.integrations.Core.SendEmail({
+ to: `${event.vendor_name}@example.com`,
+ subject: `Order Canceled: ${event.event_name}`,
+ body: `Client has canceled order: ${event.event_name}\nDate: ${event.date}\nLocation: ${event.hub || event.event_location}`
+ }).catch(console.error);
+ }
+
toast({
title: "✅ Order Canceled",
description: "Your order has been canceled successfully",
});
- setCancelDialog(false);
+ setShowCancellationFeeModal(false);
navigate(createPageUrl("ClientOrders"));
},
onError: () => {
@@ -74,6 +86,14 @@ export default function EventDetail() {
},
});
+ const handleCancelClick = () => {
+ setShowCancellationFeeModal(true);
+ };
+
+ const handleConfirmCancellation = () => {
+ cancelOrderMutation.mutate();
+ };
+
const handleNotifyStaff = async () => {
const assignedStaff = event?.assigned_staff || [];
@@ -171,11 +191,11 @@ export default function EventDetail() {
)}
{canCancelOrder() && (
setCancelDialog(true)}
+ onClick={handleCancelClick}
className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-red-50 border-2 border-red-200 rounded-full text-red-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
>
- cancel
+ Cancel Order
)}
{!isClient && event.assigned_staff?.length > 0 && (
@@ -269,7 +289,7 @@ export default function EventDetail() {
Event Shifts & Staff Assignment
{eventShifts.length > 0 ? (
eventShifts.map((shift, idx) => (
-
+
))
) : (
@@ -316,48 +336,14 @@ export default function EventDetail() {
- {/* Cancel Order Dialog */}
-
+ {/* Cancellation Fee Modal */}
+ setShowCancellationFeeModal(false)}
+ onConfirm={handleConfirmCancellation}
+ event={event}
+ isSubmitting={cancelOrderMutation.isPending}
+ />
);
}
\ No newline at end of file
diff --git a/frontend-web/src/pages/Events.jsx b/frontend-web/src/pages/Events.jsx
index afcd712f..ce40db91 100644
--- a/frontend-web/src/pages/Events.jsx
+++ b/frontend-web/src/pages/Events.jsx
@@ -1,4 +1,3 @@
-
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
@@ -444,18 +443,32 @@ export default function Events() {
-
-
-
- setSearchTerm(e.target.value)} className="pl-10 border-slate-200 h-10" />
-
-
-
setViewMode("table")} className={viewMode === "table" ? "bg-[#0A39DF]" : ""}>
-
-
-
setViewMode("scheduler")} className={viewMode === "scheduler" ? "bg-[#0A39DF]" : ""}>
-
-
+
+
+
+
+ setSearchTerm(e.target.value)} className="pl-10 border-slate-200 h-11" />
+
+
+ setViewMode("table")}
+ className={`${viewMode === "table" ? "bg-blue-600 text-white hover:bg-blue-700 shadow-lg" : "hover:bg-white/50"} h-11 px-6 font-semibold cursor-pointer`}
+ >
+
+ Table View
+
+ setViewMode("scheduler")}
+ className={`${viewMode === "scheduler" ? "bg-blue-600 text-white hover:bg-blue-700 shadow-lg" : "hover:bg-white/50"} h-11 px-6 font-semibold cursor-pointer`}
+ >
+
+ Scheduler View
+
+
@@ -688,4 +701,4 @@ export default function Events() {
setAssignModal({ open: false, event: null, shift: null, role: null })} event={assignModal.event} shift={assignModal.shift} role={assignModal.role} />
);
-}
+}
\ No newline at end of file
diff --git a/frontend-web/src/pages/InvoiceDetail.jsx b/frontend-web/src/pages/InvoiceDetail.jsx
new file mode 100644
index 00000000..73bd9c16
--- /dev/null
+++ b/frontend-web/src/pages/InvoiceDetail.jsx
@@ -0,0 +1,68 @@
+import React from "react";
+import { useQuery } from "@tanstack/react-query";
+import { base44 } from "@/api/base44Client";
+import { useNavigate } from "react-router-dom";
+import { createPageUrl } from "@/utils";
+import InvoiceDetailView from "@/components/invoices/InvoiceDetailView";
+import { Button } from "@/components/ui/button";
+import { ArrowLeft } from "lucide-react";
+
+export default function InvoiceDetail() {
+ const navigate = useNavigate();
+ const urlParams = new URLSearchParams(window.location.search);
+ const invoiceId = urlParams.get('id');
+
+ const { data: user } = useQuery({
+ queryKey: ['current-user-invoice-detail'],
+ queryFn: () => base44.auth.me(),
+ });
+
+ const { data: invoices = [], isLoading } = useQuery({
+ queryKey: ['invoices'],
+ queryFn: () => base44.entities.Invoice.list(),
+ });
+
+ const invoice = invoices.find(inv => inv.id === invoiceId);
+ const userRole = user?.user_role || user?.role;
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!invoice) {
+ return (
+
+
+
Invoice not found
+
navigate(createPageUrl('Invoices'))}>
+
+ Back to Invoices
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
navigate(createPageUrl('Invoices'))}
+ className="bg-white shadow-lg"
+ >
+
+ Back
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend-web/src/pages/InvoiceEditor.jsx b/frontend-web/src/pages/InvoiceEditor.jsx
new file mode 100644
index 00000000..15ab27d9
--- /dev/null
+++ b/frontend-web/src/pages/InvoiceEditor.jsx
@@ -0,0 +1,869 @@
+import React, { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { base44 } from "@/api/base44Client";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+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 { Card } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { ArrowLeft, Plus, Trash2, Clock } from "lucide-react";
+import { createPageUrl } from "@/utils";
+import { useToast } from "@/components/ui/use-toast";
+import { format, addDays } from "date-fns";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+
+export default function InvoiceEditor() {
+ const navigate = useNavigate();
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+ const urlParams = new URLSearchParams(window.location.search);
+ const invoiceId = urlParams.get('id');
+ const isEdit = !!invoiceId;
+
+ const { data: user } = useQuery({
+ queryKey: ['current-user-invoice-editor'],
+ queryFn: () => base44.auth.me(),
+ });
+
+ const { data: invoices = [] } = useQuery({
+ queryKey: ['invoices'],
+ queryFn: () => base44.entities.Invoice.list(),
+ enabled: isEdit,
+ });
+
+ const { data: events = [] } = useQuery({
+ queryKey: ['events-for-invoice'],
+ queryFn: () => base44.entities.Event.list(),
+ });
+
+ const existingInvoice = invoices.find(inv => inv.id === invoiceId);
+
+ const [formData, setFormData] = useState({
+ invoice_number: existingInvoice?.invoice_number || `INV-G00G${Math.floor(Math.random() * 100000)}`,
+ event_id: existingInvoice?.event_id || "",
+ event_name: existingInvoice?.event_name || "",
+ invoice_date: existingInvoice?.issue_date || format(new Date(), 'yyyy-MM-dd'),
+ due_date: existingInvoice?.due_date || format(addDays(new Date(), 30), 'yyyy-MM-dd'),
+ payment_terms: existingInvoice?.payment_terms || "30",
+ hub: existingInvoice?.hub || "",
+ manager: existingInvoice?.manager_name || "",
+ vendor_id: existingInvoice?.vendor_id || "",
+ department: existingInvoice?.department || "",
+ po_reference: existingInvoice?.po_reference || "",
+ from_company: existingInvoice?.from_company || {
+ name: "Legendary Event Staffing",
+ address: "848 E Gish Rd Ste 1, San Jose, CA 95112",
+ phone: "(408) 936-0180",
+ email: "order@legendaryeventstaff.com"
+ },
+ to_company: existingInvoice?.to_company || {
+ name: "Thinkloops",
+ phone: "4086702861",
+ email: "mohsin@thikloops.com",
+ address: "Dublin St, San Francisco, CA 94112, USA",
+ manager_name: "Manager Name",
+ hub_name: "Hub Name",
+ vendor_id: "Vendor #"
+ },
+ staff_entries: existingInvoice?.roles?.[0]?.staff_entries || [],
+ charges: existingInvoice?.charges || [],
+ other_charges: existingInvoice?.other_charges || 0,
+ notes: existingInvoice?.notes || "",
+ });
+
+ const [timePickerOpen, setTimePickerOpen] = useState(null);
+ const [selectedTime, setSelectedTime] = useState({ hours: "09", minutes: "00", period: "AM" });
+
+ const saveMutation = useMutation({
+ mutationFn: async (data) => {
+ // Calculate totals
+ const staffTotal = data.staff_entries.reduce((sum, entry) => sum + (entry.total || 0), 0);
+ const chargesTotal = data.charges.reduce((sum, charge) => sum + ((charge.qty * charge.rate) || 0), 0);
+ const subtotal = staffTotal + chargesTotal;
+ const total = subtotal + (parseFloat(data.other_charges) || 0);
+
+ const roles = data.staff_entries.length > 0 ? [{
+ role_name: "Mixed",
+ staff_entries: data.staff_entries,
+ role_subtotal: staffTotal
+ }] : [];
+
+ const invoiceData = {
+ invoice_number: data.invoice_number,
+ event_id: data.event_id,
+ event_name: data.event_name,
+ event_date: data.invoice_date,
+ po_reference: data.po_reference,
+ from_company: data.from_company,
+ to_company: data.to_company,
+ business_name: data.to_company.name,
+ manager_name: data.manager,
+ vendor_name: data.from_company.name,
+ vendor_id: data.vendor_id,
+ hub: data.hub,
+ department: data.department,
+ cost_center: data.po_reference,
+ roles: roles,
+ charges: data.charges,
+ subtotal: subtotal,
+ other_charges: parseFloat(data.other_charges) || 0,
+ amount: total,
+ status: existingInvoice?.status || "Draft",
+ issue_date: data.invoice_date,
+ due_date: data.due_date,
+ payment_terms: data.payment_terms,
+ is_auto_generated: false,
+ notes: data.notes,
+ };
+
+ if (isEdit) {
+ return base44.entities.Invoice.update(invoiceId, invoiceData);
+ } else {
+ return base44.entities.Invoice.create(invoiceData);
+ }
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['invoices'] });
+ toast({
+ title: isEdit ? "✅ Invoice Updated" : "✅ Invoice Created",
+ description: isEdit ? "Invoice has been updated successfully" : "Invoice has been created successfully",
+ });
+ navigate(createPageUrl('Invoices'));
+ },
+ });
+
+ const handleAddStaffEntry = () => {
+ setFormData({
+ ...formData,
+ staff_entries: [
+ ...formData.staff_entries,
+ {
+ name: "Mohsin",
+ date: format(new Date(), 'MM/dd/yyyy'),
+ position: "Bartender",
+ check_in: "hh:mm",
+ lunch: 0,
+ check_out: "",
+ worked_hours: 0,
+ regular_hours: 0,
+ ot_hours: 0,
+ dt_hours: 0,
+ rate: 52.68,
+ regular_value: 0,
+ ot_value: 0,
+ dt_value: 0,
+ total: 0
+ }
+ ]
+ });
+ };
+
+ const handleAddCharge = () => {
+ setFormData({
+ ...formData,
+ charges: [
+ ...formData.charges,
+ {
+ name: "Gas Compensation",
+ qty: 7.30,
+ rate: 0,
+ price: 0
+ }
+ ]
+ });
+ };
+
+ const handleStaffChange = (index, field, value) => {
+ const newEntries = [...formData.staff_entries];
+ newEntries[index] = { ...newEntries[index], [field]: value };
+
+ // Recalculate totals if time-related fields change
+ if (['worked_hours', 'regular_hours', 'ot_hours', 'dt_hours', 'rate'].includes(field)) {
+ const entry = newEntries[index];
+ entry.regular_value = (entry.regular_hours || 0) * (entry.rate || 0);
+ entry.ot_value = (entry.ot_hours || 0) * (entry.rate || 0) * 1.5;
+ entry.dt_value = (entry.dt_hours || 0) * (entry.rate || 0) * 2;
+ entry.total = entry.regular_value + entry.ot_value + entry.dt_value;
+ }
+
+ setFormData({ ...formData, staff_entries: newEntries });
+ };
+
+ const handleChargeChange = (index, field, value) => {
+ const newCharges = [...formData.charges];
+ newCharges[index] = { ...newCharges[index], [field]: value };
+
+ if (['qty', 'rate'].includes(field)) {
+ newCharges[index].price = (newCharges[index].qty || 0) * (newCharges[index].rate || 0);
+ }
+
+ setFormData({ ...formData, charges: newCharges });
+ };
+
+ const handleRemoveStaff = (index) => {
+ setFormData({
+ ...formData,
+ staff_entries: formData.staff_entries.filter((_, i) => i !== index)
+ });
+ };
+
+ const handleRemoveCharge = (index) => {
+ setFormData({
+ ...formData,
+ charges: formData.charges.filter((_, i) => i !== index)
+ });
+ };
+
+ const handleTimeSelect = (entryIndex, field) => {
+ const timeString = `${selectedTime.hours}:${selectedTime.minutes} ${selectedTime.period}`;
+ handleStaffChange(entryIndex, field, timeString);
+ setTimePickerOpen(null);
+ };
+
+ const calculateTotals = () => {
+ const staffTotal = formData.staff_entries.reduce((sum, entry) => sum + (entry.total || 0), 0);
+ const chargesTotal = formData.charges.reduce((sum, charge) => sum + (charge.price || 0), 0);
+ const subtotal = staffTotal + chargesTotal;
+ const otherCharges = parseFloat(formData.other_charges) || 0;
+ const grandTotal = subtotal + otherCharges;
+
+ return { subtotal, otherCharges, grandTotal };
+ };
+
+ const totals = calculateTotals();
+
+ return (
+
+
+
+
+
navigate(createPageUrl('Invoices'))} className="bg-white">
+
+ Back to Invoices
+
+
+
{isEdit ? 'Edit Invoice' : 'Create New Invoice'}
+
Complete all invoice details below
+
+
+
+ {existingInvoice?.status || "Draft"}
+
+
+
+
+ {/* Invoice Details Header */}
+
+
+
+
+ 📄
+
+
+
Invoice Details
+
Event: {formData.event_name || "Internal Support"}
+
+
+
+
+
Invoice Number
+
{formData.invoice_number}
+
+
+
+
+
+
+ setFormData({ ...formData, hub: e.target.value })}
+ placeholder="Hub"
+ className="mt-1"
+ />
+
+
+
+
+ setFormData({ ...formData, manager: e.target.value })}
+ placeholder="Manager Name"
+ className="mt-1"
+ />
+
+
+
+
+ setFormData({ ...formData, vendor_id: e.target.value })}
+ placeholder="Vendor #"
+ className="mt-1"
+ />
+
+
+
+
+
+
+
+ setFormData({ ...formData, payment_terms: "30", due_date: format(addDays(new Date(formData.invoice_date), 30), 'yyyy-MM-dd') })}
+ >
+ 30 days
+
+ setFormData({ ...formData, payment_terms: "45", due_date: format(addDays(new Date(formData.invoice_date), 45), 'yyyy-MM-dd') })}
+ >
+ 45 days
+
+ setFormData({ ...formData, payment_terms: "60", due_date: format(addDays(new Date(formData.invoice_date), 60), 'yyyy-MM-dd') })}
+ >
+ 60 days
+
+
+
+
+
+
+
+
+ {/* From and To */}
+
+
+
+
+
+
T
+ To (Client):
+
+
+
+ Company:
+ setFormData({
+ ...formData,
+ to_company: { ...formData.to_company, name: e.target.value }
+ })}
+ className="flex-1"
+ />
+
+
+ Phone:
+ setFormData({
+ ...formData,
+ to_company: { ...formData.to_company, phone: e.target.value }
+ })}
+ className="flex-1"
+ />
+
+
+ Manager Name:
+ setFormData({
+ ...formData,
+ to_company: { ...formData.to_company, manager_name: e.target.value }
+ })}
+ className="flex-1"
+ />
+
+
+ Email:
+ setFormData({
+ ...formData,
+ to_company: { ...formData.to_company, email: e.target.value }
+ })}
+ className="flex-1"
+ />
+
+
+ Hub Name:
+ setFormData({
+ ...formData,
+ to_company: { ...formData.to_company, hub_name: e.target.value }
+ })}
+ className="flex-1"
+ />
+
+
+ Address:
+ setFormData({
+ ...formData,
+ to_company: { ...formData.to_company, address: e.target.value }
+ })}
+ className="flex-1"
+ />
+
+
+ Vendor #:
+ setFormData({
+ ...formData,
+ to_company: { ...formData.to_company, vendor_id: e.target.value }
+ })}
+ className="flex-1"
+ />
+
+
+
+
+
+ {/* Staff Table */}
+
+
+
+
+ 👥
+
+
+
Staff Entries
+
{formData.staff_entries.length} entries
+
+
+
+
+ Add Staff Entry
+
+
+
+
+
+
+
+ | # |
+ Name |
+ ClockIn |
+ Lunch |
+ Checkout |
+ Worked H |
+ Reg H |
+ OT Hours |
+ DT Hours |
+ Rate |
+ Reg Value |
+ OT Value |
+ DT Value |
+ Total |
+ Action |
+
+
+
+ {formData.staff_entries.map((entry, idx) => (
+
+ | {idx + 1} |
+
+ handleStaffChange(idx, 'name', e.target.value)}
+ className="h-8 w-24"
+ />
+ |
+
+ setTimePickerOpen(open ? `checkin-${idx}` : null)}>
+
+
+
+ {entry.check_in}
+
+
+
+
+
+
+ |
+
+ handleStaffChange(idx, 'lunch', parseFloat(e.target.value))}
+ className="h-8 w-16"
+ />
+ |
+
+ setTimePickerOpen(open ? `checkout-${idx}` : null)}>
+
+
+
+ {entry.check_out || "hh:mm"}
+
+
+
+
+
+
+ |
+
+ handleStaffChange(idx, 'worked_hours', parseFloat(e.target.value))}
+ className="h-8 w-16"
+ />
+ |
+
+ handleStaffChange(idx, 'regular_hours', parseFloat(e.target.value))}
+ className="h-8 w-16"
+ />
+ |
+
+ handleStaffChange(idx, 'ot_hours', parseFloat(e.target.value))}
+ className="h-8 w-16"
+ />
+ |
+
+ handleStaffChange(idx, 'dt_hours', parseFloat(e.target.value))}
+ className="h-8 w-16"
+ />
+ |
+
+ handleStaffChange(idx, 'rate', parseFloat(e.target.value))}
+ className="h-8 w-20"
+ />
+ |
+ ${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"} |
+
+ handleRemoveStaff(idx)}
+ className="h-8 w-8 p-0 text-red-600 hover:bg-red-50"
+ >
+
+
+ |
+
+ ))}
+
+
+
+
+
+ {/* Charges */}
+
+
+
+
+ 💰
+
+
+
Additional Charges
+
{formData.charges.length} charges
+
+
+
+
+ Add Charge
+
+
+
+
+
+
+ {/* Totals */}
+
+
+
+
+ Sub total:
+ ${totals.subtotal.toFixed(2)}
+
+
+ Other charges:
+ setFormData({ ...formData, other_charges: e.target.value })}
+ className="h-9 w-32 text-right border-blue-300 focus:border-blue-500 bg-white"
+ />
+
+
+ Grand total:
+ ${totals.grandTotal.toFixed(2)}
+
+
+
+
+
+ {/* Notes */}
+
+
+
+
+ {/* Actions */}
+
+
navigate(createPageUrl('Invoices'))} className="border-slate-300">
+ Cancel
+
+
+ saveMutation.mutate({ ...formData, status: "Draft" })}
+ disabled={saveMutation.isPending}
+ className="border-blue-300 text-blue-700 hover:bg-blue-50"
+ >
+ Save as Draft
+
+ saveMutation.mutate(formData)}
+ disabled={saveMutation.isPending}
+ className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold px-8 shadow-lg"
+ >
+ {saveMutation.isPending ? "Saving..." : isEdit ? "Update Invoice" : "Create Invoice"}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend-web/src/pages/Invoices.jsx b/frontend-web/src/pages/Invoices.jsx
index 29d22023..974c1e82 100644
--- a/frontend-web/src/pages/Invoices.jsx
+++ b/frontend-web/src/pages/Invoices.jsx
@@ -1,48 +1,38 @@
-
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { Link } from "react-router-dom";
-import { createPageUrl } from "@/utils";
+import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { FileText, Plus, DollarSign, Search, Eye, Download } from "lucide-react";
+import { FileText, Plus, Search, Eye, AlertTriangle, CheckCircle, Clock, DollarSign, Edit } from "lucide-react";
import { format, parseISO, isPast } from "date-fns";
import PageHeader from "@/components/common/PageHeader";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogFooter,
-} from "@/components/ui/dialog";
-import { Label } from "@/components/ui/label";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { useNavigate } from "react-router-dom";
+import { createPageUrl } from "@/utils";
+import AutoInvoiceGenerator from "@/components/invoices/AutoInvoiceGenerator";
+import CreateInvoiceModal from "@/components/invoices/CreateInvoiceModal";
const statusColors = {
- 'Open': 'bg-orange-500 text-white',
- 'Confirmed': 'bg-purple-500 text-white',
- 'Overdue': 'bg-red-500 text-white',
+ 'Draft': 'bg-slate-500 text-white',
+ 'Pending Review': 'bg-amber-500 text-white',
+ 'Approved': 'bg-green-500 text-white',
+ 'Disputed': 'bg-red-500 text-white',
+ 'Under Review': 'bg-orange-500 text-white',
'Resolved': 'bg-blue-500 text-white',
- 'Paid': 'bg-green-500 text-white',
- 'Reconciled': 'bg-amber-600 text-white', // Changed from bg-yellow-600
- 'Disputed': 'bg-gray-500 text-white',
- 'Verified': 'bg-teal-500 text-white',
- 'Pending': 'bg-amber-500 text-white',
+ 'Overdue': 'bg-red-600 text-white',
+ 'Paid': 'bg-emerald-500 text-white',
+ 'Reconciled': 'bg-purple-500 text-white',
+ 'Cancelled': 'bg-slate-400 text-white',
};
export default function Invoices() {
+ const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
- const [selectedInvoice, setSelectedInvoice] = useState(null);
- const [showPaymentDialog, setShowPaymentDialog] = useState(false);
- const [showCreateDialog, setShowCreateDialog] = useState(false);
- const [paymentMethod, setPaymentMethod] = useState("");
- const queryClient = useQueryClient();
+ const [showCreateModal, setShowCreateModal] = useState(false);
const { data: user } = useQuery({
queryKey: ['current-user-invoices'],
@@ -57,49 +47,52 @@ export default function Invoices() {
const userRole = user?.user_role || user?.role;
+ // Auto-mark overdue invoices
+ React.useEffect(() => {
+ invoices.forEach(async (invoice) => {
+ if (invoice.status === "Approved" && isPast(parseISO(invoice.due_date))) {
+ try {
+ await base44.entities.Invoice.update(invoice.id, { status: "Overdue" });
+ } catch (error) {
+ console.error('Failed to mark invoice as overdue:', error);
+ }
+ }
+ });
+ }, [invoices]);
+
// Filter invoices based on user role
const visibleInvoices = React.useMemo(() => {
if (userRole === "client") {
- return invoices.filter(inv =>
+ return invoices.filter(inv =>
inv.business_name === user?.company_name ||
inv.manager_name === user?.full_name ||
inv.created_by === user?.email
);
}
if (userRole === "vendor") {
- return invoices.filter(inv => inv.vendor_name === user?.company_name);
+ return invoices.filter(inv =>
+ inv.vendor_name === user?.company_name ||
+ inv.vendor_id === user?.vendor_id
+ );
}
- // Admin, procurement, operator can see all
return invoices;
}, [invoices, userRole, user]);
- const updateInvoiceMutation = useMutation({
- mutationFn: ({ id, data }) => base44.entities.Invoice.update(id, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['invoices'] });
- setShowPaymentDialog(false);
- setSelectedInvoice(null);
- },
- });
-
const getFilteredInvoices = () => {
let filtered = visibleInvoices;
- // Status filter
if (activeTab !== "all") {
const statusMap = {
- open: "Open",
- disputed: "Disputed",
- resolved: "Resolved",
- verified: "Verified",
- overdue: "Overdue",
- reconciled: "Reconciled",
- paid: "Paid"
+ 'pending': 'Pending Review',
+ 'approved': 'Approved',
+ 'disputed': 'Disputed',
+ 'overdue': 'Overdue',
+ 'paid': 'Paid',
+ 'reconciled': 'Reconciled',
};
filtered = filtered.filter(inv => inv.status === statusMap[activeTab]);
}
- // Search filter
if (searchTerm) {
filtered = filtered.filter(inv =>
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -114,317 +107,245 @@ export default function Invoices() {
const filteredInvoices = getFilteredInvoices();
- // Calculate metrics
const getStatusCount = (status) => {
if (status === "all") return visibleInvoices.length;
return visibleInvoices.filter(inv => inv.status === status).length;
};
const getTotalAmount = (status) => {
- const filtered = status === "all"
- ? visibleInvoices
+ const filtered = status === "all"
+ ? visibleInvoices
: visibleInvoices.filter(inv => inv.status === status);
return filtered.reduce((sum, inv) => sum + (inv.amount || 0), 0);
};
- const allTotal = getTotalAmount("all");
- const openTotal = getTotalAmount("Open");
- const overdueTotal = getTotalAmount("Overdue");
- const paidTotal = getTotalAmount("Paid");
-
- const openPercentage = allTotal > 0 ? ((openTotal / allTotal) * 100).toFixed(1) : 0;
- const overduePercentage = allTotal > 0 ? ((overdueTotal / allTotal) * 100).toFixed(1) : 0;
- const paidPercentage = allTotal > 0 ? ((paidTotal / allTotal) * 100).toFixed(1) : 0;
-
- const handleRecordPayment = () => {
- if (selectedInvoice && paymentMethod) {
- updateInvoiceMutation.mutate({
- id: selectedInvoice.id,
- data: {
- ...selectedInvoice,
- status: "Paid",
- paid_date: new Date().toISOString().split('T')[0],
- payment_method: paymentMethod
- }
- });
- }
+ const metrics = {
+ all: getTotalAmount("all"),
+ pending: getTotalAmount("Pending Review"),
+ approved: getTotalAmount("Approved"),
+ disputed: getTotalAmount("Disputed"),
+ overdue: getTotalAmount("Overdue"),
+ paid: getTotalAmount("Paid"),
};
return (
-
-
-
- setShowPaymentDialog(true)}
- variant="outline"
- className="bg-amber-500 hover:bg-amber-600 text-white border-0 font-semibold" // Changed className
- >
- Record Payment
-
- setShowCreateDialog(true)}
- className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white shadow-lg"
- >
-
- Create Invoice
-
- >
- }
- />
+ <>
+
+
+
+
+
setShowCreateModal(true)} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
+
+ Create Invoice
+
+ )
+ }
+ />
- {/* Status Tabs */}
-
-
-
- All Invoices {getStatusCount("all")}
-
-
- Open {getStatusCount("Open")}
-
-
- Disputed {getStatusCount("Disputed")}
-
-
- Resolved {getStatusCount("Resolved")}
-
-
- Verified {getStatusCount("Verified")}
-
-
- Overdue {getStatusCount("Overdue")}
-
-
- Reconciled {getStatusCount("Reconciled")}
-
-
- Paid {getStatusCount("Paid")}
-
-
-
+ {/* Alert Banners */}
+ {metrics.disputed > 0 && (
+
+
+
+
Disputed Invoices Require Attention
+
{getStatusCount("Disputed")} invoices are currently disputed
+
+
+ )}
- {/* Summary Cards */}
-
-
-
-
-
-
All
-
${allTotal.toLocaleString()}
+ {metrics.overdue > 0 && userRole === "client" && (
+
+
+
+
Overdue Payments
+
${metrics.overdue.toLocaleString()} in overdue invoices
+
+
+ )}
+
+ {/* Status Tabs */}
+
+
+
+ All {getStatusCount("all")}
+
+
+ Pending Review {getStatusCount("Pending Review")}
+
+
+ Approved {getStatusCount("Approved")}
+
+
+ Disputed {getStatusCount("Disputed")}
+
+
+ Overdue {getStatusCount("Overdue")}
+
+
+ Paid {getStatusCount("Paid")}
+
+
+ Reconciled {getStatusCount("Reconciled")}
+
+
+
+
+ {/* Metric Cards */}
+
+
+
+
+
+
+
+
+
Total
+
${metrics.all.toLocaleString()}
+
- {getStatusCount("all")} invoices
-
-
-
100%
-
-
+
+
-
-
-
-
-
Open
-
${openTotal.toLocaleString()}
+
+
+
+
+
+
+
+
Pending
+
${metrics.pending.toLocaleString()}
+
- {getStatusCount("Open")} invoices
-
-
-
{openPercentage}%
-
-
+
+
-
-
-
-
-
Overdue
-
${overdueTotal.toLocaleString()}
+
+
+
+
+
+
Overdue
+
${metrics.overdue.toLocaleString()}
+
- {getStatusCount("Overdue")} invoices
-
-
-
{overduePercentage}%
-
-
+
+
-
-
-
-
-
Paid
-
${paidTotal.toLocaleString()}
+
+
+
+
+
+
+
+
Paid
+
${metrics.paid.toLocaleString()}
+
- {getStatusCount("Paid")} invoices
-
-
-
{paidPercentage}%
-
-
-
-
- {/* Search */}
-
-
-
- setSearchTerm(e.target.value)}
- className="pl-10 border-slate-300"
- />
+
+
-
- {/* Invoices Table */}
-
-
-
-
-
- S #
- Manager Name
- Hub
- Invoice ID
- Cost Center
- Event
- Value $
- Count
- Payment Status
- Actions
-
-
-
- {filteredInvoices.length === 0 ? (
-
-
-
- No invoices found
-
+ {/* Search */}
+
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-10"
+ />
+
+
+
+ {/* Invoices Table */}
+
+
+
+
+
+ Invoice #
+ Client
+ Event
+ Vendor
+ Issue Date
+ Due Date
+ Amount
+ Status
+ Actions
- ) : (
- filteredInvoices.map((invoice, idx) => (
-
- {idx + 1}
- {invoice.manager_name || invoice.business_name}
- {invoice.hub || "Hub Name"}
-
-
-
{invoice.invoice_number}
-
{format(parseISO(invoice.issue_date), 'M.d.yyyy')}
-
-
- {invoice.cost_center || "Cost Center"}
- {invoice.event_name || "Events Name"}
- ${invoice.amount?.toLocaleString()}
-
-
- {invoice.item_count || 2}
-
-
-
-
- {invoice.status}
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {filteredInvoices.length === 0 ? (
+
+
+
+ No invoices found
- ))
- )}
-
-
-
-
-
- {/* Record Payment Dialog */}
-
-
- {/* Create Invoice Dialog */}
-
+ ) : (
+ filteredInvoices.map((invoice) => (
+
+ {invoice.invoice_number}
+ {invoice.business_name}
+ {invoice.event_name}
+ {invoice.vendor_name || "—"}
+ {format(parseISO(invoice.issue_date), 'MMM dd, yyyy')}
+
+ {format(parseISO(invoice.due_date), 'MMM dd, yyyy')}
+
+ ${invoice.amount?.toLocaleString()}
+
+
+ {invoice.status}
+
+
+
+
+ navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))}
+ className="font-semibold"
+ >
+
+ View
+
+ {userRole === "vendor" && invoice.status === "Draft" && (
+ navigate(createPageUrl(`InvoiceEditor?id=${invoice.id}`))}
+ className="font-semibold text-blue-600"
+ >
+ Edit
+
+ )}
+
+
+
+ ))
+ )}
+
+
+
+
+
-
+
+
setShowCreateModal(false)}
+ />
+ >
);
-}
+}
\ No newline at end of file
diff --git a/frontend-web/src/pages/Layout.jsx b/frontend-web/src/pages/Layout.jsx
index 87ade54a..b159bc72 100644
--- a/frontend-web/src/pages/Layout.jsx
+++ b/frontend-web/src/pages/Layout.jsx
@@ -1,6 +1,6 @@
import React from "react";
-import { Link, useLocation } from "react-router-dom";
+import { Link, useLocation, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
@@ -9,7 +9,7 @@ import {
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
- Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap
+ Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -37,7 +37,7 @@ import { Toaster } from "@/components/ui/toaster";
// Navigation items for each role
const roleNavigationMap = {
admin: [
- { title: "Dashboard", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
+ { title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
@@ -58,7 +58,7 @@ const roleNavigationMap = {
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
],
procurement: [
- { title: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
+ { title: "Home", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
@@ -73,7 +73,7 @@ const roleNavigationMap = {
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
operator: [
- { title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
+ { title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
@@ -87,7 +87,7 @@ const roleNavigationMap = {
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
sector: [
- { title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
+ { title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
@@ -100,7 +100,7 @@ const roleNavigationMap = {
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
],
client: [
- { title: "Dashboard", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
+ { title: "Home", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
@@ -113,7 +113,7 @@ const roleNavigationMap = {
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
],
vendor: [
- { title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
+ { title: "Home", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
@@ -131,7 +131,7 @@ const roleNavigationMap = {
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
],
workforce: [
- { title: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
+ { title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
@@ -241,6 +241,7 @@ function NavigationMenu({ location, userRole, closeSheet }) {
export default function Layout({ children }) {
const location = useLocation();
+ const navigate = useNavigate();
const [showNotifications, setShowNotifications] = React.useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
@@ -323,6 +324,16 @@ export default function Layout({ children }) {
+
navigate(-1)}
+ className="hover:bg-slate-100"
+ title="Go back"
+ >
+
+
+
diff --git a/frontend-web/src/pages/Onboarding.jsx b/frontend-web/src/pages/Onboarding.jsx
index 883ffe3b..b3c3ff0d 100644
--- a/frontend-web/src/pages/Onboarding.jsx
+++ b/frontend-web/src/pages/Onboarding.jsx
@@ -36,7 +36,7 @@ export default function Onboarding() {
queryFn: async () => {
const allInvites = await base44.entities.TeamMemberInvite.list();
const foundInvite = allInvites.find(inv => inv.invite_code === inviteCode && inv.invite_status === 'pending');
-
+
if (foundInvite) {
// Pre-fill form with invite data
const nameParts = (foundInvite.full_name || "").split(' ');
@@ -44,10 +44,14 @@ export default function Onboarding() {
...prev,
email: foundInvite.email,
first_name: nameParts[0] || "",
- last_name: nameParts.slice(1).join(' ') || ""
+ last_name: nameParts.slice(1).join(' ') || "",
+ phone: foundInvite.phone || "",
+ department: foundInvite.department || "",
+ hub: foundInvite.hub || "",
+ title: foundInvite.title || ""
}));
}
-
+
return foundInvite;
},
enabled: !!inviteCode,
@@ -65,6 +69,40 @@ export default function Onboarding() {
initialData: [],
});
+ // Fetch team to get departments
+ const { data: team } = useQuery({
+ queryKey: ['team-for-departments', invite?.team_id],
+ queryFn: async () => {
+ if (!invite?.team_id) return null;
+ const allTeams = await base44.entities.Team.list();
+ return allTeams.find(t => t.id === invite.team_id);
+ },
+ enabled: !!invite?.team_id,
+ });
+
+ // Get all unique departments from team and hubs
+ const availableDepartments = React.useMemo(() => {
+ const depts = new Set();
+
+ // Add team departments
+ if (team?.departments) {
+ team.departments.forEach(d => depts.add(d));
+ }
+
+ // Add hub departments
+ hubs.forEach(hub => {
+ if (hub.departments) {
+ hub.departments.forEach(dept => {
+ if (dept.department_name) {
+ depts.add(dept.department_name);
+ }
+ });
+ }
+ });
+
+ return Array.from(depts);
+ }, [team, hubs]);
+
const registerMutation = useMutation({
mutationFn: async (data) => {
if (!invite) {
@@ -233,8 +271,14 @@ export default function Onboarding() {
Join {invite.team_name}
+ {invite.hub && (
+
+ 📍 {invite.hub}
+
+ )}
You've been invited by {invite.invited_by} as a {invite.role}
+ {invite.department && in {invite.department}}
@@ -313,6 +357,7 @@ export default function Onboarding() {
placeholder="+1 (555) 123-4567"
className="mt-2"
/>
+
You can edit this if needed
- Operations
- Sales
- HR
- Finance
- IT
- Marketing
- Customer Service
- Logistics
- Management
- Other
+ {availableDepartments.length > 0 ? (
+ availableDepartments.map((dept) => (
+
+ {dept}
+
+ ))
+ ) : (
+ Operations
+ )}
+ {formData.department && (
+ ✓ Pre-filled from your invitation
+ )}
{hubs.length > 0 && (
-
+
+ {formData.hub && (
+
📍 You're joining {formData.hub}!
+ )}
)}
diff --git a/frontend-web/src/pages/RapidOrder.jsx b/frontend-web/src/pages/RapidOrder.jsx
index 47d32d9e..50d8108d 100644
--- a/frontend-web/src/pages/RapidOrder.jsx
+++ b/frontend-web/src/pages/RapidOrder.jsx
@@ -130,7 +130,8 @@ Return a concise summary.`,
const primaryLocation = businesses[0]?.business_name || "Primary Location";
// Ensure count is properly set - default to 1 if not detected
- const staffCount = parsed.count && parsed.count > 0 ? parsed.count : 1;
+ // CRITICAL: For RAPID orders, use the EXACT count parsed, no modifications
+ const staffCount = parsed.count && parsed.count > 0 ? Math.floor(parsed.count) : 1;
// Get current time for start_time (when ASAP)
const now = new Date();
@@ -221,15 +222,17 @@ Return a concise summary.`,
const confirmTime12Hour = convertTo12Hour(confirmTime);
// Create comprehensive order data with proper requested field and actual times
+ // CRITICAL: For RAPID orders, requested must exactly match the count - no additions
+ const exactCount = Math.floor(Number(detectedOrder.count));
const orderData = {
- event_name: `RAPID: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`,
+ event_name: `RAPID: ${exactCount} ${detectedOrder.role}${exactCount > 1 ? 's' : ''}`,
is_rapid: true,
status: "Pending",
business_name: detectedOrder.business_name,
hub: detectedOrder.hub,
event_location: detectedOrder.location,
date: now.toISOString().split('T')[0],
- requested: Number(detectedOrder.count), // Ensure it's a number
+ requested: exactCount, // EXACT count requested, no modifications
client_name: user?.full_name,
client_email: user?.email,
notes: `RAPID ORDER - Submitted at ${detectedOrder.start_time_display} - Confirmed at ${confirmTime12Hour}\nStart: ${detectedOrder.start_time_display} | End: ${detectedOrder.end_time_display}`,
@@ -238,7 +241,7 @@ Return a concise summary.`,
location: detectedOrder.location,
roles: [{
role: detectedOrder.role,
- count: Number(detectedOrder.count), // Ensure it's a number
+ count: exactCount, // Use exact count, no modifications
start_time: detectedOrder.start_time, // Store in 24-hour format
end_time: detectedOrder.end_time // Store in 24-hour format
}]
diff --git a/frontend-web/src/pages/TaskBoard.jsx b/frontend-web/src/pages/TaskBoard.jsx
index ca4f7824..1ddfeeb4 100644
--- a/frontend-web/src/pages/TaskBoard.jsx
+++ b/frontend-web/src/pages/TaskBoard.jsx
@@ -11,7 +11,15 @@ import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { DragDropContext, Draggable } from "@hello-pangea/dnd";
-import { Link2, Plus, Users } from "lucide-react";
+import { Link2, Plus, Users, Search, UserCircle, Filter, ArrowUpDown, EyeOff, Grid3x3, MoreVertical, Pin, Ruler, Palette } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+ DropdownMenuLabel,
+} from "@/components/ui/dropdown-menu";
import TaskCard from "@/components/tasks/TaskCard";
import TaskColumn from "@/components/tasks/TaskColumn";
import TaskDetailModal from "@/components/tasks/TaskDetailModal";
@@ -32,6 +40,15 @@ export default function TaskBoard() {
assigned_members: []
});
const [selectedMembers, setSelectedMembers] = useState([]);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [filterPerson, setFilterPerson] = useState("all");
+ const [filterPriority, setFilterPriority] = useState("all");
+ const [sortBy, setSortBy] = useState("due_date");
+ const [showCompleted, setShowCompleted] = useState(true);
+ const [groupBy, setGroupBy] = useState("status");
+ const [pinnedColumns, setPinnedColumns] = useState([]);
+ const [itemHeight, setItemHeight] = useState("normal");
+ const [conditionalColoring, setConditionalColoring] = useState(true);
const { data: user } = useQuery({
queryKey: ['current-user-taskboard'],
@@ -57,7 +74,30 @@ export default function TaskBoard() {
});
const userTeam = teams.find(t => t.owner_id === user?.id) || teams[0];
- const teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
+ let teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
+
+ // Apply filters
+ if (searchQuery) {
+ teamTasks = teamTasks.filter(t =>
+ t.task_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ t.description?.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+ }
+
+ if (filterPerson !== "all") {
+ teamTasks = teamTasks.filter(t =>
+ t.assigned_members?.some(m => m.member_id === filterPerson)
+ );
+ }
+
+ if (filterPriority !== "all") {
+ teamTasks = teamTasks.filter(t => t.priority === filterPriority);
+ }
+
+ if (!showCompleted) {
+ teamTasks = teamTasks.filter(t => t.status !== "completed");
+ }
+
const currentTeamMembers = teamMembers.filter(m => m.team_id === userTeam?.id);
const leadMembers = currentTeamMembers.filter(m => m.role === 'admin' || m.role === 'manager');
@@ -66,12 +106,30 @@ export default function TaskBoard() {
// Get unique departments from team members
const departments = [...new Set(currentTeamMembers.map(m => m.department).filter(Boolean))];
+ const sortTasks = (tasks) => {
+ return [...tasks].sort((a, b) => {
+ switch (sortBy) {
+ case "due_date":
+ return new Date(a.due_date || '9999-12-31') - new Date(b.due_date || '9999-12-31');
+ case "priority":
+ const priorityOrder = { high: 0, normal: 1, low: 2 };
+ return (priorityOrder[a.priority] || 1) - (priorityOrder[b.priority] || 1);
+ case "created_date":
+ return new Date(b.created_date || 0) - new Date(a.created_date || 0);
+ case "task_name":
+ return (a.task_name || '').localeCompare(b.task_name || '');
+ default:
+ return (a.order_index || 0) - (b.order_index || 0);
+ }
+ });
+ };
+
const tasksByStatus = useMemo(() => ({
- pending: teamTasks.filter(t => t.status === 'pending').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
- in_progress: teamTasks.filter(t => t.status === 'in_progress').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
- on_hold: teamTasks.filter(t => t.status === 'on_hold').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
- completed: teamTasks.filter(t => t.status === 'completed').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
- }), [teamTasks]);
+ pending: sortTasks(teamTasks.filter(t => t.status === 'pending')),
+ in_progress: sortTasks(teamTasks.filter(t => t.status === 'in_progress')),
+ on_hold: sortTasks(teamTasks.filter(t => t.status === 'on_hold')),
+ completed: sortTasks(teamTasks.filter(t => t.status === 'completed')),
+ }), [teamTasks, sortBy]);
const overallProgress = useMemo(() => {
if (teamTasks.length === 0) return 0;
@@ -158,6 +216,130 @@ export default function TaskBoard() {
{/* Header */}
+ {/* Toolbar */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 h-9"
+ />
+
+
+
+
+
+
+ Person
+
+
+
+ setFilterPerson("all")}>
+ All People
+
+
+ {currentTeamMembers.map((member) => (
+ setFilterPerson(member.id)}
+ >
+ {member.member_name}
+
+ ))}
+
+
+
+
+
+
+
+ Filter
+
+
+
+ Priority
+ setFilterPriority("all")}>All
+ setFilterPriority("high")}>High
+ setFilterPriority("normal")}>Normal
+ setFilterPriority("low")}>Low
+
+
+
+
+
+
+
+ Sort
+
+
+
+ setSortBy("due_date")}>Due Date
+ setSortBy("priority")}>Priority
+ setSortBy("created_date")}>Created Date
+ setSortBy("task_name")}>Name
+
+
+
+
setShowCompleted(!showCompleted)}
+ >
+
+ Hide
+
+
+
+
+
+
+ Group by
+
+
+
+ setGroupBy("status")}>Status
+ setGroupBy("priority")}>Priority
+ setGroupBy("assigned")}>Assigned To
+
+
+
+
+
+
+
+
+
+
+ setPinnedColumns(pinnedColumns.length > 0 ? [] : ['pending'])}>
+
+ Pin columns
+
+
+ Item height
+ setItemHeight("compact")}>
+
+ Compact
+
+ setItemHeight("normal")}>
+
+ Normal
+
+ setItemHeight("comfortable")}>
+
+ Comfortable
+
+
+ setConditionalColoring(!conditionalColoring)}>
+
+ Conditional coloring
+
+
+
+
+
Task Board
@@ -205,8 +387,8 @@ export default function TaskBoard() {
-
-
+
+
Share
-
- Create List
+
+ Create Task
@@ -256,6 +438,8 @@ export default function TaskBoard() {
task={task}
provided={provided}
onClick={() => setSelectedTask(task)}
+ itemHeight={itemHeight}
+ conditionalColoring={conditionalColoring}
/>
)}
diff --git a/frontend-web/src/pages/TeamDetails.jsx b/frontend-web/src/pages/TeamDetails.jsx
index b700666f..4b683349 100644
--- a/frontend-web/src/pages/TeamDetails.jsx
+++ b/frontend-web/src/pages/TeamDetails.jsx
@@ -1,4 +1,3 @@
-
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -48,8 +47,23 @@ export default function TeamDetails() {
state: "",
zip_code: "",
manager_name: "",
- manager_email: ""
+ manager_email: "",
+ departments: []
});
+
+ const [showAddDepartmentDialog, setShowAddDepartmentDialog] = useState(false);
+ const [selectedHub, setSelectedHub] = useState(null);
+ const [newDepartment, setNewDepartment] = useState({
+ department_name: "",
+ cost_center: "",
+ manager_name: ""
+ });
+
+ const [favoriteSearch, setFavoriteSearch] = useState("");
+ const [blockedSearch, setBlockedSearch] = useState("");
+ const [showAddFavoriteDialog, setShowAddFavoriteDialog] = useState(false);
+ const [showAddBlockedDialog, setShowAddBlockedDialog] = useState(false);
+ const [blockReason, setBlockReason] = useState("");
const { data: user } = useQuery({
queryKey: ['current-user-team-details'],
@@ -84,6 +98,13 @@ export default function TeamDetails() {
enabled: !!teamId,
initialData: [],
});
+
+ const { data: allStaff = [] } = useQuery({
+ queryKey: ['staff-for-favorites'],
+ queryFn: () => base44.entities.Staff.list(),
+ enabled: !!teamId,
+ initialData: [],
+ });
const updateMemberMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.TeamMember.update(id, data),
@@ -200,7 +221,8 @@ export default function TeamDetails() {
state: "",
zip_code: "",
manager_name: "",
- manager_email: ""
+ manager_email: "",
+ departments: []
});
toast({
title: "Hub Created",
@@ -208,6 +230,78 @@ export default function TeamDetails() {
});
},
});
+
+ const updateTeamMutation = useMutation({
+ mutationFn: ({ id, data }) => base44.entities.Team.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['team', teamId] });
+ toast({
+ title: "Updated",
+ description: "Team updated successfully",
+ });
+ },
+ });
+
+ const addToFavorites = (staff) => {
+ const favoriteStaff = team.favorite_staff || [];
+ const newFavorite = {
+ staff_id: staff.id,
+ staff_name: staff.employee_name,
+ position: staff.position,
+ added_date: new Date().toISOString()
+ };
+
+ updateTeamMutation.mutate({
+ id: teamId,
+ data: {
+ favorite_staff: [...favoriteStaff, newFavorite],
+ favorite_staff_count: favoriteStaff.length + 1
+ }
+ });
+ setShowAddFavoriteDialog(false);
+ };
+
+ const removeFromFavorites = (staffId) => {
+ const favoriteStaff = (team.favorite_staff || []).filter(f => f.staff_id !== staffId);
+ updateTeamMutation.mutate({
+ id: teamId,
+ data: {
+ favorite_staff: favoriteStaff,
+ favorite_staff_count: favoriteStaff.length
+ }
+ });
+ };
+
+ const addToBlocked = (staff) => {
+ const blockedStaff = team.blocked_staff || [];
+ const newBlocked = {
+ staff_id: staff.id,
+ staff_name: staff.employee_name,
+ reason: blockReason,
+ blocked_date: new Date().toISOString()
+ };
+
+ updateTeamMutation.mutate({
+ id: teamId,
+ data: {
+ blocked_staff: [...blockedStaff, newBlocked],
+ blocked_staff_count: blockedStaff.length + 1
+ }
+ });
+ setShowAddBlockedDialog(false);
+ setBlockReason("");
+ };
+
+ const removeFromBlocked = (staffId) => {
+ const blockedStaff = (team.blocked_staff || []).filter(b => b.staff_id !== staffId);
+ updateTeamMutation.mutate({
+ id: teamId,
+ data: {
+ blocked_staff: blockedStaff,
+ blocked_staff_count: blockedStaff.length
+ }
+ });
+ };
const handleEditMember = (member) => {
setEditingMember(member);
@@ -559,7 +653,7 @@ export default function TeamDetails() {
)}
-
+
{hub.address &&
{hub.address}
}
{hub.city && (
@@ -570,6 +664,38 @@ export default function TeamDetails() {
{hub.manager_email}
)}
+
+ {hub.departments && hub.departments.length > 0 && (
+
+
DEPARTMENTS
+
+ {hub.departments.map((dept, idx) => (
+
+
{dept.department_name}
+ {dept.cost_center && (
+
Cost Center: {dept.cost_center}
+ )}
+ {dept.manager_name && (
+
Manager: {dept.manager_name}
+ )}
+
+ ))}
+
+
+ )}
+
+
{
+ setSelectedHub(hub);
+ setShowAddDepartmentDialog(true);
+ }}
+ >
+
+ Add Department
+
))
@@ -592,10 +718,69 @@ export default function TeamDetails() {
{/* Favorite Staff Tab */}
-
-
- No Favorite Staff
- Mark staff as favorites to see them here
+
+
+
+
+ setFavoriteSearch(e.target.value)}
+ className="pl-10"
+ />
+
+
setShowAddFavoriteDialog(true)} className="bg-[#0A39DF]">
+
+ Add Favorite
+
+
+
+ {team.favorite_staff && team.favorite_staff.length > 0 ? (
+
+ {team.favorite_staff.filter(f =>
+ !favoriteSearch ||
+ f.staff_name?.toLowerCase().includes(favoriteSearch.toLowerCase()) ||
+ f.position?.toLowerCase().includes(favoriteSearch.toLowerCase())
+ ).map((fav) => (
+
+
+
+
+
+
+ {fav.staff_name?.charAt(0)}
+
+
+
+
{fav.staff_name}
+
{fav.position}
+
+
+
+
+ removeFromFavorites(fav.staff_id)}
+ className="w-full border-amber-300 hover:bg-amber-100 text-xs"
+ >
+ Remove
+
+
+
+ ))}
+
+ ) : (
+
+
+
No Favorite Staff
+
Mark staff as favorites to see them here
+
setShowAddFavoriteDialog(true)} className="bg-[#0A39DF]">
+
+ Add Your First Favorite
+
+
+ )}
@@ -603,10 +788,64 @@ export default function TeamDetails() {
{/* Blocked Staff Tab */}
-
-
- No Blocked Staff
- Blocked staff will appear here
+
+
+
+
+ setBlockedSearch(e.target.value)}
+ className="pl-10"
+ />
+
+
setShowAddBlockedDialog(true)} variant="outline" className="border-red-300 text-red-600 hover:bg-red-50">
+
+ Block Staff
+
+
+
+ {team.blocked_staff && team.blocked_staff.length > 0 ? (
+
+ {team.blocked_staff.filter(b =>
+ !blockedSearch ||
+ b.staff_name?.toLowerCase().includes(blockedSearch.toLowerCase())
+ ).map((blocked) => (
+
+
+
+
+
+
+ {blocked.staff_name?.charAt(0)}
+
+
+
+
{blocked.staff_name}
+
Reason: {blocked.reason || 'No reason provided'}
+
Blocked {new Date(blocked.blocked_date).toLocaleDateString()}
+
+
+
removeFromBlocked(blocked.staff_id)}
+ className="border-red-300 hover:bg-red-100 text-red-600 text-xs"
+ >
+ Unblock
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
No Blocked Staff
+
Blocked staff will appear here
+
+ )}
@@ -806,7 +1045,7 @@ export default function TeamDetails() {
setNewHub({ ...newHub, hub_name: e.target.value })}
- placeholder="Downtown Office"
+ placeholder="BVG 300"
/>
@@ -814,7 +1053,7 @@ export default function TeamDetails() {
setNewHub({ ...newHub, address: e.target.value })}
- placeholder="123 Main Street"
+ placeholder="300 Bayview Dr, Mountain View, CA 94043"
/>
@@ -822,7 +1061,7 @@ export default function TeamDetails() {
setNewHub({ ...newHub, city: e.target.value })}
- placeholder="San Francisco"
+ placeholder="Mountain View"
/>
@@ -838,7 +1077,7 @@ export default function TeamDetails() {
setNewHub({ ...newHub, zip_code: e.target.value })}
- placeholder="94102"
+ placeholder="94043"
/>
@@ -867,7 +1106,139 @@ export default function TeamDetails() {
+
+ {/* Add Department Dialog */}
+
+
+ {/* Add Favorite Staff Dialog */}
+
+
+ {/* Add Blocked Staff Dialog */}
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend-web/src/pages/Teams.jsx b/frontend-web/src/pages/Teams.jsx
index 3ad317c2..a7310228 100644
--- a/frontend-web/src/pages/Teams.jsx
+++ b/frontend-web/src/pages/Teams.jsx
@@ -1,5 +1,4 @@
-
-import React, { useState } from "react";
+import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
@@ -8,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
-import { Users, Plus, Search, Building2, MapPin, UserCheck, Mail, Edit, Loader2, Trash2, UserX, LayoutGrid, List as ListIcon, RefreshCw, Send, Filter } from "lucide-react";
+import { Users, Plus, Search, Building2, MapPin, UserCheck, Mail, Edit, Loader2, Trash2, UserX, LayoutGrid, List as ListIcon, RefreshCw, Send, Filter, Star, UserPlus } from "lucide-react";
import PageHeader from "@/components/common/PageHeader";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
@@ -43,13 +42,40 @@ export default function Teams() {
const [editingDepartment, setEditingDepartment] = useState(null);
const [teamToDelete, setTeamToDelete] = useState(null);
const [newDepartment, setNewDepartment] = useState("");
+ const [favoriteSearch, setFavoriteSearch] = useState("");
+ const [blockedSearch, setBlockedSearch] = useState("");
+ const [showAddFavoriteDialog, setShowAddFavoriteDialog] = useState(false);
+ const [showAddBlockedDialog, setShowAddBlockedDialog] = useState(false);
+ const [blockReason, setBlockReason] = useState("");
+ const [showAddHubDialog, setShowAddHubDialog] = useState(false);
+ const [showAddHubDepartmentDialog, setShowAddHubDepartmentDialog] = useState(false);
+ const [selectedHubForDept, setSelectedHubForDept] = useState(null);
+ const [preSelectedHub, setPreSelectedHub] = useState(null);
+ const [newHubDepartment, setNewHubDepartment] = useState({
+ department_name: "",
+ cost_center: ""
+ });
const [inviteData, setInviteData] = useState({
email: "",
full_name: "",
role: "member",
+ hub: "",
+ department: "",
});
+ const [newHub, setNewHub] = useState({
+ hub_name: "",
+ address: "",
+ manager_name: "",
+ manager_position: "",
+ manager_email: ""
+ });
+
+ const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false);
+ const addressInputRef = React.useRef(null);
+ const autocompleteRef = React.useRef(null);
+
const { data: user } = useQuery({
queryKey: ['current-user-teams'],
queryFn: () => base44.auth.me(),
@@ -182,6 +208,24 @@ export default function Teams() {
initialData: [],
});
+ const { data: allStaff = [] } = useQuery({
+ queryKey: ['staff-for-favorites'],
+ queryFn: () => base44.entities.Staff.list(),
+ enabled: !!userTeam?.id,
+ initialData: [],
+ });
+
+ const { data: teamHubs = [] } = useQuery({
+ queryKey: ['team-hubs-main', userTeam?.id],
+ queryFn: async () => {
+ if (!userTeam?.id) return [];
+ const allHubs = await base44.entities.TeamHub.list('-created_date');
+ return allHubs.filter(h => h.team_id === userTeam.id);
+ },
+ enabled: !!userTeam?.id,
+ initialData: [],
+ });
+
// Get unique departments from both team settings and existing team members
const teamDepartments = userTeam?.departments || [];
const memberDepartments = [...new Set(teamMembers.map(m => m.department).filter(Boolean))];
@@ -201,14 +245,20 @@ export default function Teams() {
}
const inviteCode = `TEAM-${Math.floor(10000 + Math.random() * 90000)}`;
-
+
+ // Use the first hub if available, or empty string
+ const firstHub = teamHubs.length > 0 ? teamHubs[0].hub_name : "";
+ const firstDept = uniqueDepartments.length > 0 ? uniqueDepartments[0] : "Operations";
+
const invite = await base44.entities.TeamMemberInvite.create({
team_id: userTeam.id,
team_name: userTeam.team_name || "Team",
invite_code: inviteCode,
- email: "test@example.com",
- full_name: "Test User",
+ email: "demo@example.com",
+ full_name: "Demo User",
role: "member",
+ hub: firstHub,
+ department: firstDept,
invited_by: user?.email || user?.full_name,
invite_status: "pending",
invited_date: new Date().toISOString(),
@@ -239,6 +289,17 @@ export default function Teams() {
throw new Error("Unable to identify who is sending the invite. Please try logging out and back in.");
}
+ // Create hub if it doesn't exist
+ if (data.hub && !teamHubs.find(h => h.hub_name === data.hub)) {
+ await base44.entities.TeamHub.create({
+ team_id: userTeam.id,
+ hub_name: data.hub,
+ address: "",
+ is_active: true
+ });
+ queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] });
+ }
+
const inviteCode = `TEAM-${Math.floor(10000 + Math.random() * 90000)}`;
const invite = await base44.entities.TeamMemberInvite.create({
@@ -248,6 +309,8 @@ export default function Teams() {
email: data.email,
full_name: data.full_name,
role: data.role,
+ hub: data.hub || "",
+ department: data.department || "",
invited_by: user?.email || user?.full_name,
invite_status: "pending",
invited_date: new Date().toISOString(),
@@ -257,53 +320,72 @@ export default function Teams() {
const registerUrl = `${window.location.origin}${createPageUrl('Onboarding')}?invite=${inviteCode}`;
await base44.integrations.Core.SendEmail({
- from_name: userTeam.team_name || "Team",
+ from_name: userTeam.team_name || "KROW",
to: data.email,
- subject: `You're invited to join ${userTeam.team_name || 'our team'}!`,
+ subject: `🚀 Welcome to KROW! You've been invited to ${data.hub || userTeam.team_name}`,
body: `
-
-
-
🎉 Team Invitation
-
Join ${userTeam.team_name || 'our team'}
+
+
+
🎉
+
You're Invited!
+
Join ${data.hub ? `the ${data.hub} hub` : userTeam.team_name || 'our team'}
-
-
-
- Hi ${data.full_name || 'there'},
-
-
-
- ${user?.full_name || user?.email} has invited you to join ${userTeam.team_name || 'our team'} as a ${data.role}.
+
+
+
+ Hi ${data.full_name || 'there'} 👋
-
+
+ Great news! ${user?.full_name || user?.email} has invited you to join ${data.hub || userTeam.team_name || 'the team'} as a ${data.role}.
+
+
+
+
🚀 Why KROW?
+
+ - Seamless Operations: Manage your workforce effortlessly
+ - Smart Scheduling: AI-powered shift assignments
+ - Real-Time Updates: Stay connected with your team
+ - Simplified Workflow: Everything you need in one place
+
+
+
+
-
-
What to do next:
-
- - Click the button above to register
- - Create your account with this email (${data.email})
- - You'll be automatically added to the team
+
+
✅ Quick Setup (3 Steps):
+
+ - Click the button above to register
+ - Create your account with ${data.email}
+ - Start managing your operations smoothly!
-
-
- ⏰ Important: This invitation will expire in 7 days.
+
+
+ ⏰ Time-Sensitive: This invitation expires in 7 days. Don't miss out!
-
- Your invite code: ${inviteCode}
- Questions? Contact ${user?.email || 'the team admin'}
-
+
+
+
+
+
Powered by KROW - Workforce Control Tower
`
@@ -315,16 +397,19 @@ export default function Teams() {
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
queryClient.invalidateQueries({ queryKey: ['team-invites', userTeam?.id] });
setShowInviteMemberDialog(false);
+ setPreSelectedHub(null);
setInviteData({
email: "",
full_name: "",
role: "member",
+ hub: "",
+ department: "",
});
toast({
title: "✅ Invitation Sent!",
description: `Email invitation sent to ${inviteData.email}`,
});
- },
+ },
onError: (error) => {
toast({
title: "❌ Failed to Send Invitation",
@@ -556,6 +641,151 @@ export default function Teams() {
}
};
+ const updateTeamMutation = useMutation({
+ mutationFn: ({ id, data }) => base44.entities.Team.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['user-team', user?.id, userRole] });
+ toast({
+ title: "✅ Team Updated",
+ description: "Team updated successfully",
+ });
+ },
+ });
+
+ const addToFavorites = (staff) => {
+ const favoriteStaff = userTeam.favorite_staff || [];
+ const newFavorite = {
+ staff_id: staff.id,
+ staff_name: staff.employee_name,
+ position: staff.position,
+ added_date: new Date().toISOString()
+ };
+
+ updateTeamMutation.mutate({
+ id: userTeam.id,
+ data: {
+ favorite_staff: [...favoriteStaff, newFavorite],
+ favorite_staff_count: favoriteStaff.length + 1
+ }
+ });
+ setShowAddFavoriteDialog(false);
+ };
+
+ const removeFromFavorites = (staffId) => {
+ const favoriteStaff = (userTeam.favorite_staff || []).filter(f => f.staff_id !== staffId);
+ updateTeamMutation.mutate({
+ id: userTeam.id,
+ data: {
+ favorite_staff: favoriteStaff,
+ favorite_staff_count: favoriteStaff.length
+ }
+ });
+ };
+
+ const addToBlocked = (staff) => {
+ const blockedStaff = userTeam.blocked_staff || [];
+ const newBlocked = {
+ staff_id: staff.id,
+ staff_name: staff.employee_name,
+ reason: blockReason,
+ blocked_date: new Date().toISOString()
+ };
+
+ updateTeamMutation.mutate({
+ id: userTeam.id,
+ data: {
+ blocked_staff: [...blockedStaff, newBlocked],
+ blocked_staff_count: blockedStaff.length + 1
+ }
+ });
+ setShowAddBlockedDialog(false);
+ setBlockReason("");
+ };
+
+ const removeFromBlocked = (staffId) => {
+ const blockedStaff = (userTeam.blocked_staff || []).filter(b => b.staff_id !== staffId);
+ updateTeamMutation.mutate({
+ id: userTeam.id,
+ data: {
+ blocked_staff: blockedStaff,
+ blocked_staff_count: blockedStaff.length
+ }
+ });
+ };
+
+ // Load Google Maps script
+ useEffect(() => {
+ if (window.google?.maps?.places) {
+ setIsGoogleMapsLoaded(true);
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.src = `https://maps.googleapis.com/maps/api/js?key=AIzaSyBkP7xH4NvR6C6vZ8Y3J7qX2QW8Z9vN3Zc&libraries=places`;
+ script.async = true;
+ script.onload = () => setIsGoogleMapsLoaded(true);
+ document.head.appendChild(script);
+ }, []);
+
+ // Initialize autocomplete
+ useEffect(() => {
+ if (isGoogleMapsLoaded && addressInputRef.current && showAddHubDialog && !autocompleteRef.current) {
+ autocompleteRef.current = new window.google.maps.places.Autocomplete(addressInputRef.current, {
+ types: ['address'],
+ componentRestrictions: { country: 'us' }
+ });
+
+ autocompleteRef.current.addListener('place_changed', () => {
+ const place = autocompleteRef.current.getPlace();
+ if (place.formatted_address) {
+ setNewHub({ ...newHub, address: place.formatted_address });
+ }
+ });
+ }
+ }, [isGoogleMapsLoaded, showAddHubDialog]);
+
+ const createHubMutation = useMutation({
+ mutationFn: (hubData) => base44.entities.TeamHub.create({
+ ...hubData,
+ team_id: userTeam.id,
+ is_active: true
+ }),
+ onSuccess: (createdHub) => {
+ queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] });
+ setShowAddHubDialog(false);
+ const hubName = newHub.hub_name;
+ setNewHub({
+ hub_name: "",
+ address: "",
+ manager_name: "",
+ manager_position: "",
+ manager_email: ""
+ });
+ autocompleteRef.current = null;
+
+ // Show success with invite action
+ toast({
+ title: "✅ Hub Created Successfully!",
+ description: (
+
+ Ready to invite members to {hubName}?
+ {
+ setPreSelectedHub(hubName);
+ setInviteData({ ...inviteData, hub: hubName });
+ setShowInviteMemberDialog(true);
+ }}
+ >
+ Invite Now
+
+
+ ),
+ });
+ },
+ });
+
const filteredMembers = (members) => members.filter(member => {
const matchesSearch = !searchTerm ||
member.member_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -704,29 +934,29 @@ export default function Teams() {
setShowAddHubDialog(true)}
className="hover:bg-slate-50 hover:border-[#0A39DF] hover:text-[#0A39DF] transition-all"
>
- Departments
+ Create Hub
+
+
createTestInviteMutation.mutate()}
+ disabled={createTestInviteMutation.isPending || !userTeam?.id}
+ className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg font-bold"
+ size="lg"
+ >
+ {createTestInviteMutation.isPending ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ <>
+ 🎯 Get Started Now →
+ >
+ )}
- {['admin', 'procurement', 'operator', 'vendor'].includes(userRole) && (
-
createTestInviteMutation.mutate()}
- disabled={createTestInviteMutation.isPending || !userTeam?.id}
- className="hover:bg-slate-50 hover:border-[#0A39DF] hover:text-[#0A39DF] transition-all"
- >
- {createTestInviteMutation.isPending ? (
- <>
-
- Creating...
- >
- ) : (
- "View Onboarding"
- )}
-
- )}
setShowInviteMemberDialog(true)}
@@ -785,9 +1015,9 @@ export default function Teams() {
- {/* Tabs for Active, Deactivated, and Invitations */}
+ {/* Tabs for Active, Deactivated, Invitations, Hubs, Favorites, Blocked */}
-
+
Active ({activeMembers.length})
@@ -800,6 +1030,18 @@ export default function Teams() {
Invitations ({pendingInvites.length})
+
+
+ Hubs ({teamHubs.length})
+
+
+
+ Favorites ({userTeam?.favorite_staff_count || 0})
+
+
+
+ Blocked ({userTeam?.blocked_staff_count || 0})
+
{/* Active Members Tab */}
@@ -1012,6 +1254,276 @@ export default function Teams() {
)}
+
+ {/* Hubs Tab */}
+
+
+
+
+
+
+ Team Locations & Hubs
+
+
Manage your physical locations and departments
+
+
setShowAddHubDialog(true)} size="lg" className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg">
+
+ New Hub
+
+
+
+ {teamHubs.length > 0 ? (
+
+ {teamHubs.map((hub) => (
+
+
+
+
+
+
+
+
+
{hub.hub_name}
+ {hub.manager_name && (
+
+
+ {hub.manager_name}
+
+ )}
+
+
+
+ {hub.departments?.length || 0} Depts
+
+
+
+
+
+ {hub.address && (
+
+ )}
+
+ {hub.manager_email && (
+
+ )}
+
+ {hub.departments && hub.departments.length > 0 ? (
+
+
Departments
+
+ {hub.departments.map((dept, idx) => (
+
+
+
+
{dept.department_name}
+ {dept.cost_center && (
+
CC: {dept.cost_center}
+ )}
+
+ {dept.manager_name && (
+
+ {dept.manager_name}
+
+ )}
+
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
{
+ setSelectedHubForDept(hub);
+ setShowAddHubDepartmentDialog(true);
+ }}
+ >
+
+ Add Dept
+
+
{
+ setPreSelectedHub(hub.hub_name);
+ setInviteData({ ...inviteData, hub: hub.hub_name });
+ setShowInviteMemberDialog(true);
+ }}
+ >
+
+ Invite
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
No Hubs Yet
+
+ Create your first hub location to organize your team by physical locations and departments
+
+
setShowAddHubDialog(true)}>
+
+ Create First Hub
+
+
+ )}
+
+
+
+ {/* Favorites Tab */}
+
+
+
+
+
+ setFavoriteSearch(e.target.value)}
+ className="pl-10"
+ />
+
+
setShowAddFavoriteDialog(true)} className="bg-[#0A39DF]">
+
+ Add Favorite
+
+
+
+ {userTeam?.favorite_staff && userTeam.favorite_staff.length > 0 ? (
+
+ {userTeam.favorite_staff.filter(f =>
+ !favoriteSearch ||
+ f.staff_name?.toLowerCase().includes(favoriteSearch.toLowerCase()) ||
+ f.position?.toLowerCase().includes(favoriteSearch.toLowerCase())
+ ).map((fav) => (
+
+
+
+
+
+
+ {fav.staff_name?.charAt(0)}
+
+
+
+
{fav.staff_name}
+
{fav.position}
+
+
+
+
+ removeFromFavorites(fav.staff_id)}
+ className="w-full border-amber-300 hover:bg-amber-100 text-xs"
+ >
+ Remove
+
+
+
+ ))}
+
+ ) : (
+
+
+
No Favorite Staff
+
Mark staff as favorites to see them here
+
setShowAddFavoriteDialog(true)} className="bg-[#0A39DF]">
+
+ Add Your First Favorite
+
+
+ )}
+
+
+
+ {/* Blocked Staff Tab */}
+
+
+
+
+
+ setBlockedSearch(e.target.value)}
+ className="pl-10"
+ />
+
+
setShowAddBlockedDialog(true)} variant="outline" className="border-red-300 text-red-600 hover:bg-red-50">
+
+ Block Staff
+
+
+
+ {userTeam?.blocked_staff && userTeam.blocked_staff.length > 0 ? (
+
+ {userTeam.blocked_staff.filter(b =>
+ !blockedSearch ||
+ b.staff_name?.toLowerCase().includes(blockedSearch.toLowerCase())
+ ).map((blocked) => (
+
+
+
+
+
+
+ {blocked.staff_name?.charAt(0)}
+
+
+
+
{blocked.staff_name}
+
Reason: {blocked.reason || 'No reason provided'}
+
Blocked {new Date(blocked.blocked_date).toLocaleDateString()}
+
+
+
removeFromBlocked(blocked.staff_id)}
+ className="border-red-300 hover:bg-red-100 text-red-600 text-xs"
+ >
+ Unblock
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
No Blocked Staff
+
Blocked staff will appear here
+
+ )}
+
+
@@ -1074,6 +1586,46 @@ export default function Teams() {
+
+
+
+
+
setInviteData({ ...inviteData, hub: e.target.value })}
+ placeholder="e.g., BVG300"
+ list="existing-hubs"
+ />
+
+
+ {preSelectedHub && ✨ Pre-selected from hub creation • }
+ {teamHubs.find(h => h.hub_name === inviteData.hub) ? '✓ Existing hub' : inviteData.hub ? '+ Will create new hub' : 'Type to search or create'}
+
+
+
+
+ setInviteData({ ...inviteData, department: e.target.value })}
+ placeholder="e.g., Catering FOH"
+ list="existing-departments"
+ />
+
+
+
setShowInviteMemberDialog(false)}>Cancel
+
+
+ setEditingMember({ ...editingMember, hub: e.target.value })}
+ placeholder="e.g., BVG300"
+ list="existing-hubs-edit"
+ />
+
+
)}
@@ -1266,8 +1832,257 @@ export default function Teams() {
+
+ {/* Add Hub Dialog */}
+
+
+ {/* Add Hub Department Dialog */}
+
+
+ {/* Add Favorite Staff Dialog */}
+
+
+ {/* Add Blocked Staff Dialog */}
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend-web/src/pages/VendorMarketplace.jsx b/frontend-web/src/pages/VendorMarketplace.jsx
index 3c6124a0..3efb75c6 100644
--- a/frontend-web/src/pages/VendorMarketplace.jsx
+++ b/frontend-web/src/pages/VendorMarketplace.jsx
@@ -29,7 +29,7 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Textarea } from "@/components/ui/textarea";
-import { Search, MapPin, Users, Star, DollarSign, TrendingUp, MessageSquare, CheckCircle, Award, Filter, Grid, List, Phone, Mail, Building2, Zap, ArrowRight, ChevronDown, ChevronUp, UserCheck, Briefcase, Shield, Crown, X, Edit2, Clock, Target } from "lucide-react";
+import { Search, MapPin, Users, Star, DollarSign, TrendingUp, MessageSquare, CheckCircle, Award, Filter, Grid, List, Phone, Mail, Building2, Zap, ArrowRight, ChevronDown, ChevronUp, UserCheck, Briefcase, Shield, Crown, X, Edit2, Clock, Target, Handshake } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
export default function VendorMarketplace() {
@@ -287,29 +287,25 @@ export default function VendorMarketplace() {
{/* Hero Header */}
-
-
+
-
-
+
+
-
Vendor Marketplace
-
Find the perfect vendor partner for your staffing needs
+
Vendor Marketplace
+
Find the perfect vendor partner for your staffing needs
-
-
-
{filteredVendors.length} Active Vendors
+
+
+ {filteredVendors.length} Active Vendors
-
-
-
Verified & Approved
+
+
+ Verified & Approved
@@ -322,11 +318,11 @@ export default function VendorMarketplace() {
-
-
+
+
-
@@ -354,32 +350,32 @@ export default function VendorMarketplace() {
{/* Stats Grid */}
-
-
+
+
{preferredVendor.staffCount}
Staff
-
-
+
+
{preferredVendor.rating.toFixed(1)}
Rating
-
-
+
-
-
+
+
{preferredVendor.responseTime}
Response
-
-
+
+
${Math.round(preferredVendor.minRate)}
From/hr
@@ -394,32 +390,32 @@ export default function VendorMarketplace() {
{/* Benefits Banner */}
-
-
+
+
-
Priority Support
-
Faster responses
+
Priority Support
+
Faster responses
-
-
+
+
-
Dedicated Manager
+
Dedicated Manager
Direct contact
-
-
-
+
+
+
-
Better Rates
-
Volume pricing
+
Better Rates
+
Volume pricing
@@ -457,7 +453,7 @@ export default function VendorMarketplace() {
{/* Stats Cards */}
-
+
@@ -465,46 +461,14 @@ export default function VendorMarketplace() {
{vendors.length}
Approved
-
-
-
-
-
-
Staff
-
{staff.length}
-
Available
-
-
-
-
-
-
-
-
-
-
-
-
-
Avg Rate
-
- ${Math.round(vendorsWithMetrics.reduce((sum, v) => sum + v.avgRate, 0) / vendorsWithMetrics.length || 0)}
-
-
Per hour
-
-
-
-
-
-
-
-
-
+
@@ -515,12 +479,42 @@ export default function VendorMarketplace() {
Average
-
+
+
+
+
+
+
Fill Rate
+
98%
+
Success rate
+
+
+
+
+
+
+
+
+
+
+
+
+
Response
+
2h
+
Avg time
+
+
+
+
+
+
+
{/* Filters */}
@@ -635,13 +629,16 @@ export default function VendorMarketplace() {
const isExpanded = expandedVendors[vendor.id];
return (
-
-
+
+
-
-
+
+ {vendor.company_logo ? (
+
+ ) : null}
+
{vendor.legal_name?.charAt(0)}
@@ -652,12 +649,12 @@ export default function VendorMarketplace() {
-
+
{vendor.legal_name}
-
-
-
{vendor.rating.toFixed(1)}
+
+
+ {vendor.rating.toFixed(1)}
@@ -667,20 +664,20 @@ export default function VendorMarketplace() {
{vendor.service_specialty && (
-
+
{vendor.service_specialty}
)}
-
+
{vendor.region || vendor.city}
-
+
{vendor.staffCount} Staff
-
+
{vendor.responseTime}
@@ -688,19 +685,19 @@ export default function VendorMarketplace() {
-
-
Starting from
-
${vendor.minRate}
-
per hour
+
+
Starting from
+
${vendor.minRate}
+
per hour
{vendor.clientsInSector > 0 && (
-
+
-
- {vendor.clientsInSector}
+
+ {vendor.clientsInSector}
-
+
in your area
@@ -711,7 +708,7 @@ export default function VendorMarketplace() {
{vendor.completedJobs} jobs
-
+
{vendor.rates.length} services
@@ -719,17 +716,17 @@ export default function VendorMarketplace() {
-
+
toggleVendorRates(vendor.id)} className="flex-1">
-
-
+
+
- Compare Rates
+ Compare Rates
{vendor.rates.length} services
{isExpanded ?
:
}
@@ -742,22 +739,21 @@ export default function VendorMarketplace() {
setPreferredMutation.mutate(vendor)}
disabled={setPreferredMutation.isPending}
- className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 font-bold shadow-md"
+ className="bg-cyan-100 hover:bg-cyan-200 text-slate-800 font-bold shadow-sm border border-cyan-200"
>
Set as Preferred
handleContactVendor(vendor)}
- className="border-2 hover:border-[#0A39DF] hover:bg-blue-50"
+ className="bg-amber-50 hover:bg-amber-100 text-slate-800 border border-amber-200"
>
Contact
handleCreateOrder(vendor)}
- className="bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-700 hover:to-green-700 shadow-md"
+ className="bg-purple-100 hover:bg-purple-200 text-slate-800 shadow-sm border border-purple-200"
>
Order Now
@@ -768,69 +764,39 @@ export default function VendorMarketplace() {
-
+
{Object.entries(vendor.ratesByCategory).map(([category, categoryRates]) => (
-
-
-
+
+
+
{category}
-
+
{categoryRates.length}
- {categoryRates.map((rate, idx) => {
- const baseWage = rate.employee_wage || 0;
- const markupAmount = baseWage * ((rate.markup_percentage || 0) / 100);
- const feeAmount = (baseWage + markupAmount) * ((rate.vendor_fee_percentage || 0) / 100);
-
- return (
-
-
-
-
-
- {idx + 1}
-
-
{rate.role_name}
-
-
-
-
-
Base Wage:
-
- ${baseWage.toFixed(2)}/hr
-
-
-
-
+ Markup:
-
- {rate.markup_percentage}% (+${markupAmount.toFixed(2)})
-
-
-
-
+ Admin Fee:
-
- {rate.vendor_fee_percentage}% (+${feeAmount.toFixed(2)})
-
-
-
-
-
-
-
You Pay
-
-
${rate.client_rate?.toFixed(0)}
-
per hour
-
+ {categoryRates.map((rate, idx) => {
+ return (
+
+
+
+
+ {idx + 1}
+
{rate.role_name}
+
+
+
+
${rate.client_rate?.toFixed(0)}
+
per hour
- );
- })}
+
+ );
+ })}
))}
@@ -860,16 +826,19 @@ export default function VendorMarketplace() {
{otherVendors.map((vendor) => (
-
+
-
-
+
+ {vendor.company_logo ? (
+
+ ) : null}
+
{vendor.legal_name?.charAt(0)}
- {vendor.legal_name}
+ {vendor.legal_name}
{vendor.completedJobs} jobs completed
@@ -877,19 +846,19 @@ export default function VendorMarketplace() {
| {vendor.service_specialty || '—'} |
-
+
{vendor.region}
|
-
+
- {vendor.rating.toFixed(1)}
+ {vendor.rating.toFixed(1)}
|
{vendor.clientsInSector > 0 ? (
-
+
{vendor.clientsInSector}
@@ -898,12 +867,12 @@ export default function VendorMarketplace() {
)}
|
- {vendor.staffCount}
+ {vendor.staffCount}
|
-
- ${vendor.minRate}
- /hour
+
+ ${vendor.minRate}
+ /hour
|
@@ -912,15 +881,15 @@ export default function VendorMarketplace() {
size="sm"
onClick={() => setPreferredMutation.mutate(vendor)}
disabled={setPreferredMutation.isPending}
- className="bg-blue-600 hover:bg-blue-700"
+ className="bg-cyan-100 hover:bg-cyan-200 text-slate-800 border border-cyan-200"
>
Set Preferred
handleContactVendor(vendor)}
+ className="bg-amber-50 hover:bg-amber-100 text-slate-800 border border-amber-200"
>
Contact
@@ -968,8 +937,11 @@ export default function VendorMarketplace() {
-
-
+
+ {contactModal.vendor?.company_logo ? (
+
+ ) : null}
+
{contactModal.vendor?.legal_name?.charAt(0)}
diff --git a/frontend-web/src/pages/index.jsx b/frontend-web/src/pages/index.jsx
index aa5b86c3..c6bb1921 100644
--- a/frontend-web/src/pages/index.jsx
+++ b/frontend-web/src/pages/index.jsx
@@ -130,6 +130,10 @@ import NotificationSettings from "./NotificationSettings";
import TaskBoard from "./TaskBoard";
+import InvoiceDetail from "./InvoiceDetail";
+
+import InvoiceEditor from "./InvoiceEditor";
+
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
const PAGES = {
@@ -264,6 +268,10 @@ const PAGES = {
TaskBoard: TaskBoard,
+ InvoiceDetail: InvoiceDetail,
+
+ InvoiceEditor: InvoiceEditor,
+
}
function _getCurrentPage(url) {
@@ -421,6 +429,10 @@ function PagesContent() {
} />
+ } />
+
+ } />
+
);
|