new version frontend-webpage

This commit is contained in:
José Salazar
2025-11-21 09:13:05 -05:00
parent 23dfba35cc
commit de1cc96ba0
56 changed files with 7736 additions and 3367 deletions

View File

@@ -12,7 +12,7 @@
# --- Flutter check --- # --- Flutter check ---
FLUTTER := $(shell which flutter) FLUTTER := $(shell which flutter)
ifeq ($(FLUTTER),) ifeq ($(FLUTTER),)
$(error "flutter not found in PATH. Please install Flutter and add it to your PATH.") #$(error "flutter not found in PATH. Please install Flutter and add it to your PATH.")
endif endif
# --- Firebase & GCP Configuration --- # --- Firebase & GCP Configuration ---

View File

@@ -0,0 +1,72 @@
import React, { useRef, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { MapPin } from "lucide-react";
export default function GoogleAddressInput({ value, onChange, placeholder = "Enter address...", className = "" }) {
const inputRef = useRef(null);
const autocompleteRef = useRef(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
// Check if Google Maps is already loaded
if (window.google && window.google.maps && window.google.maps.places) {
setIsLoaded(true);
return;
}
// Load Google Maps script
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=AIzaSyBkP7xH4NvR6C6vZ8Y3J7qX2QW8Z9vN3Zc&libraries=places`;
script.async = true;
script.defer = true;
script.onload = () => setIsLoaded(true);
document.head.appendChild(script);
return () => {
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
}, []);
useEffect(() => {
if (!isLoaded || !inputRef.current) return;
try {
// Initialize Google Maps Autocomplete
autocompleteRef.current = new window.google.maps.places.Autocomplete(inputRef.current, {
types: ['address'],
componentRestrictions: { country: 'us' },
});
// Handle place selection
autocompleteRef.current.addListener('place_changed', () => {
const place = autocompleteRef.current.getPlace();
if (place.formatted_address) {
onChange(place.formatted_address);
}
});
} catch (error) {
console.error('Error initializing Google Maps autocomplete:', error);
}
return () => {
if (autocompleteRef.current) {
window.google.maps.event.clearInstanceListeners(autocompleteRef.current);
}
};
}, [isLoaded, onChange]);
return (
<div className="relative">
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={`pl-10 ${className}`}
/>
</div>
);
}

View File

@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { Edit2, Trash2, ArrowLeftRight, Clock, MapPin, Check, X } from "lucide-react"; import { Edit2, Trash2, ArrowLeftRight, Clock, MapPin, Check, X } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { calculateOrderStatus } from "../orders/OrderStatusUtils";
export default function AssignedStaffManager({ event, shift, role }) { export default function AssignedStaffManager({ event, shift, role }) {
const { toast } = useToast(); const { toast } = useToast();
@@ -52,11 +53,19 @@ export default function AssignedStaffManager({ event, shift, role }) {
return s; return s;
}); });
await base44.entities.Event.update(event.id, { const updatedEvent = {
assigned_staff: updatedAssignedStaff, assigned_staff: updatedAssignedStaff,
shifts: updatedShifts, shifts: updatedShifts,
requested: Math.max((event.requested || 0) - 1, 0), // NEVER MODIFY REQUESTED - it's set by client, not by staff assignment
};
// Auto-update status based on staffing level
updatedEvent.status = calculateOrderStatus({
...event,
...updatedEvent
}); });
await base44.entities.Event.update(event.id, updatedEvent);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] }); queryClient.invalidateQueries({ queryKey: ['events'] });
@@ -135,7 +144,7 @@ export default function AssignedStaffManager({ event, shift, role }) {
key={staff.staff_id} key={staff.staff_id}
className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg hover:shadow-sm transition-shadow" className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg hover:shadow-sm transition-shadow"
> >
<Avatar className="w-10 h-10 bg-gradient-to-br from-green-600 to-emerald-600"> <Avatar className="w-10 h-10 bg-gradient-to-br from-blue-400 to-blue-500">
<AvatarFallback className="text-white font-bold"> <AvatarFallback className="text-white font-bold">
{staff.staff_name?.charAt(0) || 'S'} {staff.staff_name?.charAt(0) || 'S'}
</AvatarFallback> </AvatarFallback>

View File

@@ -62,7 +62,7 @@ const hasTimeConflict = (existingStart, existingEnd, newStart, newEnd, existingD
return (newStartMin < existingEndMin && newEndMin > existingStartMin); return (newStartMin < existingEndMin && newEndMin > existingStartMin);
}; };
export default function EventAssignmentModal({ open, onClose, order, onUpdate }) { export default function EventAssignmentModal({ open, onClose, order, onUpdate, isRapid = false }) {
const [selectedShiftIndex, setSelectedShiftIndex] = useState(0); const [selectedShiftIndex, setSelectedShiftIndex] = useState(0);
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0); const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
const [selectedStaffIds, setSelectedStaffIds] = useState([]); const [selectedStaffIds, setSelectedStaffIds] = useState([]);
@@ -196,6 +196,20 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
const updatedOrder = { ...order }; const updatedOrder = { ...order };
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || []; const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
const needed = parseInt(currentRole.count) || 0;
const currentAssigned = assignments.length;
const remaining = needed - currentAssigned;
// Strictly enforce the requested count
if (remaining <= 0) {
toast({
title: "Assignment Limit Reached",
description: `This position requested exactly ${needed} staff. Cannot assign more.`,
variant: "destructive",
});
return;
}
// Check for conflicts // Check for conflicts
const conflictingStaff = []; const conflictingStaff = [];
selectedStaffIds.forEach(staffId => { selectedStaffIds.forEach(staffId => {
@@ -215,13 +229,9 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
return; return;
} }
const needed = parseInt(currentRole.count) || 0;
const currentAssigned = assignments.length;
const remaining = needed - currentAssigned;
if (selectedStaffIds.length > remaining) { if (selectedStaffIds.length > remaining) {
toast({ toast({
title: "Too many selected", title: "Assignment Limit",
description: `Only ${remaining} more staff ${remaining === 1 ? 'is' : 'are'} needed.`, description: `Only ${remaining} more staff ${remaining === 1 ? 'is' : 'are'} needed.`,
variant: "destructive", variant: "destructive",
}); });
@@ -255,6 +265,16 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
const updatedOrder = { ...order }; const updatedOrder = { ...order };
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || []; const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
// Strictly enforce the requested count
if (assignments.length >= needed) {
toast({
title: "Assignment Limit Reached",
description: `This position requested exactly ${needed} staff. Cannot assign more.`,
variant: "destructive",
});
return;
}
// Check if already assigned // Check if already assigned
if (assignments.some(a => a.employee_id === staffMember.id)) { if (assignments.some(a => a.employee_id === staffMember.id)) {
toast({ toast({

File diff suppressed because it is too large Load Diff

View File

@@ -29,10 +29,12 @@ const convertTo12Hour = (time24) => {
} }
}; };
export default function ShiftCard({ shift, event }) { export default function ShiftCard({ shift, event, currentUser }) {
const [assignModal, setAssignModal] = useState({ open: false, role: null }); const [assignModal, setAssignModal] = useState({ open: false, role: null });
const roles = shift?.roles || []; const roles = shift?.roles || [];
const isVendor = currentUser?.user_role === 'vendor' || currentUser?.role === 'vendor';
const canAssignStaff = isVendor;
return ( return (
<> <>
@@ -99,7 +101,7 @@ export default function ShiftCard({ shift, event }) {
</div> </div>
</div> </div>
{remainingCount > 0 && ( {canAssignStaff && remainingCount > 0 && (
<Button <Button
onClick={() => setAssignModal({ open: true, role })} onClick={() => setAssignModal({ open: true, role })}
className="bg-[#0A39DF] hover:bg-blue-700 gap-2 font-semibold" className="bg-[#0A39DF] hover:bg-blue-700 gap-2 font-semibold"

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -32,7 +31,7 @@ import {
} from "@/components/ui/alert"; } from "@/components/ui/alert";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
export default function StaffAssignment({ assignedStaff = [], onChange, requestedCount = 0, eventId, eventName }) { export default function StaffAssignment({ assignedStaff = [], onChange, requestedCount = 0, eventId, eventName, isRapid = false, currentUser }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedStaff, setSelectedStaff] = useState([]); const [selectedStaff, setSelectedStaff] = useState([]);
const [filterDepartment, setFilterDepartment] = useState("all"); const [filterDepartment, setFilterDepartment] = useState("all");
@@ -40,6 +39,9 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isVendor = currentUser?.user_role === 'vendor' || currentUser?.role === 'vendor';
const canAssignStaff = isVendor;
const { data: allStaff, isLoading } = useQuery({ const { data: allStaff, isLoading } = useQuery({
queryKey: ['staff'], queryKey: ['staff'],
queryFn: () => base44.entities.Staff.list(), queryFn: () => base44.entities.Staff.list(),
@@ -77,8 +79,8 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
const uniqueDepartments = [...new Set(allStaff.map(s => s.department).filter(Boolean))]; const uniqueDepartments = [...new Set(allStaff.map(s => s.department).filter(Boolean))];
const uniqueHubs = [...new Set(allStaff.map(s => s.hub_location).filter(Boolean))]; const uniqueHubs = [...new Set(allStaff.map(s => s.hub_location).filter(Boolean))];
const remainingSlots = requestedCount - assignedStaff.length; const remainingSlots = requestedCount > 0 ? requestedCount - assignedStaff.length : Infinity;
const isFull = assignedStaff.length >= requestedCount && requestedCount > 0; const isFull = requestedCount > 0 && assignedStaff.length >= requestedCount;
// Get available (unassigned) staff // Get available (unassigned) staff
const availableStaff = allStaff.filter(staff => const availableStaff = allStaff.filter(staff =>
@@ -167,10 +169,10 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
}; };
const handleSelectAll = () => { const handleSelectAll = () => {
if (isFull && requestedCount > 0) { if (requestedCount > 0 && assignedStaff.length >= requestedCount) {
toast({ toast({
title: "Event Fully Staffed", title: "Assignment Limit Reached",
description: `All ${requestedCount} positions are filled. Cannot select more staff.`, description: `All ${requestedCount} positions are filled. Cannot select more.`,
variant: "destructive" variant: "destructive"
}); });
return; return;
@@ -190,10 +192,11 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
}; };
const handleAddStaff = async (staff) => { const handleAddStaff = async (staff) => {
if (isFull && requestedCount > 0) { // Strictly enforce the requested count
if (requestedCount > 0 && assignedStaff.length >= requestedCount) {
toast({ toast({
title: "Assignment Limit Reached", title: "Assignment Limit Reached",
description: `Cannot assign more than ${requestedCount} staff members. All positions are filled.`, description: `This order requested exactly ${requestedCount} staff. Cannot assign more.`,
variant: "destructive" variant: "destructive"
}); });
return; return;
@@ -234,8 +237,8 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
if (availableSlots <= 0) { if (availableSlots <= 0) {
toast({ toast({
title: "Event Fully Staffed", title: "Assignment Limit Reached",
description: `All ${requestedCount} positions are filled. Cannot assign more staff.`, description: `All ${requestedCount} positions are filled. Cannot assign more.`,
variant: "destructive" variant: "destructive"
}); });
return; return;
@@ -366,7 +369,12 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
<CardTitle className="text-slate-900 flex items-center gap-2"> <CardTitle className="text-slate-900 flex items-center gap-2">
<Users className="w-5 h-5 text-[#0A39DF]" /> <Users className="w-5 h-5 text-[#0A39DF]" />
Staff Assignment Staff Assignment
{requestedCount > 0 && ( {isRapid && requestedCount > 0 && (
<Badge className="bg-red-600 text-white">
RAPID: {requestedCount} {requestedCount === 1 ? 'position' : 'positions'}
</Badge>
)}
{!isRapid && requestedCount > 0 && (
<Badge variant={isFull ? "default" : "outline"} className={isFull ? "bg-green-600" : "border-amber-500 text-amber-700"}> <Badge variant={isFull ? "default" : "outline"} className={isFull ? "bg-green-600" : "border-amber-500 text-amber-700"}>
{assignedStaff.length} / {requestedCount} {assignedStaff.length} / {requestedCount}
{isFull && " ✓ Full"} {isFull && " ✓ Full"}
@@ -374,7 +382,7 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
)} )}
</CardTitle> </CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{assignedStaff.length > 0 && ( {canAssignStaff && assignedStaff.length > 0 && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -386,21 +394,22 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
Notify All Notify All
</Button> </Button>
)} )}
<Popover open={open} onOpenChange={setOpen}> {canAssignStaff && (
<PopoverTrigger asChild> <Popover open={open} onOpenChange={setOpen}>
<Button <PopoverTrigger asChild>
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white" <Button
disabled={isFull && requestedCount > 0} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white"
> disabled={isFull && requestedCount > 0}
<Plus className="w-4 h-4 mr-2" /> >
{isFull && requestedCount > 0 <Plus className="w-4 h-4 mr-2" />
? "Event Fully Staffed" {isFull && requestedCount > 0
: remainingSlots > 0 ? "Event Fully Staffed"
? `Add Staff (${remainingSlots} needed)` : remainingSlots > 0
: "Add Staff" ? `Add Staff (${remainingSlots} needed)`
} : "Add Staff"
</Button> }
</PopoverTrigger> </Button>
</PopoverTrigger>
<PopoverContent className="w-[500px] p-0" align="end"> <PopoverContent className="w-[500px] p-0" align="end">
<div className="p-4 border-b border-slate-200 space-y-3"> <div className="p-4 border-b border-slate-200 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -556,7 +565,7 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> )}
</div> </div>
</CardHeader> </CardHeader>
@@ -617,7 +626,7 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!staff.notified && ( {canAssignStaff && !staff.notified && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -629,33 +638,43 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
Notify Notify
</Button> </Button>
)} )}
<Button {canAssignStaff && (
size="sm" <Button
variant={staff.confirmed ? "default" : "outline"} size="sm"
onClick={() => handleToggleConfirmation(staff.staff_id)} variant={staff.confirmed ? "default" : "outline"}
className={staff.confirmed ? "bg-green-600 hover:bg-green-700" : ""} onClick={() => handleToggleConfirmation(staff.staff_id)}
> className={staff.confirmed ? "bg-green-600 hover:bg-green-700" : ""}
{staff.confirmed ? ( >
<> {staff.confirmed ? (
<CheckCircle2 className="w-4 h-4 mr-1" /> <>
Confirmed <CheckCircle2 className="w-4 h-4 mr-1" />
</> Confirmed
) : ( </>
<> ) : (
<Clock className="w-4 h-4 mr-1" /> <>
Pending <Clock className="w-4 h-4 mr-1" />
</> Pending
)} </>
</Button> )}
</Button>
)}
{!canAssignStaff && staff.confirmed && (
<Badge className="bg-green-600 text-white">
<CheckCircle2 className="w-3 h-3 mr-1" />
Confirmed
</Badge>
)}
<Button {canAssignStaff && (
size="icon" <Button
variant="ghost" size="icon"
onClick={() => handleRemoveStaff(staff.staff_id)} variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => handleRemoveStaff(staff.staff_id)}
> className="text-red-600 hover:text-red-700 hover:bg-red-50"
<X className="w-4 h-4" /> >
</Button> <X className="w-4 h-4" />
</Button>
)}
</div> </div>
</div> </div>
))} ))}
@@ -665,4 +684,4 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
</CardContent> </CardContent>
</Card> </Card>
); );
} }

View File

@@ -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
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create New Invoice</DialogTitle>
</DialogHeader>
<div className="py-6 text-center">
<div className="w-16 h-16 bg-blue-100 rounded-full mx-auto mb-4 flex items-center justify-center">
<FileEdit className="w-8 h-8 text-blue-600" />
</div>
<h3 className="text-lg font-semibold mb-2">Ready to create an invoice?</h3>
<p className="text-slate-600 mb-6">Use the advanced editor to create a detailed invoice with full control.</p>
<Button
onClick={handleAdvancedEditor}
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold h-12"
>
<FileEdit className="w-5 h-5 mr-2" />
Open Invoice Editor
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-start justify-between">
<div>
<DialogTitle className="text-2xl font-bold">{invoice.invoice_number}</DialogTitle>
<p className="text-sm text-slate-500 mt-1">{invoice.event_name}</p>
</div>
<Badge className={`${statusColors[invoice.status]} text-white px-4 py-2`}>
{invoice.status}
</Badge>
</div>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Header Information */}
<div className="grid grid-cols-2 gap-6">
{/* From Section */}
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 bg-[#0A39DF] rounded-full flex items-center justify-center">
<Building2 className="w-4 h-4 text-white" />
</div>
<h3 className="font-bold text-slate-900">From:</h3>
</div>
<div className="space-y-1 text-sm">
<p className="font-semibold">{invoice.from_company?.name || invoice.vendor_name}</p>
<p className="text-slate-600">{invoice.from_company?.address}</p>
<p className="text-slate-600">{invoice.from_company?.email}</p>
<p className="text-slate-600">{invoice.from_company?.phone}</p>
</div>
</div>
{/* To Section */}
<div className="bg-green-50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 bg-green-600 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-white" />
</div>
<h3 className="font-bold text-slate-900">To:</h3>
</div>
<div className="space-y-1 text-sm">
<p className="font-semibold">{invoice.to_company?.name || invoice.business_name}</p>
<p className="text-slate-600">{invoice.to_company?.address}</p>
<p className="text-slate-600">{invoice.to_company?.email}</p>
<p className="font-semibold text-slate-900 mt-2">{invoice.to_company?.manager || invoice.manager_name}</p>
<p className="text-slate-600">{invoice.to_company?.phone}</p>
<p className="text-slate-600">{invoice.to_company?.vendor_id}</p>
</div>
</div>
</div>
{/* Event Details */}
<div className="bg-slate-50 rounded-lg p-4">
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-slate-500">Event Date:</span>
<span className="ml-2 font-semibold">{invoice.event_date ? format(parseISO(invoice.event_date), 'MMM dd, yyyy') : '—'}</span>
</div>
<div>
<span className="text-slate-500">PO #:</span>
<span className="ml-2 font-semibold">{invoice.po_reference || '—'}</span>
</div>
<div>
<span className="text-slate-500">Due Date:</span>
<span className="ml-2 font-semibold text-red-600">{format(parseISO(invoice.due_date), 'MMM dd, yyyy')}</span>
</div>
</div>
</div>
<Separator />
{/* Roles and Staff Charges */}
<div className="space-y-6">
<h3 className="font-semibold text-lg">Staff Charges</h3>
{invoice.roles?.map((roleGroup, roleIdx) => (
<div key={roleIdx} className="border border-slate-200 rounded-lg overflow-hidden">
<div className="bg-slate-100 px-4 py-2 border-b border-slate-200">
<h4 className="font-bold text-slate-900">Role: {roleGroup.role_name}</h4>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-slate-50">
<tr>
{disputeMode && <th className="p-2 text-left font-semibold">Flag</th>}
<th className="p-2 text-left font-semibold">Name</th>
<th className="p-2 text-left font-semibold">Check-In</th>
<th className="p-2 text-left font-semibold">Check-Out</th>
<th className="p-2 text-right font-semibold">Worked</th>
<th className="p-2 text-right font-semibold">Reg Hrs</th>
<th className="p-2 text-right font-semibold">OT Hrs</th>
<th className="p-2 text-right font-semibold">DT Hrs</th>
<th className="p-2 text-right font-semibold">Rate</th>
<th className="p-2 text-right font-semibold">Reg Value</th>
<th className="p-2 text-right font-semibold">OT Value</th>
<th className="p-2 text-right font-semibold">DT Value</th>
<th className="p-2 text-right font-semibold">Total</th>
</tr>
</thead>
<tbody>
{roleGroup.staff_entries?.map((entry, entryIdx) => (
<tr key={entryIdx} className={`border-t border-slate-200 ${selectedItems.some(item => item.role_index === roleIdx && item.staff_index === entryIdx) ? 'bg-red-50' : ''}`}>
{disputeMode && (
<td className="p-2">
<input
type="checkbox"
checked={selectedItems.some(item => item.role_index === roleIdx && item.staff_index === entryIdx)}
onChange={() => {
const itemId = { role_index: roleIdx, staff_index: entryIdx };
setSelectedItems(prev =>
prev.some(item => item.role_index === roleIdx && item.staff_index === entryIdx)
? prev.filter(item => !(item.role_index === roleIdx && item.staff_index === entryIdx))
: [...prev, itemId]
);
}}
className="w-4 h-4"
/>
</td>
)}
<td className="p-2">{entry.staff_name}</td>
<td className="p-2">{entry.check_in}</td>
<td className="p-2">{entry.check_out}</td>
<td className="p-2 text-right">{entry.worked_hours?.toFixed(2)}</td>
<td className="p-2 text-right">{entry.regular_hours?.toFixed(2)}</td>
<td className="p-2 text-right">{entry.ot_hours?.toFixed(2)}</td>
<td className="p-2 text-right">{entry.dt_hours?.toFixed(2)}</td>
<td className="p-2 text-right">${entry.rate?.toFixed(2)}</td>
<td className="p-2 text-right">${entry.regular_value?.toFixed(2)}</td>
<td className="p-2 text-right">${entry.ot_value?.toFixed(2)}</td>
<td className="p-2 text-right">${entry.dt_value?.toFixed(2)}</td>
<td className="p-2 text-right font-bold">${entry.total?.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-slate-50 px-4 py-2 border-t border-slate-200 flex justify-end">
<span className="font-bold">Role Total: ${roleGroup.role_subtotal?.toFixed(2)}</span>
</div>
</div>
))}
</div>
{/* Totals */}
<div className="bg-slate-50 rounded-lg p-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-600">Sub-total:</span>
<span className="font-semibold">${invoice.subtotal?.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-600">Other charges:</span>
<span className="font-semibold">${(invoice.other_charges || 0)?.toFixed(2)}</span>
</div>
<Separator />
<div className="flex justify-between text-lg">
<span className="font-bold">Grand total:</span>
<span className="font-bold text-[#0A39DF]">${invoice.amount?.toFixed(2)}</span>
</div>
</div>
</div>
{/* Dispute Section */}
{disputeMode && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<h3 className="font-semibold text-red-900">Dispute Invoice</h3>
</div>
<div>
<Label>Reason for Dispute</Label>
<Select value={disputeReason} onValueChange={setDisputeReason}>
<SelectTrigger>
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Incorrect Hours">Incorrect Hours</SelectItem>
<SelectItem value="Incorrect Rate">Incorrect Rate</SelectItem>
<SelectItem value="Unauthorized Staff">Unauthorized Staff</SelectItem>
<SelectItem value="Service Not Rendered">Service Not Rendered</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Details</Label>
<Textarea
value={disputeDetails}
onChange={(e) => setDisputeDetails(e.target.value)}
placeholder="Provide detailed information about the dispute..."
rows={4}
/>
</div>
</div>
)}
{/* Payment Section */}
{invoice.status === "Approved" && isClient && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<CreditCard className="w-5 h-5 text-green-600" />
<h3 className="font-semibold text-green-900">Record Payment</h3>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Payment Method</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Credit Card">Credit Card</SelectItem>
<SelectItem value="ACH">ACH Transfer</SelectItem>
<SelectItem value="Wire Transfer">Wire Transfer</SelectItem>
<SelectItem value="Check">Check</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Reference Number</Label>
<input
type="text"
value={paymentRef}
onChange={(e) => setPaymentRef(e.target.value)}
placeholder="Transaction ID"
className="w-full px-3 py-2 border border-slate-300 rounded-md"
/>
</div>
</div>
</div>
)}
{/* Dispute Info */}
{invoice.status === "Disputed" && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h3 className="font-semibold text-amber-900 mb-2">Dispute Information</h3>
<p className="text-sm text-slate-700"><strong>Reason:</strong> {invoice.dispute_reason}</p>
<p className="text-sm text-slate-700 mt-1"><strong>Details:</strong> {invoice.dispute_details}</p>
<p className="text-xs text-slate-500 mt-2">Disputed by {invoice.disputed_by} on {format(parseISO(invoice.disputed_date), 'MMM dd, yyyy')}</p>
</div>
)}
{/* Notes */}
{invoice.notes && (
<div>
<Label className="text-sm font-semibold">Notes</Label>
<p className="text-sm text-slate-600 mt-1">{invoice.notes}</p>
</div>
)}
</div>
<DialogFooter className="flex flex-wrap gap-2">
<div className="flex gap-2 flex-wrap w-full justify-between">
<div className="flex gap-2">
<Button variant="outline" onClick={handleDownloadPDF}>
<Download className="w-4 h-4 mr-2" />
Download
</Button>
<Button variant="outline" onClick={handleEmailInvoice}>
<Mail className="w-4 h-4 mr-2" />
Email
</Button>
<Button variant="outline" onClick={() => window.print()}>
<Printer className="w-4 h-4 mr-2" />
Print
</Button>
</div>
<div className="flex gap-2">
{canApprove && (
<Button onClick={handleApprove} className="bg-green-600 hover:bg-green-700">
<CheckCheck className="w-4 h-4 mr-2" />
Approve
</Button>
)}
{canDispute && !disputeMode && (
<Button onClick={() => setDisputeMode(true)} variant="destructive">
<Flag className="w-4 h-4 mr-2" />
Dispute
</Button>
)}
{disputeMode && (
<>
<Button variant="outline" onClick={() => setDisputeMode(false)}>
Cancel
</Button>
<Button onClick={handleDispute} variant="destructive" disabled={!disputeReason}>
Submit Dispute
</Button>
</>
)}
{canPay && (
<Button
onClick={handlePay}
disabled={!paymentMethod}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
<DollarSign className="w-4 h-4 mr-2" />
Mark as Paid
</Button>
)}
{canEdit && (
<Button variant="outline">
<Edit3 className="w-4 h-4 mr-2" />
Edit
</Button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
<div className="max-w-7xl mx-auto p-6">
{/* Header */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4">
<FileText className="w-8 h-8 text-[#0A39DF]" />
<div>
<h1 className="text-3xl font-bold text-slate-900">{invoice.invoice_number}</h1>
<Badge className={`${statusColors[invoice.status]} text-white px-3 py-1 mt-2`}>
{invoice.status}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handlePrint}>
<Printer className="w-4 h-4 mr-2" />
Print
</Button>
{canDispute && (
<Button variant="outline" className="text-red-600 border-red-200 hover:bg-red-50" onClick={() => setShowDisputeDialog(true)}>
<Flag className="w-4 h-4 mr-2" />
Dispute Invoice
</Button>
)}
{canApprove && (
<Button className="bg-green-600 hover:bg-green-700" onClick={handleApprove}>
<CheckCircle className="w-4 h-4 mr-2" />
Accept Invoice
</Button>
)}
</div>
</div>
{/* Event Info */}
<div className="flex flex-wrap gap-6 text-sm text-slate-600">
<div>
<span className="font-semibold">Event Name:</span> {invoice.event_name}
</div>
<div>
<span className="font-semibold">PO#:</span> {invoice.po_reference || "N/A"}
</div>
<div>
<span className="font-semibold">Date:</span> {invoice.event_date ? format(parseISO(invoice.event_date), 'M.d.yyyy') : '—'}
</div>
<div>
<span className="font-semibold">Due date:</span> <span className="text-red-600 font-bold">{format(parseISO(invoice.due_date), 'M.d.yyyy')}</span>
</div>
</div>
</div>
{/* KROW Logo */}
<div className="mb-6">
<img
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
alt="KROW"
className="h-12"
/>
</div>
{/* From and To */}
<div className="grid grid-cols-2 gap-6 mb-8">
<div className="bg-blue-50 rounded-xl p-6 border-2 border-blue-200">
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 bg-[#0A39DF] rounded-full flex items-center justify-center">
<span className="text-white font-bold text-sm">F</span>
</div>
<h3 className="font-bold text-lg text-slate-900">From:</h3>
</div>
<div className="space-y-1 text-sm">
<p className="font-bold text-slate-900">{invoice.from_company?.name || invoice.vendor_name}</p>
<p className="text-slate-600">{invoice.from_company?.address}</p>
<p className="text-slate-600">{invoice.from_company?.email}</p>
<p className="text-slate-600">{invoice.from_company?.phone}</p>
</div>
</div>
<div className="bg-green-50 rounded-xl p-6 border-2 border-green-200">
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 bg-green-600 rounded-full flex items-center justify-center">
<span className="text-white font-bold text-sm">T</span>
</div>
<h3 className="font-bold text-lg text-slate-900">To:</h3>
</div>
<div className="space-y-1 text-sm">
<p className="font-bold text-slate-900">{invoice.to_company?.name || invoice.business_name}</p>
<p className="text-slate-600">{invoice.to_company?.address}</p>
<p className="text-slate-600">{invoice.to_company?.email}</p>
<div className="grid grid-cols-2 gap-4 mt-3 pt-3 border-t border-green-200">
<div>
<p className="text-xs text-slate-500">Main Kitchen</p>
<p className="font-semibold text-slate-900">{invoice.to_company?.manager || invoice.manager_name}</p>
</div>
<div>
<p className="text-xs text-slate-500">Manager Name</p>
<p className="font-semibold text-slate-900">{invoice.to_company?.phone}</p>
</div>
</div>
<p className="text-slate-600">{invoice.to_company?.vendor_id || "Vendor #"}</p>
</div>
</div>
</div>
{/* Staff Charges Table */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden mb-6">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-bold text-slate-700">#</th>
<th className="px-4 py-3 text-left text-xs font-bold text-slate-700">Date</th>
<th className="px-4 py-3 text-left text-xs font-bold text-slate-700">Position</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">Worked Hours</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">Reg Hours</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">OT Hours</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">DT Hours</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">Reg Value</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">OT Value</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">DT Value</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">Total</th>
<th className="px-4 py-3 text-center text-xs font-bold text-slate-700">Actions</th>
</tr>
</thead>
<tbody>
{invoice.roles?.map((roleGroup, roleIdx) => (
<React.Fragment key={roleIdx}>
{roleGroup.staff_entries?.map((entry, entryIdx) => (
<tr key={`${roleIdx}-${entryIdx}`} className="border-b border-slate-100 hover:bg-slate-50">
<td className="px-4 py-3 text-sm text-slate-900">{roleIdx + 1}</td>
<td className="px-4 py-3 text-sm text-slate-600">{entry.date ? format(parseISO(entry.date), 'M/d/yyyy') : '—'}</td>
<td className="px-4 py-3 text-sm font-medium text-slate-900">{entry.position}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">{entry.worked_hours?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">{entry.regular_hours?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">{entry.ot_hours?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">{entry.dt_hours?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">${entry.regular_value?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">${entry.ot_value?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">${entry.dt_value?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right font-bold text-slate-900">${entry.total?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>View Details</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">Flag Entry</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
<tr className="bg-slate-100 font-semibold">
<td colSpan="10" className="px-4 py-2 text-sm text-slate-700">Total</td>
<td className="px-4 py-2 text-sm text-right font-bold">${roleGroup.role_subtotal?.toFixed(2)}</td>
<td></td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
{/* Other Charges */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden mb-6">
<div className="bg-slate-50 px-6 py-3 border-b border-slate-200">
<h3 className="font-bold text-slate-900">Other charges</h3>
</div>
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-bold text-slate-700">#</th>
<th className="px-6 py-3 text-left text-xs font-bold text-slate-700">Charge</th>
<th className="px-6 py-3 text-right text-xs font-bold text-slate-700">QTY</th>
<th className="px-6 py-3 text-right text-xs font-bold text-slate-700">Rate</th>
<th className="px-6 py-3 text-right text-xs font-bold text-slate-700">Amount</th>
</tr>
</thead>
<tbody>
{(!invoice.other_charges || invoice.other_charges === 0) ? (
<tr>
<td colSpan="5" className="px-6 py-8 text-center text-sm text-slate-500">
No additional charges
</td>
</tr>
) : (
<tr className="border-b border-slate-100">
<td className="px-6 py-3 text-sm">1</td>
<td className="px-6 py-3 text-sm">Additional Charges</td>
<td className="px-6 py-3 text-sm text-right">1</td>
<td className="px-6 py-3 text-sm text-right">${invoice.other_charges?.toFixed(2)}</td>
<td className="px-6 py-3 text-sm text-right font-semibold">${invoice.other_charges?.toFixed(2)}</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Totals */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<div className="max-w-md ml-auto space-y-3">
<div className="flex justify-between text-base">
<span className="text-slate-600">Sub-total:</span>
<span className="font-bold text-slate-900">${invoice.subtotal?.toFixed(2)}</span>
</div>
<div className="flex justify-between text-base">
<span className="text-slate-600">Other charges:</span>
<span className="font-bold text-slate-900">${(invoice.other_charges || 0)?.toFixed(2)}</span>
</div>
<div className="border-t-2 border-slate-300 pt-3 flex justify-between text-xl">
<span className="font-bold text-slate-900">Grand total:</span>
<span className="font-bold text-[#0A39DF]">${invoice.amount?.toFixed(2)}</span>
</div>
</div>
</div>
{/* Footer */}
<div className="mt-8 flex items-center justify-between text-sm text-slate-500">
<img
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
alt="KROW"
className="h-8"
/>
<span>Page 1</span>
</div>
</div>
{/* Dispute Dialog */}
<Dialog open={showDisputeDialog} onOpenChange={setShowDisputeDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Dispute Invoice</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>Reason for Dispute</Label>
<Select value={disputeReason} onValueChange={setDisputeReason}>
<SelectTrigger>
<SelectValue placeholder="Select reason" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Incorrect Hours">Incorrect Hours</SelectItem>
<SelectItem value="Incorrect Rate">Incorrect Rate</SelectItem>
<SelectItem value="Unauthorized Staff">Unauthorized Staff</SelectItem>
<SelectItem value="Service Not Rendered">Service Not Rendered</SelectItem>
<SelectItem value="Calculation Error">Calculation Error</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Details</Label>
<Textarea
value={disputeDetails}
onChange={(e) => setDisputeDetails(e.target.value)}
placeholder="Provide detailed information about the dispute..."
rows={5}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDisputeDialog(false)}>
Cancel
</Button>
<Button
onClick={handleDispute}
disabled={!disputeReason}
className="bg-red-600 hover:bg-red-700"
>
Submit Dispute
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -16,10 +15,12 @@ import {
AlertCircle, AlertCircle,
CheckCircle, CheckCircle,
ArrowRight, ArrowRight,
MoreVertical MoreVertical,
CheckSquare,
Package
} from "lucide-react"; } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow, format, isToday, isYesterday, isThisWeek, startOfDay } from "date-fns";
const iconMap = { const iconMap = {
calendar: Calendar, calendar: Calendar,
@@ -41,6 +42,7 @@ const colorMap = {
export default function NotificationPanel({ isOpen, onClose }) { export default function NotificationPanel({ isOpen, onClose }) {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [activeFilter, setActiveFilter] = useState('all');
const { data: user } = useQuery({ const { data: user } = useQuery({
queryKey: ['current-user-notifications'], queryKey: ['current-user-notifications'],
@@ -126,15 +128,96 @@ export default function NotificationPanel({ isOpen, onClose }) {
}, },
}); });
const newNotifications = notifications.filter(n => !n.is_read); // Categorize by type
const olderNotifications = notifications.filter(n => n.is_read); 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) => { const handleAction = (notification) => {
if (notification.action_link) { // Mark as read when clicking
navigate(createPageUrl(notification.action_link)); if (!notification.is_read) {
markAsReadMutation.mutate({ id: notification.id }); 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 ( 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" className="fixed right-0 top-0 h-full w-full sm:w-[440px] bg-white shadow-2xl z-50 flex flex-col"
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200"> <div className="border-b border-slate-200">
<div className="flex items-center gap-3"> <div className="flex items-center justify-between p-6">
<Bell className="w-6 h-6 text-[#1C323E]" /> <div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-[#1C323E]">Notifications</h2> <Bell className="w-6 h-6 text-[#1C323E]" />
</div> <h2 className="text-xl font-bold text-[#1C323E]">Notifications</h2>
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-purple-500 flex items-center justify-center text-white font-bold">
{user?.full_name?.split(' ').map(n => n[0]).join('').slice(0, 2) || 'U'}
</div> </div>
<Button variant="ghost" size="icon" onClick={onClose}> <div className="flex items-center gap-2">
<MoreVertical className="w-5 h-5" /> <Button variant="ghost" size="icon" onClick={onClose}>
</Button> <X className="w-5 h-5" />
<Button variant="ghost" size="icon" onClick={onClose}> </Button>
<X className="w-5 h-5" /> </div>
</Button> </div>
{/* Filter Tabs */}
<div className="flex items-center gap-2 px-6 pb-4">
<button
onClick={() => 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 <span className="ml-1">{allCount}</span>
</button>
<button
onClick={() => 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 <span className="ml-1">{mentionsCount}</span>
</button>
<button
onClick={() => 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 <span className="ml-1">{invitesCount}</span>
</button>
</div> </div>
</div> </div>
{/* Notifications List */} {/* Notifications List */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{newNotifications.length > 0 && ( {filteredNotifications.length === 0 ? (
<div className="p-6">
<h3 className="text-sm font-bold text-slate-900 mb-4">New</h3>
<div className="space-y-4">
{newNotifications.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const colorClass = colorMap[notification.icon_color] || colorMap.blue;
return (
<div key={notification.id} className="relative">
<div className="absolute left-0 top-0 w-2 h-2 bg-red-500 rounded-full" />
<div className="flex gap-4 pl-4">
<div className={`w-12 h-12 rounded-full ${colorClass} flex items-center justify-center flex-shrink-0`}>
<Icon className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-1">
<h4 className="font-semibold text-slate-900">{notification.title}</h4>
<span className="text-xs text-slate-500 whitespace-nowrap ml-2">
{formatDistanceToNow(new Date(notification.created_date), { addSuffix: true })}
</span>
</div>
<p className="text-sm text-slate-600 mb-3">{notification.description}</p>
<div className="flex items-center gap-4">
{notification.action_link && (
<button
onClick={() => handleAction(notification)}
className="text-blue-600 hover:text-blue-700 text-sm font-medium flex items-center gap-1"
>
{notification.action_label || 'View'}
<ArrowRight className="w-4 h-4" />
</button>
)}
<button
onClick={() => markAsReadMutation.mutate({ id: notification.id })}
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Mark as Read
</button>
<button
onClick={() => deleteMutation.mutate({ id: notification.id })}
className="text-red-600 hover:text-red-700 text-sm font-medium"
>
Delete
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{olderNotifications.length > 0 && (
<div className="p-6 border-t border-slate-100">
<h3 className="text-sm font-bold text-slate-900 mb-4">Older</h3>
<div className="space-y-4">
{olderNotifications.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const colorClass = colorMap[notification.icon_color] || colorMap.blue;
return (
<div key={notification.id} className="flex gap-4 opacity-70 hover:opacity-100 transition-opacity">
<div className={`w-12 h-12 rounded-full ${colorClass} flex items-center justify-center flex-shrink-0`}>
<Icon className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-1">
<h4 className="font-semibold text-slate-900">{notification.title}</h4>
<span className="text-xs text-slate-500 whitespace-nowrap ml-2">
{formatDistanceToNow(new Date(notification.created_date), { addSuffix: true })}
</span>
</div>
<p className="text-sm text-slate-600 mb-3">{notification.description}</p>
<div className="flex items-center gap-4">
{notification.action_link && (
<button
onClick={() => handleAction(notification)}
className="text-blue-600 hover:text-blue-700 text-sm font-medium flex items-center gap-1"
>
{notification.action_label || 'View'}
<ArrowRight className="w-4 h-4" />
</button>
)}
<button
onClick={() => deleteMutation.mutate({ id: notification.id })}
className="text-red-600 hover:text-red-700 text-sm font-medium"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{notifications.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center p-8"> <div className="flex flex-col items-center justify-center h-full text-center p-8">
<Bell className="w-16 h-16 text-slate-300 mb-4" /> <Bell className="w-16 h-16 text-slate-300 mb-4" />
<h3 className="text-lg font-semibold text-slate-900 mb-2">No notifications</h3> <h3 className="text-lg font-semibold text-slate-900 mb-2">No notifications</h3>
<p className="text-slate-600">You're all caught up!</p> <p className="text-slate-600">You're all caught up!</p>
</div> </div>
) : (
<>
{/* TODAY */}
{groupedNotifications.today.length > 0 && (
<div className="px-6 py-4">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">TODAY</h3>
<div className="space-y-4">
{groupedNotifications.today.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const isUnread = !notification.is_read;
const notifDate = new Date(notification.created_date);
return (
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 relative">
{isUnread && (
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
)}
<div
onClick={() => 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'
}`}>
<Icon className={`w-5 h-5 ${
notification.icon_color === 'blue' ? 'text-blue-600' :
notification.icon_color === 'green' ? 'text-green-600' :
notification.icon_color === 'red' ? 'text-red-600' :
notification.icon_color === 'purple' ? 'text-purple-600' :
'text-slate-600'
}`} />
</div>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => handleAction(notification)}
>
<div className="flex items-start justify-between mb-1">
<p className="text-sm text-slate-900">
<span className="font-semibold">{notification.title}</span> {notification.description}
</p>
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
{formatDistanceToNow(notifDate, { addSuffix: true })}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
{notification.action_link && (
<span className="font-medium text-blue-600">{notification.action_label}</span>
)}
<span className="text-slate-500">{format(notifDate, 'h:mm a')}</span>
{isUnread && (
<button
onClick={(e) => {
e.stopPropagation();
markAsReadMutation.mutate({ id: notification.id });
}}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Mark as Read
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate({ id: notification.id });
}}
className="text-red-600 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* YESTERDAY */}
{groupedNotifications.yesterday.length > 0 && (
<div className="px-6 py-4 border-t border-slate-100">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">YESTERDAY</h3>
<div className="space-y-4">
{groupedNotifications.yesterday.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const isUnread = !notification.is_read;
const notifDate = new Date(notification.created_date);
return (
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 relative">
{isUnread && (
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
)}
<div
onClick={() => 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'
}`}>
<Icon className={`w-5 h-5 ${
notification.icon_color === 'blue' ? 'text-blue-600' :
notification.icon_color === 'green' ? 'text-green-600' :
notification.icon_color === 'red' ? 'text-red-600' :
notification.icon_color === 'purple' ? 'text-purple-600' :
'text-slate-600'
}`} />
</div>
<div
className="flex-1 min-w-0"
onClick={() => notification.action_link && handleAction(notification)}
>
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
<p className="text-sm text-slate-900">
<span className="font-semibold">{notification.title}</span> {notification.description}
</p>
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
{formatDistanceToNow(notifDate, { addSuffix: true })}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
{notification.action_link && (
<span className="font-medium text-blue-600">{notification.action_label}</span>
)}
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy h:mm a')}</span>
{isUnread && (
<button
onClick={(e) => {
e.stopPropagation();
markAsReadMutation.mutate({ id: notification.id });
}}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Mark as Read
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate({ id: notification.id });
}}
className="text-red-600 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* THIS WEEK */}
{groupedNotifications.thisWeek.length > 0 && (
<div className="px-6 py-4 border-t border-slate-100">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">THIS WEEK</h3>
<div className="space-y-4">
{groupedNotifications.thisWeek.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const isUnread = !notification.is_read;
const notifDate = new Date(notification.created_date);
return (
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 opacity-80 hover:opacity-100">
{isUnread && (
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
)}
<div
onClick={() => 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'
}`}>
<Icon className={`w-5 h-5 ${
notification.icon_color === 'blue' ? 'text-blue-600' :
notification.icon_color === 'green' ? 'text-green-600' :
notification.icon_color === 'red' ? 'text-red-600' :
notification.icon_color === 'purple' ? 'text-purple-600' :
'text-slate-600'
}`} />
</div>
<div
className="flex-1 min-w-0"
onClick={() => notification.action_link && handleAction(notification)}
>
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
<p className="text-sm text-slate-900">
<span className="font-semibold">{notification.title}</span> {notification.description}
</p>
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
{formatDistanceToNow(notifDate, { addSuffix: true })}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
{notification.action_link && (
<span className="font-medium text-blue-600">{notification.action_label}</span>
)}
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy h:mm a')}</span>
{isUnread && (
<button
onClick={(e) => {
e.stopPropagation();
markAsReadMutation.mutate({ id: notification.id });
}}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Mark as Read
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate({ id: notification.id });
}}
className="text-red-600 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* OLDER */}
{groupedNotifications.older.length > 0 && (
<div className="px-6 py-4 border-t border-slate-100">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">OLDER</h3>
<div className="space-y-4">
{groupedNotifications.older.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const isUnread = !notification.is_read;
const notifDate = new Date(notification.created_date);
return (
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 opacity-70 hover:opacity-100">
{isUnread && (
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
)}
<div
onClick={() => 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'
}`}>
<Icon className={`w-5 h-5 ${
notification.icon_color === 'blue' ? 'text-blue-600' :
notification.icon_color === 'green' ? 'text-green-600' :
notification.icon_color === 'red' ? 'text-red-600' :
notification.icon_color === 'purple' ? 'text-purple-600' :
'text-slate-600'
}`} />
</div>
<div
className="flex-1 min-w-0"
onClick={() => notification.action_link && handleAction(notification)}
>
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
<p className="text-sm text-slate-900">
<span className="font-semibold">{notification.title}</span> {notification.description}
</p>
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
{formatDistanceToNow(notifDate, { addSuffix: true })}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
{notification.action_link && (
<span className="font-medium text-blue-600">{notification.action_label}</span>
)}
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy h:mm a')}</span>
{isUnread && (
<button
onClick={(e) => {
e.stopPropagation();
markAsReadMutation.mutate({ id: notification.id });
}}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Mark as Read
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate({ id: notification.id });
}}
className="text-red-600 hover:text-red-700 font-medium"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</>
)} )}
</div> </div>
</motion.div> </motion.div>
@@ -293,4 +619,4 @@ export default function NotificationPanel({ isOpen, onClose }) {
)} )}
</AnimatePresence> </AnimatePresence>
); );
} }

View File

@@ -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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<div>
<DialogTitle className="text-2xl font-bold text-red-700">
Confirm Order Cancellation
</DialogTitle>
<DialogDescription className="text-slate-600 mt-1">
{feeData.feeApplies
? "⚠️ Cancellation fee will apply"
: "✅ No cancellation fee"
}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Event Summary */}
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<h4 className="font-bold text-slate-900 mb-3">{event.event_name}</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-blue-600" />
<span>{format(new Date(event.date), 'MMM d, yyyy')}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-600" />
<span>{eventStartTime}</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-600" />
<span>{assignedCount} Staff Assigned</span>
</div>
</div>
</div>
{/* Time Until Event */}
<Alert className={feeData.feeApplies ? "bg-red-50 border-red-300" : "bg-green-50 border-green-300"}>
<AlertDescription>
<div className="flex items-center gap-2 mb-2">
<Clock className={`w-5 h-5 ${feeData.feeApplies ? 'text-red-600' : 'text-green-600'}`} />
<span className="font-bold text-slate-900">
{feeData.hoursUntilEvent} hours until event
</span>
</div>
<p className="text-sm text-slate-700">
{feeData.feeApplies
? "Canceling within 24 hours triggers a 4-hour minimum fee per assigned worker."
: "You're canceling more than 24 hours in advance - no penalty applies."
}
</p>
</AlertDescription>
</Alert>
{/* Fee Breakdown */}
{feeData.feeApplies && (
<div className="bg-gradient-to-r from-red-50 to-orange-50 border-2 border-red-300 rounded-xl p-5">
<div className="flex items-center gap-2 mb-4">
<DollarSign className="w-5 h-5 text-red-600" />
<h4 className="font-bold text-red-900">Cancellation Fee Breakdown</h4>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-white rounded-lg">
<span className="text-sm text-slate-700">Assigned Staff</span>
<span className="font-bold text-slate-900">{assignedCount} workers</span>
</div>
<div className="flex items-center justify-between p-3 bg-white rounded-lg">
<span className="text-sm text-slate-700">Minimum Charge</span>
<span className="font-bold text-slate-900">4 hours each</span>
</div>
<div className="flex items-center justify-between p-4 bg-red-100 rounded-lg border-2 border-red-300">
<span className="font-bold text-red-900">Total Cancellation Fee</span>
<span className="text-2xl font-bold text-red-700">
${feeData.feeAmount.toLocaleString()}
</span>
</div>
</div>
</div>
)}
{/* Warning Text */}
<Alert className="bg-yellow-50 border-yellow-300">
<AlertDescription className="text-sm text-yellow-900">
<strong> This action cannot be undone.</strong> The vendor will be notified immediately,
and all assigned staff will be released from this event.
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Keep Order
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isSubmitting}
className="bg-red-600 hover:bg-red-700"
>
{isSubmitting ? "Canceling..." : `Confirm Cancellation${feeData.feeApplies ? ` ($${feeData.feeAmount})` : ''}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<Badge className={`${config.bg} text-white px-4 py-1.5 font-semibold`}>
{config.text}
</Badge>
);
};
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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader className="border-b pb-4">
<div className="flex items-start justify-between">
<div>
<DialogTitle className="text-2xl font-bold text-slate-900">{order.event_name}</DialogTitle>
<p className="text-sm text-slate-500 mt-1">Order Details & Information</p>
</div>
{getStatusBadge(order.status)}
</div>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Order Information */}
<div className="bg-slate-50 rounded-lg p-4">
<h3 className="font-semibold text-slate-900 mb-4">Order Information</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
<Calendar className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-xs text-slate-500">Event Date</p>
<p className="font-bold text-slate-900 text-sm">{safeFormatDate(order.date, 'MMM dd, yyyy')}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center">
<MapPin className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="text-xs text-slate-500">Location</p>
<p className="font-bold text-slate-900 text-sm">{order.hub || order.event_location || "—"}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-50 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-xs text-slate-500">Staff Assigned</p>
<p className="font-bold text-slate-900 text-sm">{assignedCount} / {requestedCount}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
<DollarSign className="w-5 h-5 text-amber-600" />
</div>
<div>
<p className="text-xs text-slate-500">Total Cost</p>
<p className="font-bold text-slate-900 text-sm">${(order.total || 0).toLocaleString()}</p>
</div>
</div>
</div>
</div>
{/* Business & Time Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-slate-50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Building2 className="w-4 h-4 text-blue-600" />
<p className="text-xs text-slate-500 font-semibold">Business</p>
</div>
<p className="font-bold text-slate-900">{order.business_name || "—"}</p>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-purple-600" />
<p className="text-xs text-slate-500 font-semibold">Time</p>
</div>
<p className="font-bold text-slate-900">{startTime} - {endTime}</p>
</div>
</div>
{/* Shifts & Roles */}
{order.shifts && order.shifts.length > 0 && (
<div>
<h3 className="font-semibold text-slate-900 mb-3">Shifts & Staff Requirements</h3>
<div className="space-y-3">
{order.shifts.map((shift, idx) => (
<div key={idx} className="bg-slate-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-semibold text-slate-900">{shift.shift_name || `Shift ${idx + 1}`}</p>
{shift.location && (
<p className="text-xs text-slate-500 flex items-center gap-1 mt-1">
<MapPin className="w-3 h-3" />
{shift.location}
</p>
)}
</div>
</div>
<div className="space-y-2">
{shift.roles?.map((role, roleIdx) => (
<div key={roleIdx} className="flex items-center justify-between bg-white rounded p-3">
<div>
<p className="font-medium text-slate-900">{role.role}</p>
<p className="text-xs text-slate-500">{role.department || "—"}</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xs text-slate-500">Required</p>
<p className="font-bold text-slate-900">{role.count || 0}</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-500">Time</p>
<p className="font-medium text-slate-900 text-sm">
{convertTo12Hour(role.start_time)} - {convertTo12Hour(role.end_time)}
</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-500">Rate</p>
<p className="font-bold text-emerald-600">${role.cost_per_hour}/hr</p>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Assigned Staff */}
{order.assigned_staff && order.assigned_staff.length > 0 && (
<div>
<h3 className="font-semibold text-slate-900 mb-3">Assigned Staff ({order.assigned_staff.length})</h3>
<div className="bg-slate-50 rounded-lg p-4">
<div className="space-y-3">
{order.assigned_staff.map((staff, idx) => {
const staffDetails = getStaffDetails(staff.staff_id);
const rating = staffDetails.rating || 0;
const reliability = staffDetails.reliability_score || 0;
const totalShifts = staffDetails.total_shifts || 0;
return (
<div key={idx} className="flex items-center justify-between bg-white rounded-lg p-4">
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12">
<AvatarImage
src={staffDetails.profile_picture || staffDetails.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.staff_name || 'Staff')}&background=10b981&color=fff&size=128`}
alt={staff.staff_name}
/>
</Avatar>
<div>
<p className="font-semibold text-slate-900">{staff.staff_name}</p>
<p className="text-sm text-slate-500">{staffDetails.position || staff.role || "—"}</p>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-center">
<div className="flex items-center gap-1 mb-1">
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
<span className="font-bold text-slate-900">{rating.toFixed(1)}</span>
</div>
<p className="text-xs text-slate-500">Rating</p>
</div>
<div className="text-center">
<p className="font-bold text-emerald-600">{reliability}%</p>
<p className="text-xs text-slate-500">On-Time Arrival</p>
</div>
<div className="text-center">
<p className="font-bold text-blue-600">{totalShifts}</p>
<p className="text-xs text-slate-500">Jobs</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
{/* Notes */}
{order.notes && (
<div>
<h3 className="font-semibold text-slate-900 mb-2">Additional Notes</h3>
<div className="bg-slate-50 rounded-lg p-4">
<p className="text-slate-700 text-sm whitespace-pre-wrap">{order.notes}</p>
</div>
</div>
)}
</div>
<DialogFooter className="border-t pt-4">
<div className="flex items-center justify-between w-full gap-3">
<Button
variant="outline"
onClick={handleViewFullOrder}
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
View Full Order
</Button>
<div className="flex items-center gap-2">
{canEditOrder(order) && (
<Button
variant="outline"
onClick={handleEditOrder}
className="flex items-center gap-2"
>
<Edit3 className="w-4 h-4" />
Edit Order
</Button>
)}
{canCancelOrder(order) && (
<Button
variant="destructive"
onClick={handleCancelOrder}
className="flex items-center gap-2"
>
<X className="w-4 h-4" />
Cancel Order
</Button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<Card className="border-2 border-orange-500 bg-orange-50 shadow-lg">
<CardHeader className="bg-gradient-to-r from-orange-100 to-red-50 border-b border-orange-200">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-orange-500 rounded-xl flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-white" />
</div>
<div>
<CardTitle className="text-xl font-bold text-orange-900">
Order Size Reduction Detected
</CardTitle>
<p className="text-sm text-orange-700 mt-1">
Client reduced headcount from {originalRequested} to {newRequested}
</p>
</div>
</div>
</CardHeader>
<CardContent className="p-6 space-y-4">
<Alert className="bg-white border-orange-300">
<AlertDescription className="text-slate-900">
<div className="flex items-center gap-2 mb-2">
<TrendingDown className="w-5 h-5 text-orange-600" />
<span className="font-bold">Action Required:</span>
</div>
<p className="text-sm">
You have <strong className="text-orange-700">{excessStaff} staff member{excessStaff !== 1 ? 's' : ''}</strong> assigned
that exceed{excessStaff === 1 ? 's' : ''} the new request.
You must unassign {excessStaff} worker{excessStaff !== 1 ? 's' : ''} to match the new headcount.
</p>
</AlertDescription>
</Alert>
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border-2 border-slate-200 rounded-xl p-4 text-center">
<p className="text-xs text-slate-500 mb-1">Original Request</p>
<p className="text-2xl font-bold text-slate-900">{originalRequested}</p>
</div>
<div className="bg-white border-2 border-orange-300 rounded-xl p-4 text-center">
<p className="text-xs text-orange-600 mb-1">New Request</p>
<p className="text-2xl font-bold text-orange-700">{newRequested}</p>
</div>
<div className="bg-white border-2 border-red-300 rounded-xl p-4 text-center">
<p className="text-xs text-red-600 mb-1">Must Remove</p>
<p className="text-2xl font-bold text-red-700">{excessStaff}</p>
</div>
</div>
<div className="space-y-3">
<Button
onClick={onManualUnassign}
variant="outline"
className="w-full border-2 border-slate-300 hover:bg-slate-50"
>
<UserMinus className="w-4 h-4 mr-2" />
Manually Select Which Staff to Remove
</Button>
{lowReliabilityStaff.length > 0 && (
<Button
onClick={onAutoUnassign}
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
>
<CheckCircle className="w-4 h-4 mr-2" />
Auto-Remove {excessStaff} Lowest Reliability Staff
</Button>
)}
</div>
{lowReliabilityStaff.length > 0 && (
<div className="bg-white border border-orange-200 rounded-lg p-4">
<p className="text-xs font-bold text-slate-700 mb-3 uppercase">
Suggested for Auto-Removal (Lowest Reliability):
</p>
<div className="space-y-2">
{lowReliabilityStaff.slice(0, excessStaff).map((staff, idx) => (
<div key={idx} className="flex items-center justify-between p-2 bg-red-50 rounded-lg border border-red-200">
<span className="text-sm font-medium text-slate-900">{staff.name}</span>
<Badge variant="outline" className="border-red-400 text-red-700">
Reliability: {staff.reliability}%
</Badge>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -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";
}

View File

@@ -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 (
<div className="bg-white rounded-2xl shadow-lg border-2 border-red-200 overflow-hidden">
<div className="bg-gradient-to-r from-red-500 to-orange-500 p-4 text-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<Zap className="w-6 h-6" />
</div>
<div>
<h2 className="text-xl font-bold flex items-center gap-2">
<Zap className="w-5 h-5" />
RAPID Order
</h2>
<p className="text-red-100 text-xs">Emergency staffing in minutes</p>
</div>
</div>
<div className="text-right text-xs text-red-100">
<div className="flex items-center gap-2">
<Calendar className="w-3 h-3" />
{format(new Date(), 'EEE, MMM dd, yyyy')}
</div>
<div className="flex items-center gap-2 mt-0.5">
<Clock className="w-3 h-3" />
{format(new Date(), 'h:mm a')}
</div>
</div>
</div>
</div>
<div className="p-6">
{/* Section Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-slate-900">Tell us what you need</h3>
<Badge className="bg-red-500 text-white text-xs">URGENT</Badge>
</div>
{/* Main Content */}
<div className="space-y-4">
{/* Icon + Message */}
<div className="text-center py-4">
<div className="w-16 h-16 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center mx-auto mb-3 shadow-lg">
<Zap className="w-8 h-8 text-white" />
</div>
<h4 className="text-lg font-bold text-slate-900 mb-1">Need staff urgently?</h4>
<p className="text-sm text-slate-600">Type or speak what you need. I'll handle the rest</p>
</div>
{/* Example Prompts */}
<div className="space-y-2">
{examples.map((example, idx) => (
<button
key={idx}
onClick={() => handleExampleClick(example.text)}
className={`w-full p-3 rounded-lg border-2 text-left transition-all hover:shadow-md text-sm ${example.color}`}
>
<span className="font-semibold">Example:</span> "{example.text}"
</button>
))}
</div>
{/* Input Area */}
{showConfirmation && parsedData ? (
<div className="space-y-4">
{/* AI Confirmation Card */}
<div className="bg-gradient-to-br from-orange-50 to-red-50 border-2 border-orange-200 rounded-xl p-4">
<div className="flex items-start gap-3 mb-3">
<div className="w-8 h-8 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center flex-shrink-0">
<Zap className="w-4 h-4 text-white" />
</div>
<div className="flex-1">
<p className="text-xs font-bold text-red-600 uppercase mb-1">AI Assistant</p>
<p className="text-sm text-slate-700">
Is this a RAPID ORDER for <strong>**{parsedData.count} {parsedData.role}**</strong> at <strong>**{parsedData.location}**</strong>?
</p>
</div>
</div>
<div className="space-y-2 mb-4">
<p className="text-xs text-slate-600">Start Time: <strong className="text-slate-900">{parsedData.startTime}</strong></p>
<p className="text-xs text-slate-600">End Time: <strong className="text-slate-900">{parsedData.endTime}</strong></p>
</div>
{/* Details Card */}
<div className="bg-white border-2 border-blue-200 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<Users className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-[10px] text-blue-600 font-semibold uppercase">Staff Needed</p>
<p className="text-sm font-bold text-slate-900">{parsedData.count} {parsedData.role}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<MapPin className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-[10px] text-blue-600 font-semibold uppercase">Location</p>
<p className="text-sm font-bold text-slate-900">{parsedData.location}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<Clock className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-[10px] text-blue-600 font-semibold uppercase">Time</p>
<p className="text-sm font-bold text-slate-900">Start: {parsedData.startTime} | End: {parsedData.endTime}</p>
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3">
<Button
onClick={handleConfirm}
className="flex-1 h-11 bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white font-bold shadow-lg"
>
<CheckCircle className="w-4 h-4 mr-2" />
CONFIRM & SEND
</Button>
<Button
onClick={handleEdit}
variant="outline"
className="h-11 px-6 border-2 border-slate-300 hover:border-slate-400"
>
<Edit2 className="w-4 h-4 mr-2" />
EDIT
</Button>
</div>
</div>
) : (
<div className="space-y-2">
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder='Type or speak... (e.g., "Need 5 cooks ASAP until 5am")'
rows={3}
className="resize-none border-2 border-slate-200 focus:border-red-400 rounded-lg text-sm"
/>
{/* Action Buttons */}
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1 h-10 border-2 border-slate-300 hover:border-slate-400 text-sm"
>
<Mic className="w-4 h-4 mr-2" />
Speak
</Button>
<Button
onClick={handleSend}
disabled={!message.trim() || isProcessing}
className="flex-1 h-10 bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white font-semibold shadow-lg text-sm"
>
<Send className="w-4 h-4 mr-2" />
{isProcessing ? 'Processing...' : 'Send Message'}
</Button>
</div>
</div>
)}
{/* Tip */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-3">
<div className="flex gap-2">
<div className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">i</span>
</div>
<div className="text-xs text-blue-900">
<span className="font-semibold">Tip:</span> Include role, quantity, and urgency for fastest processing. Optionally add end time like "until 5am" or "till midnight". AI will auto-detect your location and send to your preferred vendor with priority notification.
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-orange-600">
<AlertTriangle className="w-5 h-5" />
{canOverride ? 'Double Shift Assignment' : 'Assignment Blocked'}
</DialogTitle>
<DialogDescription>
{canOverride
? `${workerName} is finishing another shift within ${gapMinutes} minutes of this assignment.`
: `${workerName} cannot be assigned due to a scheduling conflict.`
}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Alert className="border-orange-200 bg-orange-50">
<AlertTriangle className="w-4 h-4 text-orange-600" />
<AlertDescription className="text-sm text-orange-900">
{conflict.reason}
</AlertDescription>
</Alert>
<div className="bg-slate-50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900">
<Calendar className="w-4 h-4" />
Existing Assignment
</div>
<div className="space-y-1.5 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">Event:</span>
<span className="font-medium text-slate-900">{existingEvent?.event_name || 'Unnamed Event'}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Location:</span>
<span className="font-medium text-slate-900">{existingEvent?.hub || existingEvent?.event_location || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Date:</span>
<span className="font-medium text-slate-900">
{existingEvent?.date ? format(new Date(existingEvent.date), 'MMM d, yyyy') : '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-600">Time:</span>
<div className="flex items-center gap-1.5 font-medium text-slate-900">
<Clock className="w-3.5 h-3.5" />
<span>{existingStart} - {existingEnd}</span>
</div>
</div>
</div>
</div>
{canOverride && (
<Alert className="border-blue-200 bg-blue-50">
<AlertDescription className="text-sm text-blue-900">
As a vendor, you can override this restriction and assign {workerName} to a double shift.
Please ensure the worker has adequate rest and complies with labor regulations.
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
{canOverride ? (
<>
<Button variant="outline" onClick={onClose}>
Cancel Assignment
</Button>
<Button
onClick={onConfirm}
className="bg-orange-600 hover:bg-orange-700"
>
Override & Assign Double Shift
</Button>
</>
) : (
<Button onClick={onClose}>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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: '' };
};

View File

@@ -5,6 +5,11 @@ import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Calendar, Clock, MapPin, Star } from "lucide-react"; import { Calendar, Clock, MapPin, Star } from "lucide-react";
import { format } from "date-fns"; 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 * Drag & Drop Scheduler Widget
@@ -14,6 +19,21 @@ import { format } from "date-fns";
export default function DragDropScheduler({ events, staff, onAssign, onUnassign }) { export default function DragDropScheduler({ events, staff, onAssign, onUnassign }) {
const [localEvents, setLocalEvents] = useState(events || []); const [localEvents, setLocalEvents] = useState(events || []);
const [localStaff, setLocalStaff] = useState(staff || []); 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 handleDragEnd = (result) => {
const { source, destination, draggableId } = 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-")) { if (source.droppableId === "unassigned" && destination.droppableId.startsWith("event-")) {
const eventId = destination.droppableId.replace("event-", ""); const eventId = destination.droppableId.replace("event-", "");
const staffMember = localStaff.find(s => s.id === draggableId); 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) { if (staffMember && onAssign) {
onAssign(eventId, staffMember); 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 ( return (
<DragDropContext onDragEnd={handleDragEnd}> <>
<DragDropContext onDragEnd={handleDragEnd}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Unassigned Staff Pool */} {/* Unassigned Staff Pool */}
<Card className="lg:col-span-1"> <Card className="lg:col-span-1">
@@ -251,5 +330,14 @@ export default function DragDropScheduler({ events, staff, onAssign, onUnassign
</div> </div>
</div> </div>
</DragDropContext> </DragDropContext>
<DoubleBookingOverrideDialog
open={overrideDialog.open}
onClose={() => setOverrideDialog({ open: false, conflict: null, staffMember: null, eventId: null })}
conflict={overrideDialog.conflict}
workerName={overrideDialog.staffMember?.employee_name || ''}
onConfirm={handleOverrideConfirm}
/>
</>
); );
} }

View File

@@ -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"
};
}
}

View File

@@ -18,18 +18,28 @@ const progressColor = (progress) => {
return "bg-slate-400"; 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 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 ( return (
<Card <Card
ref={provided?.innerRef} ref={provided?.innerRef}
{...provided?.draggableProps} {...provided?.draggableProps}
{...provided?.dragHandleProps} {...provided?.dragHandleProps}
onClick={onClick} onClick={onClick}
className="bg-white border border-slate-200 hover:shadow-md transition-all cursor-pointer mb-3" className={`${priorityBg} border border-slate-200 ${priorityBorder} hover:shadow-md transition-all cursor-pointer mb-3`}
> >
<div className="p-4"> <div className={cardHeight}>
{/* Header */} {/* Header */}
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<h4 className="font-semibold text-slate-900 text-sm flex-1">{task.task_name}</h4> <h4 className="font-semibold text-slate-900 text-sm flex-1">{task.task_name}</h4>

View File

@@ -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"]
}
}
}

View File

@@ -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
<QueryClientProvider client={queryClient}>
<MyApplication />
</QueryClientProvider>
)
}
render(<App />, document.getElementById('root'));
```

View File

@@ -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 <div>Loading...</div>
}
if(error) {
return <div> An Error Occurred: {error} </div>
}
}
// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MyComponent from './my-component';
function App() {
const queryClient = new QueryClient();
return <QueryClientProvider client={queryClient}>
<MyComponent />
</QueryClientProvider>
}
```
## 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);
```

View File

@@ -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<ListEventsData, undefined>;
interface ListEventsRef {
...
/* Allow users to create refs without passing in DataConnect */
(): QueryRef<ListEventsData, undefined>;
}
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<ListEventsData, undefined>;
interface ListEventsRef {
...
(dc: DataConnect): QueryRef<ListEventsData, undefined>;
}
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<CreateEventData, CreateEventVariables>;
interface CreateEventRef {
...
/* Allow users to create refs without passing in DataConnect */
(vars: CreateEventVariables): MutationRef<CreateEventData, CreateEventVariables>;
}
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<CreateEventData, CreateEventVariables>;
interface CreateEventRef {
...
(dc: DataConnect, vars: CreateEventVariables): MutationRef<CreateEventData, CreateEventVariables>;
}
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);
});
```

View File

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

View File

@@ -1 +0,0 @@
{"type":"module"}

View File

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

View File

@@ -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<ListEventsData, undefined>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect): QueryRef<ListEventsData, undefined>;
operationName: string;
}
export const listEventsRef: ListEventsRef;
export function listEvents(): QueryPromise<ListEventsData, undefined>;
export function listEvents(dc: DataConnect): QueryPromise<ListEventsData, undefined>;
interface CreateEventRef {
/* Allow users to create refs without passing in DataConnect */
(vars: CreateEventVariables): MutationRef<CreateEventData, CreateEventVariables>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect, vars: CreateEventVariables): MutationRef<CreateEventData, CreateEventVariables>;
operationName: string;
}
export const createEventRef: CreateEventRef;
export function createEvent(vars: CreateEventVariables): MutationPromise<CreateEventData, CreateEventVariables>;
export function createEvent(dc: DataConnect, vars: CreateEventVariables): MutationPromise<CreateEventData, CreateEventVariables>;

View File

@@ -1,32 +0,0 @@
{
"name": "@dataconnect/generated",
"version": "1.0.0",
"author": "Firebase <firebase-support@google.com> (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"
}
}

View File

@@ -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
<QueryClientProvider client={queryClient}>
<MyApplication />
</QueryClientProvider>
)
}
```
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<ListEventsData>): UseDataConnectQueryResult<ListEventsData, undefined>;
```
You can also pass in a `DataConnect` instance to the Query hook function.
```javascript
useListEvents(options?: useDataConnectQueryOptions<ListEventsData>): UseDataConnectQueryResult<ListEventsData, undefined>;
```
### 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 <div>Loading...</div>;
}
if (query.isError) {
return <div>Error: {query.error.message}</div>;
}
// 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 <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
}
```
# 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<CreateEventData, FirebaseError, CreateEventVariables>): UseDataConnectMutationResult<CreateEventData, CreateEventVariables>;
```
You can also pass in a `DataConnect` instance to the Mutation hook function.
```javascript
useCreateEvent(dc: DataConnect, options?: useDataConnectMutationOptions<CreateEventData, FirebaseError, CreateEventVariables>): UseDataConnectMutationResult<CreateEventData, CreateEventVariables>;
```
### 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 <div>Loading...</div>;
}
if (mutation.isError) {
return <div>Error: {mutation.error.message}</div>;
}
// 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 <div>Mutation execution {mutation.isSuccess ? 'successful' : 'failed'}!</div>;
}
```

View File

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

View File

@@ -1 +0,0 @@
{"type":"module"}

View File

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

View File

@@ -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<ListEventsData>): UseDataConnectQueryResult<ListEventsData, undefined>;
export function useListEvents(dc: DataConnect, options?: useDataConnectQueryOptions<ListEventsData>): UseDataConnectQueryResult<ListEventsData, undefined>;
export function useCreateEvent(options?: useDataConnectMutationOptions<CreateEventData, FirebaseError, CreateEventVariables>): UseDataConnectMutationResult<CreateEventData, CreateEventVariables>;
export function useCreateEvent(dc: DataConnect, options?: useDataConnectMutationOptions<CreateEventData, FirebaseError, CreateEventVariables>): UseDataConnectMutationResult<CreateEventData, CreateEventVariables>;

View File

@@ -1,17 +0,0 @@
{
"name": "@dataconnect/generated-react",
"version": "1.0.0",
"author": "Firebase <firebase-support@google.com> (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"
}
}

View File

@@ -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);

View File

@@ -25,17 +25,23 @@ const COLORS = ['#0A39DF', '#6366f1', '#8b5cf6', '#a855f7', '#c026d3', '#d946ef'
const convertTo12Hour = (time24) => { const convertTo12Hour = (time24) => {
if (!time24 || time24 === "—") return time24; if (!time24 || time24 === "—") return time24;
try { try {
const parts = time24.split(':'); const parts = time24.split(':');
if (!parts || parts.length < 2) return time24; if (!parts || parts.length < 2) return time24;
const hours = parseInt(parts[0], 10); const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10); const minutes = parseInt(parts[1], 10);
if (isNaN(hours) || isNaN(minutes)) return time24; if (isNaN(hours) || isNaN(minutes)) return time24;
const period = hours >= 12 ? 'PM' : 'AM'; const period = hours >= 12 ? 'PM' : 'AM';
const hours12 = hours % 12 || 12; const hours12 = hours % 12 || 12;
const minutesStr = minutes.toString().padStart(2, '0'); const minutesStr = minutes.toString().padStart(2, '0');
return `${hours12}:${minutesStr} ${period}`; return `${hours12}:${minutesStr} ${period}`;
} catch (error) { } catch (error) {
console.error('Error converting time:', error);
return time24; return time24;
} }
}; };

View File

@@ -1,4 +1,3 @@
import React, { useState, useMemo } from "react"; import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -24,10 +23,11 @@ import {
} from "@/components/ui/tabs"; // New import } from "@/components/ui/tabs"; // New import
import { import {
Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2 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"; } from "lucide-react";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { format, parseISO, isValid } from "date-fns"; import { format, parseISO, isValid } from "date-fns";
import OrderDetailModal from "@/components/orders/OrderDetailModal";
const safeParseDate = (dateString) => { const safeParseDate = (dateString) => {
if (!dateString) return null; if (!dateString) return null;
@@ -94,6 +94,8 @@ export default function ClientOrders() {
const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open
const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order
const [viewOrderModal, setViewOrderModal] = useState(false);
const [selectedOrder, setSelectedOrder] = useState(null);
const { data: user } = useQuery({ const { data: user } = useQuery({
queryKey: ['current-user-client-orders'], queryKey: ['current-user-client-orders'],
@@ -180,6 +182,11 @@ export default function ClientOrders() {
setCancelDialogOpen(true); // Updated setCancelDialogOpen(true); // Updated
}; };
const handleViewOrder = (order) => {
setSelectedOrder(order);
setViewOrderModal(true);
};
const confirmCancel = () => { const confirmCancel = () => {
if (orderToCancel) { // Updated if (orderToCancel) { // Updated
cancelOrderMutation.mutate(orderToCancel.id); // Updated cancelOrderMutation.mutate(orderToCancel.id); // Updated
@@ -332,118 +339,115 @@ export default function ClientOrders() {
<CardContent className="p-0"> {/* CardContent padding updated */} <CardContent className="p-0"> {/* CardContent padding updated */}
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50"> {/* TableRow class updated */} <TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead className="font-semibold text-slate-700">Order</TableHead> {/* Updated */} <TableHead className="font-semibold text-slate-700 text-xs uppercase">Business</TableHead>
<TableHead className="font-semibold text-slate-700">Date</TableHead> {/* Updated */} <TableHead className="font-semibold text-slate-700 text-xs uppercase">Hub</TableHead>
<TableHead className="font-semibold text-slate-700">Location</TableHead> {/* Updated */} <TableHead className="font-semibold text-slate-700 text-xs uppercase">Event</TableHead>
<TableHead className="font-semibold text-slate-700">Time</TableHead> {/* Updated */} <TableHead className="font-semibold text-slate-700 text-xs uppercase">Date & Time</TableHead>
<TableHead className="font-semibold text-slate-700">Status</TableHead> {/* Updated */} <TableHead className="font-semibold text-slate-700 text-xs uppercase">Status</TableHead>
<TableHead className="font-semibold text-slate-700 text-center">Staff</TableHead> {/* Updated */} <TableHead className="font-semibold text-slate-700 text-xs uppercase">Requested</TableHead>
<TableHead className="font-semibold text-slate-700 text-center">Invoice</TableHead> {/* Updated */} <TableHead className="font-semibold text-slate-700 text-xs uppercase">Assigned</TableHead>
<TableHead className="font-semibold text-slate-700 text-center">Actions</TableHead> {/* Updated */} <TableHead className="font-semibold text-slate-700 text-xs uppercase">Invoice</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredOrders.length === 0 ? ( // Using filteredOrders {filteredOrders.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} className="text-center py-12 text-slate-500"> {/* Colspan updated */} <TableCell colSpan={9} className="text-center py-12 text-slate-500">
<Package className="w-12 h-12 mx-auto mb-3 text-slate-300" /> {/* Icon updated */} <Package className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No orders found</p> <p className="font-medium">No orders found</p>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredOrders.map((order) => { // Using filteredOrders, renamed event to order filteredOrders.map((order) => {
const assignment = getAssignmentStatus(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 { startTime, endTime } = getEventTimes(order);
const invoiceReady = order.status === "Completed";
// const eventDate = safeParseDate(order.date); // Not directly used here, safeFormatDate handles it.
return ( return (
<TableRow key={order.id} className="hover:bg-slate-50"> <TableRow key={order.id} className="hover:bg-slate-50">
<TableCell> {/* Order cell */} <TableCell>
<div> <div className="flex items-center gap-2">
<p className="font-semibold text-slate-900">{order.event_name}</p> <Building2 className="w-4 h-4 text-blue-600" />
<p className="text-xs text-slate-500">{order.business_name || ""}</p> <span className="text-sm font-medium text-slate-900">{order.business_name || "Primary Location"}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> {/* Date cell */} <TableCell>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-purple-600" />
<span className="text-sm text-slate-700">{order.hub || "Main Hub"}</span>
</div>
</TableCell>
<TableCell>
<p className="font-semibold text-slate-900">{order.event_name || "Untitled Event"}</p>
</TableCell>
<TableCell>
<div> <div>
<p className="font-semibold text-slate-900"> <p className="font-medium text-slate-900">{safeFormatDate(order.date, 'MM.dd.yyyy')}</p>
{safeFormatDate(order.date, 'MMM dd, yyyy')} <p className="text-xs text-slate-500 flex items-center gap-1">
</p> <Clock className="w-3 h-3" />
<p className="text-xs text-slate-500"> {startTime} - {endTime}
{safeFormatDate(order.date, 'EEEE')}
</p> </p>
</div> </div>
</TableCell> </TableCell>
<TableCell> {/* Location cell */} <TableCell>
<div className="flex items-center gap-1.5 text-sm text-slate-600">
<MapPin className="w-3.5 h-3.5 text-slate-400" />
{order.hub || order.event_location || "—"}
</div>
</TableCell>
<TableCell> {/* Time cell */}
<div className="flex items-center gap-1 text-sm text-slate-600">
<Clock className="w-3.5 h-3.5 text-slate-400" />
{startTime} - {endTime}
</div>
</TableCell>
<TableCell> {/* Status cell */}
{getStatusBadge(order)} {getStatusBadge(order)}
</TableCell> </TableCell>
<TableCell className="text-center"> {/* Staff cell */} <TableCell>
<span className="text-lg font-bold text-slate-900">{requestedCount}</span>
</TableCell>
<TableCell>
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<Badge className={assignment.badgeClass}> <div className="w-10 h-10 bg-emerald-500 rounded-full flex items-center justify-center">
{assignment.assigned} / {assignment.requested} <span className="text-white font-bold text-sm">{assignedCount}</span>
</Badge> </div>
<span className="text-[10px] text-slate-500 font-medium"> <span className="text-xs text-emerald-600 font-semibold">{assignmentProgress}%</span>
{assignment.percentage}%
</span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-center"> {/* Invoice cell */} <TableCell>
<div className="flex items-center justify-center"> <button className="w-8 h-8 flex items-center justify-center hover:bg-slate-100 rounded transition-colors">
<Button // Changed from a div to a Button for better accessibility <FileText className="w-5 h-5 text-slate-400" />
variant="ghost" </button>
size="icon"
onClick={() => invoiceReady && navigate(createPageUrl('Invoices'))}
className={`w-10 h-10 rounded-full flex items-center justify-center ${invoiceReady ? 'bg-blue-100' : 'bg-slate-100'} ${invoiceReady ? 'cursor-pointer hover:bg-blue-200' : 'cursor-not-allowed opacity-50'}`}
disabled={!invoiceReady}
title={invoiceReady ? "View Invoice" : "Invoice not available"}
>
<FileText className={`w-5 h-5 ${invoiceReady ? 'text-blue-600' : 'text-slate-400'}`} />
</Button>
</div>
</TableCell> </TableCell>
<TableCell> {/* Actions cell */} <TableCell>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-end gap-2">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate(createPageUrl(`EventDetail?id=${order.id}`))} onClick={() => handleViewOrder(order)}
className="hover:bg-slate-100" className="h-8 w-8 p-0"
title="View details" title="View"
> >
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 p-0"
title="Notifications"
>
<Bell className="w-4 h-4" />
</Button>
{canEditOrder(order) && ( {canEditOrder(order) && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${order.id}`))} onClick={() => navigate(createPageUrl(`EditEvent?id=${order.id}`))}
className="hover:bg-slate-100" className="h-8 w-8 p-0"
title="Edit order" title="Edit"
> >
<Edit className="w-4 h-4" /> {/* Changed from Edit2 */} <Edit3 className="w-4 h-4" />
</Button> </Button>
)} )}
{canCancelOrder(order) && ( {canCancelOrder(order) && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleCancelOrder(order)} // Updated onClick={() => handleCancelOrder(order)}
className="hover:bg-red-50 hover:text-red-600" className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
title="Cancel order" title="Cancel"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
@@ -460,6 +464,13 @@ export default function ClientOrders() {
</Card> </Card>
</div> </div>
<OrderDetailModal
open={viewOrderModal}
onClose={() => setViewOrderModal(false)}
order={selectedOrder}
onCancel={handleCancelOrder}
/>
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> {/* Updated open and onOpenChange */} <Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> {/* Updated open and onOpenChange */}
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -505,4 +516,4 @@ export default function ClientOrders() {
</Dialog> </Dialog>
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils"; import { createPageUrl } from "@/utils";
import EventFormWizard from "@/components/events/EventFormWizard"; import EventFormWizard from "@/components/events/EventFormWizard";
import RapidOrderInterface from "@/components/orders/RapidOrderInterface";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, AlertTriangle } from "lucide-react"; import { X, AlertTriangle } from "lucide-react";
@@ -16,6 +17,7 @@ export default function CreateEvent() {
const { toast } = useToast(); const { toast } = useToast();
const [pendingEvent, setPendingEvent] = React.useState(null); const [pendingEvent, setPendingEvent] = React.useState(null);
const [showConflictWarning, setShowConflictWarning] = React.useState(false); const [showConflictWarning, setShowConflictWarning] = React.useState(false);
const [showRapidInterface, setShowRapidInterface] = React.useState(false);
const { data: currentUser } = useQuery({ const { data: currentUser } = useQuery({
queryKey: ['current-user-create-event'], 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) => { 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 // Detect conflicts before creating
const conflicts = detectAllConflicts(eventData, allEvents); const conflicts = detectAllConflicts(eventDataWithRequested, allEvents);
if (conflicts.length > 0) { if (conflicts.length > 0) {
setPendingEvent({ ...eventData, detected_conflicts: conflicts }); setPendingEvent({ ...eventDataWithRequested, detected_conflicts: conflicts });
setShowConflictWarning(true); setShowConflictWarning(true);
} else { } else {
createEventMutation.mutate(eventData); createEventMutation.mutate(eventDataWithRequested);
} }
}; };
@@ -137,6 +180,7 @@ export default function CreateEvent() {
<EventFormWizard <EventFormWizard
event={null} event={null}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onRapidSubmit={handleRapidSubmit}
isSubmitting={createEventMutation.isPending} isSubmitting={createEventMutation.isPending}
currentUser={currentUser} currentUser={currentUser}
onCancel={() => navigate(createPageUrl("ClientDashboard"))} onCancel={() => navigate(createPageUrl("ClientDashboard"))}

View File

@@ -1,17 +1,29 @@
import React from "react"; import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils"; import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft, Loader2 } from "lucide-react"; 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() { export default function EditEvent() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { toast } = useToast();
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get('id'); 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({ const { data: allEvents, isLoading } = useQuery({
queryKey: ['events'], queryKey: ['events'],
@@ -19,7 +31,19 @@ export default function EditEvent() {
initialData: [], initialData: [],
}); });
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-reduction'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const event = allEvents.find(e => e.id === eventId); const event = allEvents.find(e => e.id === eventId);
useEffect(() => {
if (event) {
setOriginalRequested(event.requested || 0);
}
}, [event]);
const updateEventMutation = useMutation({ const updateEventMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data), mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
@@ -30,7 +54,97 @@ export default function EditEvent() {
}); });
const handleSubmit = (eventData) => { 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) { if (isLoading) {
@@ -54,7 +168,7 @@ export default function EditEvent() {
return ( return (
<div className="p-4 md:p-8"> <div className="p-4 md:p-8">
<div className="max-w-4xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="mb-8"> <div className="mb-8">
<Button <Button
variant="ghost" variant="ghost"
@@ -68,10 +182,31 @@ export default function EditEvent() {
<p className="text-slate-600">Update information for {event.event_name}</p> <p className="text-slate-600">Update information for {event.event_name}</p>
</div> </div>
<EventForm {showReductionAlert && pendingUpdate && (
<div className="mb-6">
<OrderReductionAlert
originalRequested={originalRequested}
newRequested={pendingUpdate.requested}
currentAssigned={event.assigned_staff?.length || 0}
onAutoUnassign={handleAutoUnassign}
onManualUnassign={handleManualUnassign}
lowReliabilityStaff={(event.assigned_staff || []).map(assigned => {
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)}
/>
</div>
)}
<EventFormWizard
event={event} event={event}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isSubmitting={updateEventMutation.isPending} isSubmitting={updateEventMutation.isPending}
currentUser={user}
onCancel={() => navigate(createPageUrl("Events"))}
/> />
</div> </div>
</div> </div>

View File

@@ -17,6 +17,7 @@ import {
import { ArrowLeft, Calendar, MapPin, Users, DollarSign, Send, Edit3, X, AlertTriangle } from "lucide-react"; import { ArrowLeft, Calendar, MapPin, Users, DollarSign, Send, Edit3, X, AlertTriangle } from "lucide-react";
import ShiftCard from "@/components/events/ShiftCard"; import ShiftCard from "@/components/events/ShiftCard";
import OrderStatusBadge from "@/components/orders/OrderStatusBadge"; import OrderStatusBadge from "@/components/orders/OrderStatusBadge";
import CancellationFeeModal from "@/components/orders/CancellationFeeModal";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -35,6 +36,7 @@ export default function EventDetail() {
const { toast } = useToast(); const { toast } = useToast();
const [notifyDialog, setNotifyDialog] = useState(false); const [notifyDialog, setNotifyDialog] = useState(false);
const [cancelDialog, setCancelDialog] = useState(false); const [cancelDialog, setCancelDialog] = useState(false);
const [showCancellationFeeModal, setShowCancellationFeeModal] = useState(false);
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get("id"); const eventId = urlParams.get("id");
@@ -58,11 +60,21 @@ export default function EventDetail() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] }); queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['all-events-client'] }); 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({ toast({
title: "✅ Order Canceled", title: "✅ Order Canceled",
description: "Your order has been canceled successfully", description: "Your order has been canceled successfully",
}); });
setCancelDialog(false); setShowCancellationFeeModal(false);
navigate(createPageUrl("ClientOrders")); navigate(createPageUrl("ClientOrders"));
}, },
onError: () => { onError: () => {
@@ -74,6 +86,14 @@ export default function EventDetail() {
}, },
}); });
const handleCancelClick = () => {
setShowCancellationFeeModal(true);
};
const handleConfirmCancellation = () => {
cancelOrderMutation.mutate();
};
const handleNotifyStaff = async () => { const handleNotifyStaff = async () => {
const assignedStaff = event?.assigned_staff || []; const assignedStaff = event?.assigned_staff || [];
@@ -171,11 +191,11 @@ export default function EventDetail() {
)} )}
{canCancelOrder() && ( {canCancelOrder() && (
<button <button
onClick={() => 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" className="flex items-center gap-2 px-5 py-2.5 bg-white hover:bg-red-50 border-2 border-red-200 rounded-full text-red-600 font-semibold text-base transition-all shadow-md hover:shadow-lg"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
cancel Cancel Order
</button> </button>
)} )}
{!isClient && event.assigned_staff?.length > 0 && ( {!isClient && event.assigned_staff?.length > 0 && (
@@ -269,7 +289,7 @@ export default function EventDetail() {
<h2 className="text-xl font-bold text-slate-900">Event Shifts & Staff Assignment</h2> <h2 className="text-xl font-bold text-slate-900">Event Shifts & Staff Assignment</h2>
{eventShifts.length > 0 ? ( {eventShifts.length > 0 ? (
eventShifts.map((shift, idx) => ( eventShifts.map((shift, idx) => (
<ShiftCard key={idx} shift={shift} event={event} /> <ShiftCard key={idx} shift={shift} event={event} currentUser={user} />
)) ))
) : ( ) : (
<Card className="bg-white border border-slate-200"> <Card className="bg-white border border-slate-200">
@@ -316,48 +336,14 @@ export default function EventDetail() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Cancel Order Dialog */} {/* Cancellation Fee Modal */}
<Dialog open={cancelDialog} onOpenChange={setCancelDialog}> <CancellationFeeModal
<DialogContent> open={showCancellationFeeModal}
<DialogHeader> onClose={() => setShowCancellationFeeModal(false)}
<DialogTitle className="flex items-center gap-2 text-red-600"> onConfirm={handleConfirmCancellation}
<AlertTriangle className="w-5 h-5" /> event={event}
Cancel Order? isSubmitting={cancelOrderMutation.isPending}
</DialogTitle> />
<DialogDescription>
Are you sure you want to cancel this order? This action cannot be undone and the vendor will be notified immediately.
</DialogDescription>
</DialogHeader>
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
<p className="font-bold text-slate-900">{event.event_name}</p>
<div className="flex items-center gap-2 text-sm text-slate-600">
<Calendar className="w-4 h-4" />
{safeFormatDate(event.date)}
</div>
<div className="flex items-center gap-2 text-sm text-slate-600">
<MapPin className="w-4 h-4" />
{event.hub || event.event_location}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCancelDialog(false)}
>
Keep Order
</Button>
<Button
variant="destructive"
onClick={() => cancelOrderMutation.mutate()}
disabled={cancelOrderMutation.isPending}
>
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -1,4 +1,3 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
@@ -444,18 +443,32 @@ export default function Events() {
</Card> </Card>
</div> </div>
<div className="bg-white rounded-xl p-4 mb-6 flex items-center gap-4 border shadow-sm"> <div className="bg-white rounded-xl p-4 mb-6 border-2 shadow-md">
<div className="relative flex-1"> <div className="flex flex-col md:flex-row items-stretch md:items-center gap-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" /> <div className="relative flex-1">
<Input placeholder="Search by event, business, or location..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10 border-slate-200 h-10" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
</div> <Input placeholder="Search by event, business, or location..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10 border-slate-200 h-11" />
<div className="flex items-center gap-2"> </div>
<Button variant={viewMode === "table" ? "default" : "outline"} size="sm" onClick={() => setViewMode("table")} className={viewMode === "table" ? "bg-[#0A39DF]" : ""}> <div className="flex items-center gap-2 bg-gradient-to-r from-blue-50 to-indigo-50 p-2 rounded-xl border-2 border-blue-200">
<List className="w-4 h-4" /> <Button
</Button> variant={viewMode === "table" ? "default" : "ghost"}
<Button variant={viewMode === "scheduler" ? "default" : "outline"} size="sm" onClick={() => setViewMode("scheduler")} className={viewMode === "scheduler" ? "bg-[#0A39DF]" : ""}> size="lg"
<LayoutGrid className="w-4 h-4" /> onClick={() => setViewMode("table")}
</Button> 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`}
>
<List className="w-5 h-5 mr-2" />
Table View
</Button>
<Button
variant={viewMode === "scheduler" ? "default" : "ghost"}
size="lg"
onClick={() => 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`}
>
<LayoutGrid className="w-5 h-5 mr-2" />
Scheduler View
</Button>
</div>
</div> </div>
</div> </div>
@@ -688,4 +701,4 @@ export default function Events() {
<SmartAssignModal open={assignModal.open} onClose={() => setAssignModal({ open: false, event: null, shift: null, role: null })} event={assignModal.event} shift={assignModal.shift} role={assignModal.role} /> <SmartAssignModal open={assignModal.open} onClose={() => setAssignModal({ open: false, event: null, shift: null, role: null })} event={assignModal.event} shift={assignModal.shift} role={assignModal.role} />
</div> </div>
); );
} }

View File

@@ -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 (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="w-16 h-16 border-4 border-[#0A39DF] border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600">Loading invoice...</p>
</div>
</div>
);
}
if (!invoice) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<p className="text-xl font-semibold text-slate-900 mb-4">Invoice not found</p>
<Button onClick={() => navigate(createPageUrl('Invoices'))}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Invoices
</Button>
</div>
</div>
);
}
return (
<>
<div className="fixed top-20 left-4 z-50 print:hidden">
<Button
variant="outline"
onClick={() => navigate(createPageUrl('Invoices'))}
className="bg-white shadow-lg"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</div>
<InvoiceDetailView invoice={invoice} userRole={userRole} />
</>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-slate-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="outline" onClick={() => navigate(createPageUrl('Invoices'))} className="bg-white">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Invoices
</Button>
<div>
<h1 className="text-2xl font-bold text-slate-900">{isEdit ? 'Edit Invoice' : 'Create New Invoice'}</h1>
<p className="text-sm text-slate-600">Complete all invoice details below</p>
</div>
</div>
<Badge className="bg-blue-100 text-blue-700 text-sm px-3 py-1">
{existingInvoice?.status || "Draft"}
</Badge>
</div>
<Card className="p-8 bg-white shadow-lg border-blue-100">
{/* Invoice Details Header */}
<div className="flex items-start justify-between mb-6 pb-6 border-b border-blue-100">
<div className="flex-1">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">📄</span>
</div>
<div>
<h2 className="text-xl font-bold text-slate-900">Invoice Details</h2>
<p className="text-sm text-slate-500">Event: {formData.event_name || "Internal Support"}</p>
</div>
</div>
<div className="bg-gradient-to-r from-blue-50 to-blue-100 p-4 rounded-lg mb-4">
<div className="text-xs text-blue-600 font-semibold mb-1">Invoice Number</div>
<div className="font-bold text-2xl text-blue-900">{formData.invoice_number}</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<Label className="text-xs font-semibold text-slate-700">Invoice Date</Label>
<Input
type="date"
value={formData.invoice_date}
onChange={(e) => setFormData({ ...formData, invoice_date: e.target.value })}
className="mt-1 border-blue-200 focus:border-blue-500"
/>
</div>
<div>
<Label className="text-xs font-semibold text-slate-700">Due Date</Label>
<Input
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
className="mt-1 border-blue-200 focus:border-blue-500"
/>
</div>
</div>
<div className="mb-4">
<Label className="text-xs">Hub</Label>
<Input
value={formData.hub}
onChange={(e) => setFormData({ ...formData, hub: e.target.value })}
placeholder="Hub"
className="mt-1"
/>
</div>
<div className="mb-4">
<Label className="text-xs">Manager</Label>
<Input
value={formData.manager}
onChange={(e) => setFormData({ ...formData, manager: e.target.value })}
placeholder="Manager Name"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">Vendor #</Label>
<Input
value={formData.vendor_id}
onChange={(e) => setFormData({ ...formData, vendor_id: e.target.value })}
placeholder="Vendor #"
className="mt-1"
/>
</div>
</div>
<div className="flex-1 text-right">
<div className="mb-4">
<Label className="text-xs font-semibold text-slate-700 block mb-2">Payment Terms</Label>
<div className="flex gap-2 justify-end">
<Badge
className={`cursor-pointer transition-all ${formData.payment_terms === "30" ? "bg-blue-600 text-white" : "bg-white border-2 border-slate-200 text-slate-700 hover:border-blue-300"}`}
onClick={() => setFormData({ ...formData, payment_terms: "30", due_date: format(addDays(new Date(formData.invoice_date), 30), 'yyyy-MM-dd') })}
>
30 days
</Badge>
<Badge
className={`cursor-pointer transition-all ${formData.payment_terms === "45" ? "bg-blue-600 text-white" : "bg-white border-2 border-slate-200 text-slate-700 hover:border-blue-300"}`}
onClick={() => setFormData({ ...formData, payment_terms: "45", due_date: format(addDays(new Date(formData.invoice_date), 45), 'yyyy-MM-dd') })}
>
45 days
</Badge>
<Badge
className={`cursor-pointer transition-all ${formData.payment_terms === "60" ? "bg-blue-600 text-white" : "bg-white border-2 border-slate-200 text-slate-700 hover:border-blue-300"}`}
onClick={() => setFormData({ ...formData, payment_terms: "60", due_date: format(addDays(new Date(formData.invoice_date), 60), 'yyyy-MM-dd') })}
>
60 days
</Badge>
</div>
</div>
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">Department:</span>
<Input
value={formData.department}
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
placeholder="INV-G00G20242"
className="h-8 w-48"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">PO#:</span>
<Input
value={formData.po_reference}
onChange={(e) => setFormData({ ...formData, po_reference: e.target.value })}
placeholder="INV-G00G20242"
className="h-8 w-48"
/>
</div>
</div>
</div>
</div>
{/* From and To */}
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-gradient-to-br from-blue-50 to-blue-100 p-5 rounded-xl border border-blue-200">
<h3 className="font-bold mb-4 flex items-center gap-2 text-blue-900">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white text-sm font-bold shadow-md">F</div>
From (Vendor):
</h3>
<div className="space-y-2 text-sm">
<Input
value={formData.from_company.name}
onChange={(e) => setFormData({
...formData,
from_company: { ...formData.from_company, name: e.target.value }
})}
className="font-semibold mb-2"
/>
<Input
value={formData.from_company.address}
onChange={(e) => setFormData({
...formData,
from_company: { ...formData.from_company, address: e.target.value }
})}
className="text-sm"
/>
<Input
value={formData.from_company.phone}
onChange={(e) => setFormData({
...formData,
from_company: { ...formData.from_company, phone: e.target.value }
})}
className="text-sm"
/>
<Input
value={formData.from_company.email}
onChange={(e) => setFormData({
...formData,
from_company: { ...formData.from_company, email: e.target.value }
})}
className="text-sm"
/>
</div>
</div>
<div className="bg-gradient-to-br from-slate-50 to-slate-100 p-5 rounded-xl border border-slate-200">
<h3 className="font-bold mb-4 flex items-center gap-2 text-slate-900">
<div className="w-8 h-8 bg-slate-600 rounded-lg flex items-center justify-center text-white text-sm font-bold shadow-md">T</div>
To (Client):
</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Company:</span>
<Input
value={formData.to_company.name}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, name: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Phone:</span>
<Input
value={formData.to_company.phone}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, phone: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Manager Name:</span>
<Input
value={formData.to_company.manager_name}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, manager_name: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Email:</span>
<Input
value={formData.to_company.email}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, email: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Hub Name:</span>
<Input
value={formData.to_company.hub_name}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, hub_name: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Address:</span>
<Input
value={formData.to_company.address}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, address: e.target.value }
})}
className="flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500 w-32">Vendor #:</span>
<Input
value={formData.to_company.vendor_id}
onChange={(e) => setFormData({
...formData,
to_company: { ...formData.to_company, vendor_id: e.target.value }
})}
className="flex-1"
/>
</div>
</div>
</div>
</div>
{/* Staff Table */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4 p-4 bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">👥</span>
</div>
<div>
<h3 className="font-bold text-blue-900">Staff Entries</h3>
<p className="text-xs text-blue-700">{formData.staff_entries.length} entries</p>
</div>
</div>
<Button size="sm" onClick={handleAddStaffEntry} className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
<Plus className="w-4 h-4 mr-1" />
Add Staff Entry
</Button>
</div>
<div className="overflow-x-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="p-2 text-left">#</th>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">ClockIn</th>
<th className="p-2 text-left">Lunch</th>
<th className="p-2 text-left">Checkout</th>
<th className="p-2 text-left">Worked H</th>
<th className="p-2 text-left">Reg H</th>
<th className="p-2 text-left">OT Hours</th>
<th className="p-2 text-left">DT Hours</th>
<th className="p-2 text-left">Rate</th>
<th className="p-2 text-left">Reg Value</th>
<th className="p-2 text-left">OT Value</th>
<th className="p-2 text-left">DT Value</th>
<th className="p-2 text-left">Total</th>
<th className="p-2">Action</th>
</tr>
</thead>
<tbody>
{formData.staff_entries.map((entry, idx) => (
<tr key={idx} className="border-t hover:bg-slate-50">
<td className="p-2">{idx + 1}</td>
<td className="p-2">
<Input
value={entry.name}
onChange={(e) => handleStaffChange(idx, 'name', e.target.value)}
className="h-8 w-24"
/>
</td>
<td className="p-2">
<Popover open={timePickerOpen === `checkin-${idx}`} onOpenChange={(open) => setTimePickerOpen(open ? `checkin-${idx}` : null)}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 w-24 justify-start font-normal">
<Clock className="w-3 h-3 mr-1" />
{entry.check_in}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3">
<div className="space-y-2">
<div className="flex gap-2">
<Input
type="number"
min="01"
max="12"
value={selectedTime.hours}
onChange={(e) => setSelectedTime({ ...selectedTime, hours: e.target.value.padStart(2, '0') })}
className="w-16"
placeholder="HH"
/>
<span className="text-2xl">:</span>
<Input
type="number"
min="00"
max="59"
value={selectedTime.minutes}
onChange={(e) => setSelectedTime({ ...selectedTime, minutes: e.target.value.padStart(2, '0') })}
className="w-16"
placeholder="MM"
/>
<Select value={selectedTime.period} onValueChange={(val) => setSelectedTime({ ...selectedTime, period: val })}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AM">AM</SelectItem>
<SelectItem value="PM">PM</SelectItem>
</SelectContent>
</Select>
</div>
<Button size="sm" onClick={() => handleTimeSelect(idx, 'check_in')} className="w-full">
Set Time
</Button>
</div>
</PopoverContent>
</Popover>
</td>
<td className="p-2">
<Input
type="number"
value={entry.lunch}
onChange={(e) => handleStaffChange(idx, 'lunch', parseFloat(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="p-2">
<Popover open={timePickerOpen === `checkout-${idx}`} onOpenChange={(open) => setTimePickerOpen(open ? `checkout-${idx}` : null)}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 w-24 justify-start font-normal">
<Clock className="w-3 h-3 mr-1" />
{entry.check_out || "hh:mm"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3">
<div className="space-y-2">
<div className="flex gap-2">
<Input
type="number"
min="01"
max="12"
value={selectedTime.hours}
onChange={(e) => setSelectedTime({ ...selectedTime, hours: e.target.value.padStart(2, '0') })}
className="w-16"
placeholder="HH"
/>
<span className="text-2xl">:</span>
<Input
type="number"
min="00"
max="59"
value={selectedTime.minutes}
onChange={(e) => setSelectedTime({ ...selectedTime, minutes: e.target.value.padStart(2, '0') })}
className="w-16"
placeholder="MM"
/>
<Select value={selectedTime.period} onValueChange={(val) => setSelectedTime({ ...selectedTime, period: val })}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AM">AM</SelectItem>
<SelectItem value="PM">PM</SelectItem>
</SelectContent>
</Select>
</div>
<Button size="sm" onClick={() => handleTimeSelect(idx, 'check_out')} className="w-full">
Set Time
</Button>
</div>
</PopoverContent>
</Popover>
</td>
<td className="p-2">
<Input
type="number"
step="0.1"
value={entry.worked_hours}
onChange={(e) => handleStaffChange(idx, 'worked_hours', parseFloat(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.1"
value={entry.regular_hours}
onChange={(e) => handleStaffChange(idx, 'regular_hours', parseFloat(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.1"
value={entry.ot_hours}
onChange={(e) => handleStaffChange(idx, 'ot_hours', parseFloat(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.1"
value={entry.dt_hours}
onChange={(e) => handleStaffChange(idx, 'dt_hours', parseFloat(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.01"
value={entry.rate}
onChange={(e) => handleStaffChange(idx, 'rate', parseFloat(e.target.value))}
className="h-8 w-20"
/>
</td>
<td className="p-2 text-right">${entry.regular_value?.toFixed(2) || "0.00"}</td>
<td className="p-2 text-right">${entry.ot_value?.toFixed(2) || "0.00"}</td>
<td className="p-2 text-right">${entry.dt_value?.toFixed(2) || "0.00"}</td>
<td className="p-2 text-right font-semibold">${entry.total?.toFixed(2) || "0.00"}</td>
<td className="p-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveStaff(idx)}
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Charges */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4 p-4 bg-gradient-to-r from-green-50 to-emerald-100 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-emerald-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">💰</span>
</div>
<div>
<h3 className="font-bold text-emerald-900">Additional Charges</h3>
<p className="text-xs text-emerald-700">{formData.charges.length} charges</p>
</div>
</div>
<Button size="sm" onClick={handleAddCharge} className="bg-emerald-600 hover:bg-emerald-700 text-white shadow-md">
<Plus className="w-4 h-4 mr-1" />
Add Charge
</Button>
</div>
<div className="overflow-x-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="p-2 text-left">#</th>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">QTY</th>
<th className="p-2 text-left">Rate</th>
<th className="p-2 text-left">Price</th>
<th className="p-2">Actions</th>
</tr>
</thead>
<tbody>
{formData.charges.map((charge, idx) => (
<tr key={idx} className="border-t hover:bg-slate-50">
<td className="p-2">{idx + 1}</td>
<td className="p-2">
<Input
value={charge.name}
onChange={(e) => handleChargeChange(idx, 'name', e.target.value)}
className="h-8"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.01"
value={charge.qty}
onChange={(e) => handleChargeChange(idx, 'qty', parseFloat(e.target.value))}
className="h-8 w-20"
/>
</td>
<td className="p-2">
<Input
type="number"
step="0.01"
value={charge.rate}
onChange={(e) => handleChargeChange(idx, 'rate', parseFloat(e.target.value))}
className="h-8 w-20"
/>
</td>
<td className="p-2">${charge.price?.toFixed(2) || "0.00"}</td>
<td className="p-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveCharge(idx)}
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Totals */}
<div className="flex justify-end mb-6">
<div className="w-96 bg-gradient-to-br from-blue-50 to-blue-100 p-6 rounded-xl border-2 border-blue-200 shadow-lg">
<div className="space-y-4">
<div className="flex justify-between text-sm">
<span className="text-slate-600">Sub total:</span>
<span className="font-semibold text-slate-900">${totals.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-600">Other charges:</span>
<Input
type="number"
step="0.01"
value={formData.other_charges}
onChange={(e) => setFormData({ ...formData, other_charges: e.target.value })}
className="h-9 w-32 text-right border-blue-300 focus:border-blue-500 bg-white"
/>
</div>
<div className="flex justify-between text-xl font-bold pt-4 border-t-2 border-blue-300">
<span className="text-blue-900">Grand total:</span>
<span className="text-blue-900">${totals.grandTotal.toFixed(2)}</span>
</div>
</div>
</div>
</div>
{/* Notes */}
<div className="mb-6">
<Label className="mb-2 block">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Enter your notes here..."
rows={3}
/>
</div>
{/* Actions */}
<div className="flex justify-between items-center pt-6 border-t-2 border-blue-100">
<Button variant="outline" onClick={() => navigate(createPageUrl('Invoices'))} className="border-slate-300">
Cancel
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => saveMutation.mutate({ ...formData, status: "Draft" })}
disabled={saveMutation.isPending}
className="border-blue-300 text-blue-700 hover:bg-blue-50"
>
Save as Draft
</Button>
<Button
onClick={() => 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"}
</Button>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -1,48 +1,38 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; 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 { format, parseISO, isPast } from "date-fns";
import PageHeader from "@/components/common/PageHeader"; import PageHeader from "@/components/common/PageHeader";
import { import { useNavigate } from "react-router-dom";
Dialog, import { createPageUrl } from "@/utils";
DialogContent, import AutoInvoiceGenerator from "@/components/invoices/AutoInvoiceGenerator";
DialogHeader, import CreateInvoiceModal from "@/components/invoices/CreateInvoiceModal";
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const statusColors = { const statusColors = {
'Open': 'bg-orange-500 text-white', 'Draft': 'bg-slate-500 text-white',
'Confirmed': 'bg-purple-500 text-white', 'Pending Review': 'bg-amber-500 text-white',
'Overdue': 'bg-red-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', 'Resolved': 'bg-blue-500 text-white',
'Paid': 'bg-green-500 text-white', 'Overdue': 'bg-red-600 text-white',
'Reconciled': 'bg-amber-600 text-white', // Changed from bg-yellow-600 'Paid': 'bg-emerald-500 text-white',
'Disputed': 'bg-gray-500 text-white', 'Reconciled': 'bg-purple-500 text-white',
'Verified': 'bg-teal-500 text-white', 'Cancelled': 'bg-slate-400 text-white',
'Pending': 'bg-amber-500 text-white',
}; };
export default function Invoices() { export default function Invoices() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("all"); const [activeTab, setActiveTab] = useState("all");
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [selectedInvoice, setSelectedInvoice] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false);
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [paymentMethod, setPaymentMethod] = useState("");
const queryClient = useQueryClient();
const { data: user } = useQuery({ const { data: user } = useQuery({
queryKey: ['current-user-invoices'], queryKey: ['current-user-invoices'],
@@ -57,49 +47,52 @@ export default function Invoices() {
const userRole = user?.user_role || user?.role; 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 // Filter invoices based on user role
const visibleInvoices = React.useMemo(() => { const visibleInvoices = React.useMemo(() => {
if (userRole === "client") { if (userRole === "client") {
return invoices.filter(inv => return invoices.filter(inv =>
inv.business_name === user?.company_name || inv.business_name === user?.company_name ||
inv.manager_name === user?.full_name || inv.manager_name === user?.full_name ||
inv.created_by === user?.email inv.created_by === user?.email
); );
} }
if (userRole === "vendor") { 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; return invoices;
}, [invoices, userRole, user]); }, [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 = () => { const getFilteredInvoices = () => {
let filtered = visibleInvoices; let filtered = visibleInvoices;
// Status filter
if (activeTab !== "all") { if (activeTab !== "all") {
const statusMap = { const statusMap = {
open: "Open", 'pending': 'Pending Review',
disputed: "Disputed", 'approved': 'Approved',
resolved: "Resolved", 'disputed': 'Disputed',
verified: "Verified", 'overdue': 'Overdue',
overdue: "Overdue", 'paid': 'Paid',
reconciled: "Reconciled", 'reconciled': 'Reconciled',
paid: "Paid"
}; };
filtered = filtered.filter(inv => inv.status === statusMap[activeTab]); filtered = filtered.filter(inv => inv.status === statusMap[activeTab]);
} }
// Search filter
if (searchTerm) { if (searchTerm) {
filtered = filtered.filter(inv => filtered = filtered.filter(inv =>
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) || inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -114,317 +107,245 @@ export default function Invoices() {
const filteredInvoices = getFilteredInvoices(); const filteredInvoices = getFilteredInvoices();
// Calculate metrics
const getStatusCount = (status) => { const getStatusCount = (status) => {
if (status === "all") return visibleInvoices.length; if (status === "all") return visibleInvoices.length;
return visibleInvoices.filter(inv => inv.status === status).length; return visibleInvoices.filter(inv => inv.status === status).length;
}; };
const getTotalAmount = (status) => { const getTotalAmount = (status) => {
const filtered = status === "all" const filtered = status === "all"
? visibleInvoices ? visibleInvoices
: visibleInvoices.filter(inv => inv.status === status); : visibleInvoices.filter(inv => inv.status === status);
return filtered.reduce((sum, inv) => sum + (inv.amount || 0), 0); return filtered.reduce((sum, inv) => sum + (inv.amount || 0), 0);
}; };
const allTotal = getTotalAmount("all"); const metrics = {
const openTotal = getTotalAmount("Open"); all: getTotalAmount("all"),
const overdueTotal = getTotalAmount("Overdue"); pending: getTotalAmount("Pending Review"),
const paidTotal = getTotalAmount("Paid"); approved: getTotalAmount("Approved"),
disputed: getTotalAmount("Disputed"),
const openPercentage = allTotal > 0 ? ((openTotal / allTotal) * 100).toFixed(1) : 0; overdue: getTotalAmount("Overdue"),
const overduePercentage = allTotal > 0 ? ((overdueTotal / allTotal) * 100).toFixed(1) : 0; paid: getTotalAmount("Paid"),
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
}
});
}
}; };
return ( return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen"> <>
<div className="max-w-[1600px] mx-auto"> <AutoInvoiceGenerator />
<PageHeader
title="Invoices" <div className="p-4 md:p-8 bg-slate-50 min-h-screen">
subtitle={`${filteredInvoices.length} ${filteredInvoices.length === 1 ? 'invoice' : 'invoices'}$${allTotal.toLocaleString()} total`} <div className="max-w-[1600px] mx-auto">
actions={ <PageHeader
<> title="Invoices"
<Button subtitle={`${filteredInvoices.length} invoices • $${metrics.all.toLocaleString()} total`}
onClick={() => setShowPaymentDialog(true)} actions={
variant="outline" userRole === "vendor" && (
className="bg-amber-500 hover:bg-amber-600 text-white border-0 font-semibold" // Changed className <Button onClick={() => setShowCreateModal(true)} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
> <Plus className="w-5 h-5 mr-2" />
Record Payment Create Invoice
</Button> </Button>
<Button )
onClick={() => setShowCreateDialog(true)} }
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white shadow-lg" />
>
<Plus className="w-5 h-5 mr-2" />
Create Invoice
</Button>
</>
}
/>
{/* Status Tabs */} {/* Alert Banners */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6"> {metrics.disputed > 0 && (
<TabsList className="bg-white border border-slate-200 h-auto p-1"> <div className="mb-6 p-4 bg-red-50 border-l-4 border-red-500 rounded-lg flex items-center gap-3">
<TabsTrigger value="all" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white"> <AlertTriangle className="w-5 h-5 text-red-600" />
All Invoices <Badge variant="secondary" className="ml-2">{getStatusCount("all")}</Badge> <div>
</TabsTrigger> <p className="font-semibold text-red-900">Disputed Invoices Require Attention</p>
<TabsTrigger value="open"> <p className="text-sm text-red-700">{getStatusCount("Disputed")} invoices are currently disputed</p>
Open <Badge variant="secondary" className="ml-2">{getStatusCount("Open")}</Badge> </div>
</TabsTrigger> </div>
<TabsTrigger value="disputed"> )}
Disputed <Badge variant="secondary" className="ml-2">{getStatusCount("Disputed")}</Badge>
</TabsTrigger>
<TabsTrigger value="resolved">
Resolved <Badge variant="secondary" className="ml-2">{getStatusCount("Resolved")}</Badge>
</TabsTrigger>
<TabsTrigger value="verified">
Verified <Badge variant="secondary" className="ml-2">{getStatusCount("Verified")}</Badge>
</TabsTrigger>
<TabsTrigger value="overdue">
Overdue <Badge variant="secondary" className="ml-2">{getStatusCount("Overdue")}</Badge>
</TabsTrigger>
<TabsTrigger value="reconciled">
Reconciled <Badge variant="secondary" className="ml-2">{getStatusCount("Reconciled")}</Badge>
</TabsTrigger>
<TabsTrigger value="paid">
Paid <Badge variant="secondary" className="ml-2">{getStatusCount("Paid")}</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
{/* Summary Cards */} {metrics.overdue > 0 && userRole === "client" && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"> <div className="mb-6 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-lg flex items-center gap-3">
<Card className="bg-white border-slate-200"> <Clock className="w-5 h-5 text-amber-600" />
<CardContent className="p-6"> <div>
<div className="flex items-center justify-between mb-4"> <p className="font-semibold text-amber-900">Overdue Payments</p>
<div> <p className="text-sm text-amber-700">${metrics.overdue.toLocaleString()} in overdue invoices</p>
<p className="text-sm text-slate-500 mb-1">All</p> </div>
<p className="text-3xl font-bold text-[#1C323E]">${allTotal.toLocaleString()}</p> </div>
)}
{/* Status Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-white border border-slate-200 h-auto p-1 flex-wrap">
<TabsTrigger value="all">
All <Badge variant="secondary" className="ml-2">{getStatusCount("all")}</Badge>
</TabsTrigger>
<TabsTrigger value="pending">
Pending Review <Badge variant="secondary" className="ml-2">{getStatusCount("Pending Review")}</Badge>
</TabsTrigger>
<TabsTrigger value="approved">
Approved <Badge variant="secondary" className="ml-2">{getStatusCount("Approved")}</Badge>
</TabsTrigger>
<TabsTrigger value="disputed">
Disputed <Badge variant="secondary" className="ml-2">{getStatusCount("Disputed")}</Badge>
</TabsTrigger>
<TabsTrigger value="overdue">
Overdue <Badge variant="secondary" className="ml-2">{getStatusCount("Overdue")}</Badge>
</TabsTrigger>
<TabsTrigger value="paid">
Paid <Badge variant="secondary" className="ml-2">{getStatusCount("Paid")}</Badge>
</TabsTrigger>
<TabsTrigger value="reconciled">
Reconciled <Badge variant="secondary" className="ml-2">{getStatusCount("Reconciled")}</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
{/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border-slate-200">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<FileText className="w-6 h-6 text-blue-600" />
</div>
<div>
<p className="text-sm text-slate-500">Total</p>
<p className="text-2xl font-bold text-slate-900">${metrics.all.toLocaleString()}</p>
</div>
</div> </div>
<Badge className="bg-[#1C323E] text-white">{getStatusCount("all")} invoices</Badge> </CardContent>
</div> </Card>
<div className="w-full bg-slate-200 rounded-full h-2">
<div className="bg-[#0A39DF] h-2 rounded-full" style={{ width: '100%' }}></div>
</div>
<p className="text-right text-sm font-semibold text-[#1C323E] mt-2">100%</p>
</CardContent>
</Card>
<Card className="bg-white border-slate-200"> <Card className="border-slate-200">
<CardContent className="p-6"> <CardContent className="p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center gap-3">
<div> <div className="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center">
<p className="text-sm text-slate-500 mb-1">Open</p> <Clock className="w-6 h-6 text-amber-600" />
<p className="text-3xl font-bold text-[#1C323E]">${openTotal.toLocaleString()}</p> </div>
<div>
<p className="text-sm text-slate-500">Pending</p>
<p className="text-2xl font-bold text-amber-600">${metrics.pending.toLocaleString()}</p>
</div>
</div> </div>
<Badge className="bg-orange-500 text-white">{getStatusCount("Open")} invoices</Badge> </CardContent>
</div> </Card>
<div className="w-full bg-slate-200 rounded-full h-2">
<div className="bg-orange-500 h-2 rounded-full" style={{ width: `${openPercentage}%` }}></div>
</div>
<p className="text-right text-sm font-semibold text-orange-600 mt-2">{openPercentage}%</p>
</CardContent>
</Card>
<Card className="bg-white border-slate-200"> <Card className="border-slate-200">
<CardContent className="p-6"> <CardContent className="p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center gap-3">
<div> <div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<p className="text-sm text-slate-500 mb-1">Overdue</p> <AlertTriangle className="w-6 h-6 text-red-600" />
<p className="text-3xl font-bold text-[#1C323E]">${overdueTotal.toLocaleString()}</p> </div>
<div>
<p className="text-sm text-slate-500">Overdue</p>
<p className="text-2xl font-bold text-red-600">${metrics.overdue.toLocaleString()}</p>
</div>
</div> </div>
<Badge className="bg-red-500 text-white">{getStatusCount("Overdue")} invoices</Badge> </CardContent>
</div> </Card>
<div className="w-full bg-slate-200 rounded-full h-2">
<div className="bg-red-500 h-2 rounded-full" style={{ width: `${overduePercentage}%` }}></div>
</div>
<p className="text-right text-sm font-semibold text-red-600 mt-2">{overduePercentage}%</p>
</CardContent>
</Card>
<Card className="bg-white border-slate-200"> <Card className="border-slate-200">
<CardContent className="p-6"> <CardContent className="p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center gap-3">
<div> <div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<p className="text-sm text-slate-500 mb-1">Paid</p> <CheckCircle className="w-6 h-6 text-green-600" />
<p className="text-3xl font-bold text-[#1C323E]">${paidTotal.toLocaleString()}</p> </div>
<div>
<p className="text-sm text-slate-500">Paid</p>
<p className="text-2xl font-bold text-green-600">${metrics.paid.toLocaleString()}</p>
</div>
</div> </div>
<Badge className="bg-green-500 text-white">{getStatusCount("Paid")} invoices</Badge> </CardContent>
</div> </Card>
<div className="w-full bg-slate-200 rounded-full h-2">
<div className="bg-green-500 h-2 rounded-full" style={{ width: `${paidPercentage}%` }}></div>
</div>
<p className="text-right text-sm font-semibold text-green-600 mt-2">{paidPercentage}%</p>
</CardContent>
</Card>
</div>
{/* Search */}
<div className="bg-white rounded-xl p-4 mb-6 flex items-center gap-4 border border-slate-200">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search invoices..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border-slate-300"
/>
</div> </div>
</div>
{/* Invoices Table */} {/* Search */}
<Card className="border-slate-200"> <div className="bg-white rounded-lg p-4 mb-6 border border-slate-200">
<CardContent className="p-0"> <div className="relative">
<Table> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<TableHeader> <Input
<TableRow className="bg-slate-50 hover:bg-slate-50"> placeholder="Search by invoice number, client, event..."
<TableHead className="font-semibold text-slate-700">S #</TableHead> value={searchTerm}
<TableHead className="font-semibold text-slate-700">Manager Name</TableHead> onChange={(e) => setSearchTerm(e.target.value)}
<TableHead className="font-semibold text-slate-700">Hub</TableHead> className="pl-10"
<TableHead className="font-semibold text-slate-700">Invoice ID</TableHead> />
<TableHead className="font-semibold text-slate-700">Cost Center</TableHead> </div>
<TableHead className="font-semibold text-slate-700">Event</TableHead> </div>
<TableHead className="font-semibold text-slate-700">Value $</TableHead>
<TableHead className="font-semibold text-slate-700">Count</TableHead> {/* Invoices Table */}
<TableHead className="font-semibold text-slate-700">Payment Status</TableHead> <Card className="border-slate-200">
<TableHead className="font-semibold text-slate-700">Actions</TableHead> <CardContent className="p-0">
</TableRow> <Table>
</TableHeader> <TableHeader>
<TableBody> <TableRow className="bg-slate-50 hover:bg-slate-50">
{filteredInvoices.length === 0 ? ( <TableHead>Invoice #</TableHead>
<TableRow> <TableHead>Client</TableHead>
<TableCell colSpan={10} className="text-center py-12 text-slate-500"> <TableHead>Event</TableHead>
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" /> <TableHead>Vendor</TableHead>
<p className="font-medium">No invoices found</p> <TableHead>Issue Date</TableHead>
</TableCell> <TableHead>Due Date</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow> </TableRow>
) : ( </TableHeader>
filteredInvoices.map((invoice, idx) => ( <TableBody>
<TableRow key={invoice.id} className="hover:bg-slate-50"> {filteredInvoices.length === 0 ? (
<TableCell>{idx + 1}</TableCell> <TableRow>
<TableCell className="font-medium">{invoice.manager_name || invoice.business_name}</TableCell> <TableCell colSpan={9} className="text-center py-12 text-slate-500">
<TableCell>{invoice.hub || "Hub Name"}</TableCell> <FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<TableCell> <p className="font-medium">No invoices found</p>
<div>
<p className="font-semibold text-sm">{invoice.invoice_number}</p>
<p className="text-xs text-slate-500">{format(parseISO(invoice.issue_date), 'M.d.yyyy')}</p>
</div>
</TableCell>
<TableCell>{invoice.cost_center || "Cost Center"}</TableCell>
<TableCell>{invoice.event_name || "Events Name"}</TableCell>
<TableCell className="font-semibold">${invoice.amount?.toLocaleString()}</TableCell>
<TableCell>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
{invoice.item_count || 2}
</Badge>
</TableCell>
<TableCell>
<Badge className={`${statusColors[invoice.status]} font-medium px-3 py-1`}>
{invoice.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="hover:text-[#0A39DF]">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="hover:text-[#0A39DF]">
<Download className="w-4 h-4" />
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ) : (
)} filteredInvoices.map((invoice) => (
</TableBody> <TableRow key={invoice.id} className="hover:bg-slate-50">
</Table> <TableCell className="font-semibold">{invoice.invoice_number}</TableCell>
</CardContent> <TableCell>{invoice.business_name}</TableCell>
</Card> <TableCell>{invoice.event_name}</TableCell>
<TableCell>{invoice.vendor_name || "—"}</TableCell>
{/* Record Payment Dialog */} <TableCell>{format(parseISO(invoice.issue_date), 'MMM dd, yyyy')}</TableCell>
<Dialog open={showPaymentDialog} onOpenChange={setShowPaymentDialog}> <TableCell className={isPast(parseISO(invoice.due_date)) && invoice.status !== "Paid" ? "text-red-600 font-semibold" : ""}>
<DialogContent> {format(parseISO(invoice.due_date), 'MMM dd, yyyy')}
<DialogHeader> </TableCell>
<DialogTitle>Record Payment</DialogTitle> <TableCell className="text-right font-bold">${invoice.amount?.toLocaleString()}</TableCell>
</DialogHeader> <TableCell>
<div className="space-y-4 py-4"> <Badge className={statusColors[invoice.status]}>
<div> {invoice.status}
<Label>Select Invoice</Label> </Badge>
<Select onValueChange={(value) => setSelectedInvoice(filteredInvoices.find(i => i.id === value))}> </TableCell>
<SelectTrigger> <TableCell>
<SelectValue placeholder="Choose an invoice" /> <div className="flex gap-2">
</SelectTrigger> <Button
<SelectContent> variant="ghost"
{filteredInvoices.filter(i => i.status !== "Paid").map((invoice) => ( size="sm"
<SelectItem key={invoice.id} value={invoice.id}> onClick={() => navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))}
{invoice.invoice_number} - ${invoice.amount} ({invoice.status}) className="font-semibold"
</SelectItem> >
))} <Eye className="w-4 h-4 mr-2" />
</SelectContent> View
</Select> </Button>
</div> {userRole === "vendor" && invoice.status === "Draft" && (
<div> <Button
<Label>Payment Method</Label> variant="ghost"
<Select value={paymentMethod} onValueChange={setPaymentMethod}> size="sm"
<SelectTrigger> onClick={() => navigate(createPageUrl(`InvoiceEditor?id=${invoice.id}`))}
<SelectValue placeholder="Select payment method" /> className="font-semibold text-blue-600"
</SelectTrigger> >
<SelectContent> Edit
<SelectItem value="Credit Card">Credit Card</SelectItem> </Button>
<SelectItem value="ACH">ACH Transfer</SelectItem> )}
<SelectItem value="Wire Transfer">Wire Transfer</SelectItem> </div>
<SelectItem value="Check">Check</SelectItem> </TableCell>
<SelectItem value="Cash">Cash</SelectItem> </TableRow>
</SelectContent> ))
</Select> )}
</div> </TableBody>
</div> </Table>
<DialogFooter> </CardContent>
<Button variant="outline" onClick={() => setShowPaymentDialog(false)}> </Card>
Cancel </div>
</Button>
<Button
onClick={handleRecordPayment}
disabled={!selectedInvoice || !paymentMethod}
className="bg-[#0A39DF]"
>
Record Payment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create Invoice Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Invoice</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-slate-600">Invoice creation form coming soon...</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</div>
<CreateInvoiceModal
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
/>
</>
); );
} }

View File

@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils"; import { createPageUrl } from "@/utils";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -9,7 +9,7 @@ import {
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare, DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff, Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical, 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"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -37,7 +37,7 @@ import { Toaster } from "@/components/ui/toaster";
// Navigation items for each role // Navigation items for each role
const roleNavigationMap = { const roleNavigationMap = {
admin: [ admin: [
{ title: "Dashboard", url: createPageUrl("Dashboard"), icon: LayoutDashboard }, { title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar }, { title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 }, { title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin }, { title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
@@ -58,7 +58,7 @@ const roleNavigationMap = {
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity }, { title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
], ],
procurement: [ procurement: [
{ title: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard }, { title: "Home", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar }, { title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 }, { title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin }, { title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
@@ -73,7 +73,7 @@ const roleNavigationMap = {
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 }, { title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
], ],
operator: [ operator: [
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard }, { title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar }, { title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin }, { title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase }, { title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
@@ -87,7 +87,7 @@ const roleNavigationMap = {
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 }, { title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
], ],
sector: [ sector: [
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard }, { title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar }, { title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase }, { title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package }, { title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
@@ -100,7 +100,7 @@ const roleNavigationMap = {
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 }, { title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
], ],
client: [ client: [
{ title: "Dashboard", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard }, { title: "Home", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard }, { title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus }, { title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store }, { title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
@@ -113,7 +113,7 @@ const roleNavigationMap = {
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle }, { title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
], ],
vendor: [ vendor: [
{ title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard }, { title: "Home", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText }, { title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign }, { title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard }, { title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
@@ -131,7 +131,7 @@ const roleNavigationMap = {
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp }, { title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
], ],
workforce: [ workforce: [
{ title: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard }, { title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap }, { title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar }, { title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck }, { title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
@@ -241,6 +241,7 @@ function NavigationMenu({ location, userRole, closeSheet }) {
export default function Layout({ children }) { export default function Layout({ children }) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const [showNotifications, setShowNotifications] = React.useState(false); const [showNotifications, setShowNotifications] = React.useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false); const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
@@ -323,6 +324,16 @@ export default function Layout({ children }) {
<header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-30"> <header className="bg-white border-b border-slate-200 shadow-sm sticky top-0 z-30">
<div className="px-4 md:px-6 py-3 flex items-center justify-between gap-4"> <div className="px-4 md:px-6 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="hover:bg-slate-100"
title="Go back"
>
<ArrowLeft className="w-5 h-5 text-slate-600" />
</Button>
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}> <Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden hover:bg-slate-100"> <Button variant="ghost" size="icon" className="lg:hidden hover:bg-slate-100">

View File

@@ -36,7 +36,7 @@ export default function Onboarding() {
queryFn: async () => { queryFn: async () => {
const allInvites = await base44.entities.TeamMemberInvite.list(); const allInvites = await base44.entities.TeamMemberInvite.list();
const foundInvite = allInvites.find(inv => inv.invite_code === inviteCode && inv.invite_status === 'pending'); const foundInvite = allInvites.find(inv => inv.invite_code === inviteCode && inv.invite_status === 'pending');
if (foundInvite) { if (foundInvite) {
// Pre-fill form with invite data // Pre-fill form with invite data
const nameParts = (foundInvite.full_name || "").split(' '); const nameParts = (foundInvite.full_name || "").split(' ');
@@ -44,10 +44,14 @@ export default function Onboarding() {
...prev, ...prev,
email: foundInvite.email, email: foundInvite.email,
first_name: nameParts[0] || "", 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; return foundInvite;
}, },
enabled: !!inviteCode, enabled: !!inviteCode,
@@ -65,6 +69,40 @@ export default function Onboarding() {
initialData: [], 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({ const registerMutation = useMutation({
mutationFn: async (data) => { mutationFn: async (data) => {
if (!invite) { if (!invite) {
@@ -233,8 +271,14 @@ export default function Onboarding() {
<h1 className="text-4xl font-bold bg-gradient-to-r from-[#1C323E] to-[#0A39DF] bg-clip-text text-transparent mb-2"> <h1 className="text-4xl font-bold bg-gradient-to-r from-[#1C323E] to-[#0A39DF] bg-clip-text text-transparent mb-2">
Join {invite.team_name} Join {invite.team_name}
</h1> </h1>
{invite.hub && (
<div className="inline-block bg-gradient-to-r from-blue-500 to-indigo-600 text-white px-6 py-2 rounded-full font-bold mb-3 shadow-lg">
📍 {invite.hub}
</div>
)}
<p className="text-slate-600"> <p className="text-slate-600">
You've been invited by {invite.invited_by} as a <strong>{invite.role}</strong> You've been invited by {invite.invited_by} as a <strong>{invite.role}</strong>
{invite.department && <span> in <strong>{invite.department}</strong></span>}
</p> </p>
</div> </div>
@@ -313,6 +357,7 @@ export default function Onboarding() {
placeholder="+1 (555) 123-4567" placeholder="+1 (555) 123-4567"
className="mt-2" className="mt-2"
/> />
<p className="text-xs text-slate-500 mt-1">You can edit this if needed</p>
</div> </div>
<Button <Button
@@ -353,23 +398,25 @@ export default function Onboarding() {
<SelectValue placeholder="Select department" /> <SelectValue placeholder="Select department" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Operations">Operations</SelectItem> {availableDepartments.length > 0 ? (
<SelectItem value="Sales">Sales</SelectItem> availableDepartments.map((dept) => (
<SelectItem value="HR">HR</SelectItem> <SelectItem key={dept} value={dept}>
<SelectItem value="Finance">Finance</SelectItem> {dept}
<SelectItem value="IT">IT</SelectItem> </SelectItem>
<SelectItem value="Marketing">Marketing</SelectItem> ))
<SelectItem value="Customer Service">Customer Service</SelectItem> ) : (
<SelectItem value="Logistics">Logistics</SelectItem> <SelectItem value="Operations">Operations</SelectItem>
<SelectItem value="Management">Management</SelectItem> )}
<SelectItem value="Other">Other</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{formData.department && (
<p className="text-xs text-slate-500 mt-1">✓ Pre-filled from your invitation</p>
)}
</div> </div>
{hubs.length > 0 && ( {hubs.length > 0 && (
<div> <div>
<Label htmlFor="hub">Hub Location (Optional)</Label> <Label htmlFor="hub">Hub Location</Label>
<Select value={formData.hub} onValueChange={(value) => setFormData({ ...formData, hub: value })}> <Select value={formData.hub} onValueChange={(value) => setFormData({ ...formData, hub: value })}>
<SelectTrigger className="mt-2"> <SelectTrigger className="mt-2">
<SelectValue placeholder="Select hub location" /> <SelectValue placeholder="Select hub location" />
@@ -383,6 +430,9 @@ export default function Onboarding() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{formData.hub && (
<p className="text-xs text-blue-600 font-semibold mt-1">📍 You're joining {formData.hub}!</p>
)}
</div> </div>
)} )}

View File

@@ -130,7 +130,8 @@ Return a concise summary.`,
const primaryLocation = businesses[0]?.business_name || "Primary Location"; const primaryLocation = businesses[0]?.business_name || "Primary Location";
// Ensure count is properly set - default to 1 if not detected // 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) // Get current time for start_time (when ASAP)
const now = new Date(); const now = new Date();
@@ -221,15 +222,17 @@ Return a concise summary.`,
const confirmTime12Hour = convertTo12Hour(confirmTime); const confirmTime12Hour = convertTo12Hour(confirmTime);
// Create comprehensive order data with proper requested field and actual times // 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 = { 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, is_rapid: true,
status: "Pending", status: "Pending",
business_name: detectedOrder.business_name, business_name: detectedOrder.business_name,
hub: detectedOrder.hub, hub: detectedOrder.hub,
event_location: detectedOrder.location, event_location: detectedOrder.location,
date: now.toISOString().split('T')[0], 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_name: user?.full_name,
client_email: user?.email, 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}`, 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, location: detectedOrder.location,
roles: [{ roles: [{
role: detectedOrder.role, 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 start_time: detectedOrder.start_time, // Store in 24-hour format
end_time: detectedOrder.end_time // Store in 24-hour format end_time: detectedOrder.end_time // Store in 24-hour format
}] }]

View File

@@ -11,7 +11,15 @@ import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { DragDropContext, Draggable } from "@hello-pangea/dnd"; 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 TaskCard from "@/components/tasks/TaskCard";
import TaskColumn from "@/components/tasks/TaskColumn"; import TaskColumn from "@/components/tasks/TaskColumn";
import TaskDetailModal from "@/components/tasks/TaskDetailModal"; import TaskDetailModal from "@/components/tasks/TaskDetailModal";
@@ -32,6 +40,15 @@ export default function TaskBoard() {
assigned_members: [] assigned_members: []
}); });
const [selectedMembers, setSelectedMembers] = useState([]); 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({ const { data: user } = useQuery({
queryKey: ['current-user-taskboard'], 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 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 currentTeamMembers = teamMembers.filter(m => m.team_id === userTeam?.id);
const leadMembers = currentTeamMembers.filter(m => m.role === 'admin' || m.role === 'manager'); 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 // Get unique departments from team members
const departments = [...new Set(currentTeamMembers.map(m => m.department).filter(Boolean))]; 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(() => ({ const tasksByStatus = useMemo(() => ({
pending: teamTasks.filter(t => t.status === 'pending').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)), pending: sortTasks(teamTasks.filter(t => t.status === 'pending')),
in_progress: teamTasks.filter(t => t.status === 'in_progress').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)), in_progress: sortTasks(teamTasks.filter(t => t.status === 'in_progress')),
on_hold: teamTasks.filter(t => t.status === 'on_hold').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)), on_hold: sortTasks(teamTasks.filter(t => t.status === 'on_hold')),
completed: teamTasks.filter(t => t.status === 'completed').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)), completed: sortTasks(teamTasks.filter(t => t.status === 'completed')),
}), [teamTasks]); }), [teamTasks, sortBy]);
const overallProgress = useMemo(() => { const overallProgress = useMemo(() => {
if (teamTasks.length === 0) return 0; if (teamTasks.length === 0) return 0;
@@ -158,6 +216,130 @@ export default function TaskBoard() {
<div className="max-w-[1800px] mx-auto"> <div className="max-w-[1800px] mx-auto">
{/* Header */} {/* Header */}
<div className="bg-white rounded-xl p-6 mb-6 shadow-sm border border-slate-200"> <div className="bg-white rounded-xl p-6 mb-6 shadow-sm border border-slate-200">
{/* Toolbar */}
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-slate-200">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<UserCircle className="w-4 h-4" />
Person
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={() => setFilterPerson("all")}>
All People
</DropdownMenuItem>
<DropdownMenuSeparator />
{currentTeamMembers.map((member) => (
<DropdownMenuItem
key={member.id}
onClick={() => setFilterPerson(member.id)}
>
{member.member_name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Filter className="w-4 h-4" />
Filter
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Priority</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setFilterPriority("all")}>All</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilterPriority("high")}>High</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilterPriority("normal")}>Normal</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilterPriority("low")}>Low</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<ArrowUpDown className="w-4 h-4" />
Sort
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setSortBy("due_date")}>Due Date</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("priority")}>Priority</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("created_date")}>Created Date</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSortBy("task_name")}>Name</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => setShowCompleted(!showCompleted)}
>
<EyeOff className="w-4 h-4" />
Hide
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Grid3x3 className="w-4 h-4" />
Group by
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setGroupBy("status")}>Status</DropdownMenuItem>
<DropdownMenuItem onClick={() => setGroupBy("priority")}>Priority</DropdownMenuItem>
<DropdownMenuItem onClick={() => setGroupBy("assigned")}>Assigned To</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => setPinnedColumns(pinnedColumns.length > 0 ? [] : ['pending'])}>
<Pin className="w-4 h-4 mr-2" />
Pin columns
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Item height</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setItemHeight("compact")}>
<Ruler className="w-4 h-4 mr-2" />
Compact
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setItemHeight("normal")}>
<Ruler className="w-4 h-4 mr-2" />
Normal
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setItemHeight("comfortable")}>
<Ruler className="w-4 h-4 mr-2" />
Comfortable
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setConditionalColoring(!conditionalColoring)}>
<Palette className="w-4 h-4 mr-2" />
Conditional coloring
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 mb-2">Task Board</h1> <h1 className="text-2xl font-bold text-slate-900 mb-2">Task Board</h1>
@@ -205,8 +387,8 @@ export default function TaskBoard() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="border-slate-300"> <Button variant="outline" className="gap-2 bg-white hover:bg-slate-50 border border-slate-300 text-slate-700 font-medium">
<Link2 className="w-4 h-4 mr-2" /> <Link2 className="w-4 h-4" />
Share Share
</Button> </Button>
<Button <Button
@@ -214,10 +396,10 @@ export default function TaskBoard() {
setSelectedStatus("pending"); setSelectedStatus("pending");
setCreateDialog(true); setCreateDialog(true);
}} }}
className="bg-[#0A39DF] hover:bg-blue-700" className="gap-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold shadow-md"
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-5 h-5" />
Create List Create Task
</Button> </Button>
</div> </div>
</div> </div>
@@ -256,6 +438,8 @@ export default function TaskBoard() {
task={task} task={task}
provided={provided} provided={provided}
onClick={() => setSelectedTask(task)} onClick={() => setSelectedTask(task)}
itemHeight={itemHeight}
conditionalColoring={conditionalColoring}
/> />
)} )}
</Draggable> </Draggable>

View File

@@ -1,4 +1,3 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { base44 } from "@/api/base44Client"; import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -48,8 +47,23 @@ export default function TeamDetails() {
state: "", state: "",
zip_code: "", zip_code: "",
manager_name: "", 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({ const { data: user } = useQuery({
queryKey: ['current-user-team-details'], queryKey: ['current-user-team-details'],
@@ -84,6 +98,13 @@ export default function TeamDetails() {
enabled: !!teamId, enabled: !!teamId,
initialData: [], initialData: [],
}); });
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-favorites'],
queryFn: () => base44.entities.Staff.list(),
enabled: !!teamId,
initialData: [],
});
const updateMemberMutation = useMutation({ const updateMemberMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.TeamMember.update(id, data), mutationFn: ({ id, data }) => base44.entities.TeamMember.update(id, data),
@@ -200,7 +221,8 @@ export default function TeamDetails() {
state: "", state: "",
zip_code: "", zip_code: "",
manager_name: "", manager_name: "",
manager_email: "" manager_email: "",
departments: []
}); });
toast({ toast({
title: "Hub Created", 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) => { const handleEditMember = (member) => {
setEditingMember(member); setEditingMember(member);
@@ -559,7 +653,7 @@ export default function TeamDetails() {
)} )}
</div> </div>
</div> </div>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm mb-4">
{hub.address && <p className="text-slate-600">{hub.address}</p>} {hub.address && <p className="text-slate-600">{hub.address}</p>}
{hub.city && ( {hub.city && (
<p className="text-slate-600"> <p className="text-slate-600">
@@ -570,6 +664,38 @@ export default function TeamDetails() {
<p className="text-slate-600">{hub.manager_email}</p> <p className="text-slate-600">{hub.manager_email}</p>
)} )}
</div> </div>
{hub.departments && hub.departments.length > 0 && (
<div className="border-t pt-4 mt-4">
<p className="text-xs font-semibold text-slate-600 mb-2">DEPARTMENTS</p>
<div className="space-y-2">
{hub.departments.map((dept, idx) => (
<div key={idx} className="bg-slate-50 p-2 rounded text-xs">
<p className="font-semibold text-slate-900">{dept.department_name}</p>
{dept.cost_center && (
<p className="text-slate-600">Cost Center: {dept.cost_center}</p>
)}
{dept.manager_name && (
<p className="text-slate-600">Manager: {dept.manager_name}</p>
)}
</div>
))}
</div>
</div>
)}
<Button
variant="outline"
size="sm"
className="w-full mt-4"
onClick={() => {
setSelectedHub(hub);
setShowAddDepartmentDialog(true);
}}
>
<Plus className="w-3 h-3 mr-2" />
Add Department
</Button>
</CardContent> </CardContent>
</Card> </Card>
)) ))
@@ -592,10 +718,69 @@ export default function TeamDetails() {
{/* Favorite Staff Tab */} {/* Favorite Staff Tab */}
<TabsContent value="favorite" className="mt-6"> <TabsContent value="favorite" className="mt-6">
<Card className="border-slate-200"> <Card className="border-slate-200">
<CardContent className="p-12 text-center"> <CardContent className="p-6">
<Star className="w-16 h-16 mx-auto text-slate-300 mb-4" /> <div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Favorite Staff</h3> <div className="relative flex-1 max-w-md">
<p className="text-slate-500">Mark staff as favorites to see them here</p> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search favorite staff..."
value={favoriteSearch}
onChange={(e) => setFavoriteSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button onClick={() => setShowAddFavoriteDialog(true)} className="bg-[#0A39DF]">
<Star className="w-4 h-4 mr-2" />
Add Favorite
</Button>
</div>
{team.favorite_staff && team.favorite_staff.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{team.favorite_staff.filter(f =>
!favoriteSearch ||
f.staff_name?.toLowerCase().includes(favoriteSearch.toLowerCase()) ||
f.position?.toLowerCase().includes(favoriteSearch.toLowerCase())
).map((fav) => (
<Card key={fav.staff_id} className="border-amber-200 bg-amber-50">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12 border-2 border-amber-300">
<AvatarFallback className="bg-amber-200 text-amber-700 font-bold">
{fav.staff_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-bold text-slate-900">{fav.staff_name}</p>
<p className="text-xs text-slate-600">{fav.position}</p>
</div>
</div>
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeFromFavorites(fav.staff_id)}
className="w-full border-amber-300 hover:bg-amber-100 text-xs"
>
Remove
</Button>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-12">
<Star className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Favorite Staff</h3>
<p className="text-slate-500 mb-4">Mark staff as favorites to see them here</p>
<Button onClick={() => setShowAddFavoriteDialog(true)} className="bg-[#0A39DF]">
<Star className="w-4 h-4 mr-2" />
Add Your First Favorite
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -603,10 +788,64 @@ export default function TeamDetails() {
{/* Blocked Staff Tab */} {/* Blocked Staff Tab */}
<TabsContent value="blocked" className="mt-6"> <TabsContent value="blocked" className="mt-6">
<Card className="border-slate-200"> <Card className="border-slate-200">
<CardContent className="p-12 text-center"> <CardContent className="p-6">
<UserX className="w-16 h-16 mx-auto text-slate-300 mb-4" /> <div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Blocked Staff</h3> <div className="relative flex-1 max-w-md">
<p className="text-slate-500">Blocked staff will appear here</p> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search blocked staff..."
value={blockedSearch}
onChange={(e) => setBlockedSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button onClick={() => setShowAddBlockedDialog(true)} variant="outline" className="border-red-300 text-red-600 hover:bg-red-50">
<UserX className="w-4 h-4 mr-2" />
Block Staff
</Button>
</div>
{team.blocked_staff && team.blocked_staff.length > 0 ? (
<div className="space-y-3">
{team.blocked_staff.filter(b =>
!blockedSearch ||
b.staff_name?.toLowerCase().includes(blockedSearch.toLowerCase())
).map((blocked) => (
<Card key={blocked.staff_id} className="border-red-200 bg-red-50">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12 border-2 border-red-300">
<AvatarFallback className="bg-red-200 text-red-700 font-bold">
{blocked.staff_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="font-bold text-slate-900">{blocked.staff_name}</p>
<p className="text-xs text-slate-600 mt-1"><strong>Reason:</strong> {blocked.reason || 'No reason provided'}</p>
<p className="text-[10px] text-slate-500 mt-1">Blocked {new Date(blocked.blocked_date).toLocaleDateString()}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => removeFromBlocked(blocked.staff_id)}
className="border-red-300 hover:bg-red-100 text-red-600 text-xs"
>
Unblock
</Button>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-12">
<UserX className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Blocked Staff</h3>
<p className="text-slate-500">Blocked staff will appear here</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -806,7 +1045,7 @@ export default function TeamDetails() {
<Input <Input
value={newHub.hub_name} value={newHub.hub_name}
onChange={(e) => setNewHub({ ...newHub, hub_name: e.target.value })} onChange={(e) => setNewHub({ ...newHub, hub_name: e.target.value })}
placeholder="Downtown Office" placeholder="BVG 300"
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
@@ -814,7 +1053,7 @@ export default function TeamDetails() {
<Input <Input
value={newHub.address} value={newHub.address}
onChange={(e) => setNewHub({ ...newHub, address: e.target.value })} onChange={(e) => setNewHub({ ...newHub, address: e.target.value })}
placeholder="123 Main Street" placeholder="300 Bayview Dr, Mountain View, CA 94043"
/> />
</div> </div>
<div> <div>
@@ -822,7 +1061,7 @@ export default function TeamDetails() {
<Input <Input
value={newHub.city} value={newHub.city}
onChange={(e) => setNewHub({ ...newHub, city: e.target.value })} onChange={(e) => setNewHub({ ...newHub, city: e.target.value })}
placeholder="San Francisco" placeholder="Mountain View"
/> />
</div> </div>
<div> <div>
@@ -838,7 +1077,7 @@ export default function TeamDetails() {
<Input <Input
value={newHub.zip_code} value={newHub.zip_code}
onChange={(e) => setNewHub({ ...newHub, zip_code: e.target.value })} onChange={(e) => setNewHub({ ...newHub, zip_code: e.target.value })}
placeholder="94102" placeholder="94043"
/> />
</div> </div>
<div> <div>
@@ -867,7 +1106,139 @@ export default function TeamDetails() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Add Department Dialog */}
<Dialog open={showAddDepartmentDialog} onOpenChange={setShowAddDepartmentDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add Department to {selectedHub?.hub_name}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Department Name *</Label>
<Input
value={newDepartment.department_name}
onChange={(e) => setNewDepartment({ ...newDepartment, department_name: e.target.value })}
placeholder="Catering FOH or Catering BOH"
/>
</div>
<div>
<Label>Cost Center</Label>
<Input
value={newDepartment.cost_center}
onChange={(e) => setNewDepartment({ ...newDepartment, cost_center: e.target.value })}
placeholder="CC-12345"
/>
</div>
<div>
<Label>Department Manager</Label>
<Input
value={newDepartment.manager_name}
onChange={(e) => setNewDepartment({ ...newDepartment, manager_name: e.target.value })}
placeholder="Manager name"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setShowAddDepartmentDialog(false);
setNewDepartment({ department_name: "", cost_center: "", manager_name: "" });
}}>Cancel</Button>
<Button
onClick={async () => {
const updatedDepartments = [...(selectedHub.departments || []), newDepartment];
await base44.entities.TeamHub.update(selectedHub.id, {
departments: updatedDepartments
});
queryClient.invalidateQueries({ queryKey: ['team-hubs', teamId] });
setShowAddDepartmentDialog(false);
setNewDepartment({ department_name: "", cost_center: "", manager_name: "" });
toast({ title: "Department Added", description: "Department created successfully" });
}}
className="bg-[#0A39DF]"
disabled={!newDepartment.department_name}
>
Add Department
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Favorite Staff Dialog */}
<Dialog open={showAddFavoriteDialog} onOpenChange={setShowAddFavoriteDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Favorite Staff</DialogTitle>
</DialogHeader>
<div className="max-h-96 overflow-y-auto space-y-2">
{allStaff.filter(s => !(team.favorite_staff || []).some(f => f.staff_id === s.id)).map((staff) => (
<Card key={staff.id} className="cursor-pointer hover:bg-blue-50 transition-colors" onClick={() => addToFavorites(staff)}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-[#0A39DF] text-white">
{staff.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">{staff.employee_name}</p>
<p className="text-xs text-slate-500">{staff.position}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddFavoriteDialog(false)}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Blocked Staff Dialog */}
<Dialog open={showAddBlockedDialog} onOpenChange={setShowAddBlockedDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Block Staff Member</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Reason for blocking *</Label>
<Input
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
placeholder="Performance issues, policy violation, etc."
/>
</div>
<div className="max-h-64 overflow-y-auto space-y-2">
{allStaff.filter(s => !(team.blocked_staff || []).some(b => b.staff_id === s.id)).map((staff) => (
<Card key={staff.id} className="cursor-pointer hover:bg-red-50 transition-colors" onClick={() => addToBlocked(staff)}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-slate-200 text-slate-700">
{staff.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">{staff.employee_name}</p>
<p className="text-xs text-slate-500">{staff.position}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setShowAddBlockedDialog(false);
setBlockReason("");
}}>Cancel</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Textarea } from "@/components/ui/textarea"; 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"; import { useToast } from "@/components/ui/use-toast";
export default function VendorMarketplace() { export default function VendorMarketplace() {
@@ -287,29 +287,25 @@ export default function VendorMarketplace() {
<div className="max-w-[1600px] mx-auto space-y-6"> <div className="max-w-[1600px] mx-auto space-y-6">
{/* Hero Header */} {/* Hero Header */}
<div className="relative overflow-hidden bg-gradient-to-r from-[#0A39DF] to-[#1C323E] rounded-xl p-8 shadow-lg"> <div className="relative overflow-hidden bg-gradient-to-br from-slate-100 via-purple-50 to-blue-50 rounded-xl p-8 shadow-lg border border-slate-200">
<div className="absolute inset-0 opacity-5" style={{
backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)',
backgroundSize: '30px 30px'
}} />
<div className="relative z-10"> <div className="relative z-10">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<div className="w-14 h-14 bg-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center"> <div className="w-14 h-14 bg-white shadow-md rounded-xl flex items-center justify-center border border-slate-200">
<Building2 className="w-7 h-7 text-white" /> <Building2 className="w-7 h-7 text-indigo-600" />
</div> </div>
<div> <div>
<h1 className="text-3xl font-bold text-white">Vendor Marketplace</h1> <h1 className="text-3xl font-bold text-slate-800">Vendor Marketplace</h1>
<p className="text-blue-100 text-sm mt-1">Find the perfect vendor partner for your staffing needs</p> <p className="text-slate-600 text-sm mt-1">Find the perfect vendor partner for your staffing needs</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4 mt-5"> <div className="flex items-center gap-4 mt-5">
<div className="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-sm rounded-lg"> <div className="flex items-center gap-2 px-4 py-2 bg-white shadow-sm rounded-lg border border-slate-200">
<Users className="w-4 h-4 text-white" /> <Users className="w-4 h-4 text-indigo-600" />
<span className="text-white font-semibold">{filteredVendors.length} Active Vendors</span> <span className="text-slate-700 font-semibold">{filteredVendors.length} Active Vendors</span>
</div> </div>
<div className="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-sm rounded-lg"> <div className="flex items-center gap-2 px-4 py-2 bg-white shadow-sm rounded-lg border border-slate-200">
<Star className="w-4 h-4 text-amber-300 fill-amber-300" /> <Star className="w-4 h-4 text-amber-500 fill-amber-400" />
<span className="text-white font-semibold">Verified & Approved</span> <span className="text-slate-700 font-semibold">Verified & Approved</span>
</div> </div>
</div> </div>
</div> </div>
@@ -322,11 +318,11 @@ export default function VendorMarketplace() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
<div className="relative"> <div className="relative">
<div className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center shadow-md"> <div className="w-16 h-16 bg-gradient-to-br from-blue-400 to-blue-500 rounded-xl flex items-center justify-center shadow-md">
<Crown className="w-8 h-8 text-amber-400" /> <Handshake className="w-8 h-8 text-white" />
</div> </div>
<div className="absolute -top-1 -right-1 w-6 h-6 bg-amber-500 rounded-full border-2 border-white flex items-center justify-center shadow-md"> <div className="absolute -top-1 -right-1 w-6 h-6 bg-blue-500 rounded-full border-2 border-white flex items-center justify-center shadow-md">
<Star className="w-3 h-3 text-white fill-white" /> <DollarSign className="w-3 h-3 text-white" />
</div> </div>
</div> </div>
<div> <div>
@@ -354,32 +350,32 @@ export default function VendorMarketplace() {
<CardContent className="p-6"> <CardContent className="p-6">
<div className="grid grid-cols-6 gap-3 mb-5"> <div className="grid grid-cols-6 gap-3 mb-5">
{/* Stats Grid */} {/* Stats Grid */}
<div className="text-center p-4 bg-slate-50 rounded-lg border border-slate-200"> <div className="text-center p-4 bg-slate-50/50 rounded-lg border border-slate-200">
<Users className="w-5 h-5 mx-auto mb-2 text-[#0A39DF]" /> <Users className="w-5 h-5 mx-auto mb-2 text-slate-600" />
<p className="text-2xl font-bold text-slate-900">{preferredVendor.staffCount}</p> <p className="text-2xl font-bold text-slate-900">{preferredVendor.staffCount}</p>
<p className="text-xs text-slate-600 mt-1 font-medium">Staff</p> <p className="text-xs text-slate-600 mt-1 font-medium">Staff</p>
</div> </div>
<div className="text-center p-4 bg-amber-50 rounded-lg border border-amber-200"> <div className="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<Star className="w-5 h-5 mx-auto mb-2 text-amber-600 fill-amber-600" /> <Star className="w-5 h-5 mx-auto mb-2 text-amber-600 fill-amber-500" />
<p className="text-2xl font-bold text-slate-900">{preferredVendor.rating.toFixed(1)}</p> <p className="text-2xl font-bold text-slate-900">{preferredVendor.rating.toFixed(1)}</p>
<p className="text-xs text-slate-600 mt-1 font-medium">Rating</p> <p className="text-xs text-slate-600 mt-1 font-medium">Rating</p>
</div> </div>
<div className="text-center p-4 bg-emerald-50 rounded-lg border border-emerald-200"> <div className="text-center p-4 bg-teal-50 rounded-lg border border-teal-200">
<Target className="w-5 h-5 mx-auto mb-2 text-emerald-600" /> <Target className="w-5 h-5 mx-auto mb-2 text-teal-600" />
<p className="text-2xl font-bold text-slate-900">98%</p> <p className="text-2xl font-bold text-slate-900">98%</p>
<p className="text-xs text-slate-600 mt-1 font-medium">Fill Rate</p> <p className="text-xs text-slate-600 mt-1 font-medium">Fill Rate</p>
</div> </div>
<div className="text-center p-4 bg-purple-50 rounded-lg border border-purple-200"> <div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<Clock className="w-5 h-5 mx-auto mb-2 text-purple-600" /> <Clock className="w-5 h-5 mx-auto mb-2 text-blue-600" />
<p className="text-2xl font-bold text-slate-900">{preferredVendor.responseTime}</p> <p className="text-2xl font-bold text-slate-900">{preferredVendor.responseTime}</p>
<p className="text-xs text-slate-600 mt-1 font-medium">Response</p> <p className="text-xs text-slate-600 mt-1 font-medium">Response</p>
</div> </div>
<div className="text-center p-4 bg-indigo-50 rounded-lg border border-indigo-200"> <div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<DollarSign className="w-5 h-5 mx-auto mb-2 text-indigo-600" /> <DollarSign className="w-5 h-5 mx-auto mb-2 text-blue-600" />
<p className="text-2xl font-bold text-slate-900">${Math.round(preferredVendor.minRate)}</p> <p className="text-2xl font-bold text-slate-900">${Math.round(preferredVendor.minRate)}</p>
<p className="text-xs text-slate-600 mt-1 font-medium">From/hr</p> <p className="text-xs text-slate-600 mt-1 font-medium">From/hr</p>
</div> </div>
@@ -394,32 +390,32 @@ export default function VendorMarketplace() {
{/* Benefits Banner */} {/* Benefits Banner */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center gap-3"> <div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-9 h-9 bg-white border border-green-200 shadow-sm rounded-lg flex items-center justify-center flex-shrink-0">
<Zap className="w-4 h-4 text-white" /> <Zap className="w-4 h-4 text-green-600" />
</div> </div>
<div> <div>
<p className="font-bold text-green-900 text-sm">Priority Support</p> <p className="font-bold text-slate-800 text-sm">Priority Support</p>
<p className="text-xs text-green-700">Faster responses</p> <p className="text-xs text-slate-600">Faster responses</p>
</div> </div>
</div> </div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center gap-3"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 bg-[#0A39DF] rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-9 h-9 bg-white border border-blue-200 shadow-sm rounded-lg flex items-center justify-center flex-shrink-0">
<Shield className="w-4 h-4 text-white" /> <Shield className="w-4 h-4 text-blue-600" />
</div> </div>
<div> <div>
<p className="font-bold text-[#1C323E] text-sm">Dedicated Manager</p> <p className="font-bold text-slate-800 text-sm">Dedicated Manager</p>
<p className="text-xs text-slate-600">Direct contact</p> <p className="text-xs text-slate-600">Direct contact</p>
</div> </div>
</div> </div>
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-3 flex items-center gap-3"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center gap-3">
<div className="w-9 h-9 bg-indigo-600 rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-9 h-9 bg-white border border-blue-200 shadow-sm rounded-lg flex items-center justify-center flex-shrink-0">
<TrendingUp className="w-4 h-4 text-white" /> <TrendingUp className="w-4 h-4 text-blue-600" />
</div> </div>
<div> <div>
<p className="font-bold text-indigo-900 text-sm">Better Rates</p> <p className="font-bold text-slate-800 text-sm">Better Rates</p>
<p className="text-xs text-indigo-700">Volume pricing</p> <p className="text-xs text-slate-600">Volume pricing</p>
</div> </div>
</div> </div>
</div> </div>
@@ -457,7 +453,7 @@ export default function VendorMarketplace() {
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
<Card className="border border-slate-200 bg-white hover:border-[#0A39DF] hover:shadow-md transition-all"> <Card className="border border-slate-200 bg-slate-50/50 hover:shadow-md transition-all">
<CardContent className="p-5"> <CardContent className="p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -465,46 +461,14 @@ export default function VendorMarketplace() {
<p className="text-3xl font-bold text-slate-900 mb-0.5">{vendors.length}</p> <p className="text-3xl font-bold text-slate-900 mb-0.5">{vendors.length}</p>
<p className="text-slate-500 text-xs">Approved</p> <p className="text-slate-500 text-xs">Approved</p>
</div> </div>
<div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center"> <div className="w-12 h-12 bg-white border border-slate-200 shadow-sm rounded-xl flex items-center justify-center">
<Building2 className="w-6 h-6 text-[#0A39DF]" /> <Building2 className="w-6 h-6 text-slate-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border border-slate-200 bg-white hover:border-emerald-500 hover:shadow-md transition-all"> <Card className="border border-yellow-200 bg-yellow-50 hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Staff</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">{staff.length}</p>
<p className="text-slate-500 text-xs">Available</p>
</div>
<div className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
<Users className="w-6 h-6 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="border border-slate-200 bg-white hover:border-indigo-500 hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Avg Rate</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">
${Math.round(vendorsWithMetrics.reduce((sum, v) => sum + v.avgRate, 0) / vendorsWithMetrics.length || 0)}
</p>
<p className="text-slate-500 text-xs">Per hour</p>
</div>
<div className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center">
<DollarSign className="w-6 h-6 text-indigo-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="border border-slate-200 bg-white hover:border-amber-500 hover:shadow-md transition-all">
<CardContent className="p-5"> <CardContent className="p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -515,12 +479,42 @@ export default function VendorMarketplace() {
</div> </div>
<p className="text-slate-500 text-xs">Average</p> <p className="text-slate-500 text-xs">Average</p>
</div> </div>
<div className="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center"> <div className="w-12 h-12 bg-white border border-yellow-200 shadow-sm rounded-xl flex items-center justify-center">
<Award className="w-6 h-6 text-amber-600" /> <Award className="w-6 h-6 text-amber-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border border-teal-200 bg-teal-50 hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Fill Rate</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">98%</p>
<p className="text-slate-500 text-xs">Success rate</p>
</div>
<div className="w-12 h-12 bg-white border border-teal-200 shadow-sm rounded-xl flex items-center justify-center">
<Target className="w-6 h-6 text-teal-600" />
</div>
</div>
</CardContent>
</Card>
<Card className="border border-blue-200 bg-blue-50 hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Response</p>
<p className="text-3xl font-bold text-slate-900 mb-0.5">2h</p>
<p className="text-slate-500 text-xs">Avg time</p>
</div>
<div className="w-12 h-12 bg-white border border-blue-200 shadow-sm rounded-xl flex items-center justify-center">
<Clock className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</div> </div>
{/* Filters */} {/* Filters */}
@@ -635,13 +629,16 @@ export default function VendorMarketplace() {
const isExpanded = expandedVendors[vendor.id]; const isExpanded = expandedVendors[vendor.id];
return ( return (
<Card key={vendor.id} className="bg-white border border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all group"> <Card key={vendor.id} className="bg-white border border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all group">
<CardHeader className="bg-slate-50 border-b border-slate-200 pb-4"> <CardHeader className="bg-gradient-to-br from-slate-50 to-blue-50/30 border-b border-slate-200 pb-4">
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">
<div className="relative"> <div className="relative">
<Avatar className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-indigo-600 shadow-lg ring-2 ring-blue-200"> <Avatar className="w-16 h-16 bg-blue-100 shadow-lg ring-2 ring-blue-200">
<AvatarFallback className="text-white text-xl font-bold"> {vendor.company_logo ? (
<AvatarImage src={vendor.company_logo} alt={vendor.legal_name} />
) : null}
<AvatarFallback className="text-blue-700 text-xl font-bold bg-blue-100">
{vendor.legal_name?.charAt(0)} {vendor.legal_name?.charAt(0)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -652,12 +649,12 @@ export default function VendorMarketplace() {
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<CardTitle className="text-xl font-bold text-[#1C323E] group-hover:text-[#0A39DF] transition-colors"> <CardTitle className="text-xl font-bold text-slate-800 group-hover:text-blue-700 transition-colors">
{vendor.legal_name} {vendor.legal_name}
</CardTitle> </CardTitle>
<div className="flex items-center gap-1.5 bg-amber-50 px-3 py-1.5 rounded-full border border-amber-200"> <div className="flex items-center gap-1.5 bg-yellow-50 px-3 py-1.5 rounded-full border border-yellow-200">
<Star className="w-4 h-4 text-amber-600 fill-amber-600" /> <Star className="w-4 h-4 text-amber-500 fill-amber-500" />
<span className="text-sm font-bold text-amber-700">{vendor.rating.toFixed(1)}</span> <span className="text-sm font-bold text-slate-800">{vendor.rating.toFixed(1)}</span>
</div> </div>
</div> </div>
@@ -667,20 +664,20 @@ export default function VendorMarketplace() {
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
{vendor.service_specialty && ( {vendor.service_specialty && (
<Badge className="bg-blue-100 text-blue-700"> <Badge className="bg-blue-100 text-blue-700 border border-blue-200">
{vendor.service_specialty} {vendor.service_specialty}
</Badge> </Badge>
)} )}
<span className="flex items-center gap-1.5 text-sm text-slate-700"> <span className="flex items-center gap-1.5 text-sm text-slate-700">
<MapPin className="w-4 h-4 text-[#0A39DF]" /> <MapPin className="w-4 h-4 text-slate-500" />
{vendor.region || vendor.city} {vendor.region || vendor.city}
</span> </span>
<span className="flex items-center gap-1.5 text-sm text-slate-700"> <span className="flex items-center gap-1.5 text-sm text-slate-700">
<Users className="w-4 h-4 text-[#0A39DF]" /> <Users className="w-4 h-4 text-slate-500" />
{vendor.staffCount} Staff {vendor.staffCount} Staff
</span> </span>
<span className="flex items-center gap-1.5 text-sm text-slate-700"> <span className="flex items-center gap-1.5 text-sm text-slate-700">
<Clock className="w-4 h-4 text-emerald-600" /> <Clock className="w-4 h-4 text-teal-600" />
{vendor.responseTime} {vendor.responseTime}
</span> </span>
</div> </div>
@@ -688,19 +685,19 @@ export default function VendorMarketplace() {
</div> </div>
<div className="flex flex-col items-end gap-3"> <div className="flex flex-col items-end gap-3">
<div className="p-4 bg-gradient-to-br from-[#0A39DF] to-indigo-600 rounded-xl shadow-lg text-center min-w-[140px]"> <div className="p-4 bg-blue-50 border border-blue-200 rounded-xl shadow-sm text-center min-w-[140px]">
<p className="text-blue-100 text-[10px] mb-1 font-semibold uppercase tracking-wide">Starting from</p> <p className="text-slate-600 text-[10px] mb-1 font-semibold uppercase tracking-wide">Starting from</p>
<p className="text-3xl font-bold text-white mb-1">${vendor.minRate}</p> <p className="text-3xl font-bold text-slate-900 mb-1">${vendor.minRate}</p>
<p className="text-blue-200 text-xs">per hour</p> <p className="text-slate-600 text-xs">per hour</p>
</div> </div>
{vendor.clientsInSector > 0 && ( {vendor.clientsInSector > 0 && (
<div className="bg-gradient-to-br from-purple-50 to-pink-50 border-2 border-purple-300 rounded-xl px-4 py-3 shadow-md min-w-[140px]"> <div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 shadow-sm min-w-[140px]">
<div className="flex items-center justify-center gap-2 mb-1"> <div className="flex items-center justify-center gap-2 mb-1">
<UserCheck className="w-5 h-5 text-purple-700" /> <UserCheck className="w-5 h-5 text-blue-600" />
<span className="text-2xl font-bold text-purple-700">{vendor.clientsInSector}</span> <span className="text-2xl font-bold text-slate-900">{vendor.clientsInSector}</span>
</div> </div>
<p className="text-[10px] text-purple-600 font-bold text-center uppercase tracking-wide"> <p className="text-[10px] text-slate-600 font-bold text-center uppercase tracking-wide">
in your area in your area
</p> </p>
</div> </div>
@@ -711,7 +708,7 @@ export default function VendorMarketplace() {
<CheckCircle className="w-3 h-3 mr-1" /> <CheckCircle className="w-3 h-3 mr-1" />
{vendor.completedJobs} jobs {vendor.completedJobs} jobs
</Badge> </Badge>
<Badge variant="outline" className="border-slate-300 px-3 py-1.5 text-xs font-semibold"> <Badge variant="outline" className="border-slate-300 bg-slate-50/50 px-3 py-1.5 text-xs font-semibold">
{vendor.rates.length} services {vendor.rates.length} services
</Badge> </Badge>
</div> </div>
@@ -719,17 +716,17 @@ export default function VendorMarketplace() {
</div> </div>
</CardHeader> </CardHeader>
<div className="px-5 py-4 bg-white border-b border-slate-100"> <div className="px-5 py-4 bg-slate-50/50 border-b border-slate-100">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Collapsible open={isExpanded} onOpenChange={() => toggleVendorRates(vendor.id)} className="flex-1"> <Collapsible open={isExpanded} onOpenChange={() => toggleVendorRates(vendor.id)} className="flex-1">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" className="w-auto px-4 py-2 hover:bg-blue-50 rounded-lg"> <Button variant="ghost" className="w-auto px-4 py-2 hover:bg-blue-50 rounded-lg">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-9 h-9 bg-blue-100 rounded-lg flex items-center justify-center"> <div className="w-9 h-9 bg-white border border-slate-200 shadow-sm rounded-lg flex items-center justify-center">
<TrendingUp className="w-4 h-4 text-[#0A39DF]" /> <TrendingUp className="w-4 h-4 text-blue-600" />
</div> </div>
<div className="text-left"> <div className="text-left">
<span className="font-bold text-[#1C323E] text-base">Compare Rates</span> <span className="font-bold text-slate-800 text-base">Compare Rates</span>
<span className="text-xs text-slate-500 block">{vendor.rates.length} services</span> <span className="text-xs text-slate-500 block">{vendor.rates.length} services</span>
</div> </div>
{isExpanded ? <ChevronUp className="w-4 h-4 text-slate-400 ml-2" /> : <ChevronDown className="w-4 h-4 text-slate-400 ml-2" />} {isExpanded ? <ChevronUp className="w-4 h-4 text-slate-400 ml-2" /> : <ChevronDown className="w-4 h-4 text-slate-400 ml-2" />}
@@ -742,22 +739,21 @@ export default function VendorMarketplace() {
<Button <Button
onClick={() => setPreferredMutation.mutate(vendor)} onClick={() => setPreferredMutation.mutate(vendor)}
disabled={setPreferredMutation.isPending} 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"
> >
<Award className="w-4 h-4 mr-2" /> <Award className="w-4 h-4 mr-2" />
Set as Preferred Set as Preferred
</Button> </Button>
<Button <Button
variant="outline"
onClick={() => handleContactVendor(vendor)} onClick={() => 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"
> >
<MessageSquare className="w-4 h-4 mr-2" /> <MessageSquare className="w-4 h-4 mr-2" />
Contact Contact
</Button> </Button>
<Button <Button
onClick={() => handleCreateOrder(vendor)} onClick={() => 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"
> >
<Zap className="w-4 h-4 mr-2" /> <Zap className="w-4 h-4 mr-2" />
Order Now Order Now
@@ -768,69 +764,39 @@ export default function VendorMarketplace() {
<Collapsible open={isExpanded}> <Collapsible open={isExpanded}>
<CollapsibleContent> <CollapsibleContent>
<CardContent className="p-6 bg-gradient-to-br from-slate-50 to-blue-50/20"> <CardContent className="p-6 bg-slate-50/50">
<div className="space-y-4"> <div className="space-y-4">
{Object.entries(vendor.ratesByCategory).map(([category, categoryRates]) => ( {Object.entries(vendor.ratesByCategory).map(([category, categoryRates]) => (
<div key={category} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm"> <div key={category} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<div className="bg-gradient-to-r from-[#0A39DF] to-indigo-600 px-5 py-3"> <div className="bg-gradient-to-r from-slate-100 to-purple-50 px-5 py-3 border-b border-slate-200">
<h4 className="font-bold text-white text-sm flex items-center gap-2"> <h4 className="font-bold text-slate-800 text-sm flex items-center gap-2">
<Briefcase className="w-4 h-4" /> <Briefcase className="w-4 h-4 text-slate-600" />
{category} {category}
<Badge className="bg-white/20 text-white border-0 ml-auto"> <Badge className="bg-slate-200 text-slate-700 border-0 ml-auto">
{categoryRates.length} {categoryRates.length}
</Badge> </Badge>
</h4> </h4>
</div> </div>
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{categoryRates.map((rate, idx) => { {categoryRates.map((rate, idx) => {
const baseWage = rate.employee_wage || 0; return (
const markupAmount = baseWage * ((rate.markup_percentage || 0) / 100); <div key={rate.id} className="p-4 hover:bg-blue-50/30 transition-all">
const feeAmount = (baseWage + markupAmount) * ((rate.vendor_fee_percentage || 0) / 100); <div className="flex items-center justify-between gap-6">
<div className="flex items-center gap-3 flex-1">
return ( <div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center font-bold text-blue-700 text-sm">
<div key={rate.id} className="p-4 hover:bg-blue-50 transition-all"> {idx + 1}
<div className="flex items-center justify-between gap-6">
<div className="flex-1">
<div className="flex items-center gap-2 mb-3">
<div className="w-7 h-7 bg-slate-100 rounded-lg flex items-center justify-center font-bold text-slate-600 text-sm">
{idx + 1}
</div>
<h5 className="font-bold text-[#1C323E] text-base">{rate.role_name}</h5>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs">
<span className="w-24 text-slate-600 font-medium">Base Wage:</span>
<div className="flex-1 h-7 bg-gradient-to-r from-emerald-500 to-emerald-600 rounded-lg flex items-center justify-between px-3 text-white font-semibold">
<span>${baseWage.toFixed(2)}/hr</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-24 text-slate-600 font-medium">+ Markup:</span>
<div className="flex-1 h-7 bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg flex items-center justify-between px-3 text-white font-semibold">
<span>{rate.markup_percentage}% (+${markupAmount.toFixed(2)})</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-24 text-slate-600 font-medium">+ Admin Fee:</span>
<div className="flex-1 h-7 bg-gradient-to-r from-purple-500 to-purple-600 rounded-lg flex items-center justify-between px-3 text-white font-semibold">
<span>{rate.vendor_fee_percentage}% (+${feeAmount.toFixed(2)})</span>
</div>
</div>
</div>
</div>
<div className="flex flex-col items-center">
<p className="text-[10px] text-slate-500 mb-2 font-bold uppercase">You Pay</p>
<div className="bg-gradient-to-br from-[#0A39DF] to-indigo-600 rounded-xl px-6 py-4 shadow-lg">
<p className="text-3xl font-bold text-white">${rate.client_rate?.toFixed(0)}</p>
<p className="text-blue-200 text-xs text-center mt-1">per hour</p>
</div>
</div> </div>
<h5 className="font-bold text-slate-900 text-base">{rate.role_name}</h5>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-xl px-6 py-3 shadow-sm">
<p className="text-3xl font-bold text-slate-900">${rate.client_rate?.toFixed(0)}</p>
<p className="text-slate-600 text-xs text-center mt-1">per hour</p>
</div> </div>
</div> </div>
); </div>
})} );
})}
</div> </div>
</div> </div>
))} ))}
@@ -860,16 +826,19 @@ export default function VendorMarketplace() {
</thead> </thead>
<tbody> <tbody>
{otherVendors.map((vendor) => ( {otherVendors.map((vendor) => (
<tr key={vendor.id} className="border-b border-slate-100 hover:bg-blue-50/50 transition-all"> <tr key={vendor.id} className="border-b border-slate-100 hover:bg-blue-50/30 transition-all">
<td className="py-5 px-5"> <td className="py-5 px-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-indigo-600 shadow-md"> <Avatar className="w-12 h-12 bg-blue-100 shadow-md">
<AvatarFallback className="text-white font-bold"> {vendor.company_logo ? (
<AvatarImage src={vendor.company_logo} alt={vendor.legal_name} />
) : null}
<AvatarFallback className="text-blue-700 font-bold text-lg bg-blue-100">
{vendor.legal_name?.charAt(0)} {vendor.legal_name?.charAt(0)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<p className="font-bold text-[#1C323E]">{vendor.legal_name}</p> <p className="font-bold text-slate-800">{vendor.legal_name}</p>
<p className="text-xs text-slate-500">{vendor.completedJobs} jobs completed</p> <p className="text-xs text-slate-500">{vendor.completedJobs} jobs completed</p>
</div> </div>
</div> </div>
@@ -877,19 +846,19 @@ export default function VendorMarketplace() {
<td className="py-5 px-5 text-sm text-slate-700">{vendor.service_specialty || ''}</td> <td className="py-5 px-5 text-sm text-slate-700">{vendor.service_specialty || ''}</td>
<td className="py-5 px-5"> <td className="py-5 px-5">
<span className="flex items-center gap-1.5 text-sm text-slate-700"> <span className="flex items-center gap-1.5 text-sm text-slate-700">
<MapPin className="w-4 h-4 text-[#0A39DF]" /> <MapPin className="w-4 h-4 text-slate-500" />
{vendor.region} {vendor.region}
</span> </span>
</td> </td>
<td className="py-5 px-5 text-center"> <td className="py-5 px-5 text-center">
<div className="inline-flex items-center gap-2 bg-amber-50 px-3 py-1.5 rounded-full"> <div className="inline-flex items-center gap-2 bg-yellow-50 px-3 py-1.5 rounded-full border border-yellow-200">
<Star className="w-4 h-4 text-amber-500 fill-amber-500" /> <Star className="w-4 h-4 text-amber-500 fill-amber-500" />
<span className="font-bold text-amber-700">{vendor.rating.toFixed(1)}</span> <span className="font-bold text-slate-800">{vendor.rating.toFixed(1)}</span>
</div> </div>
</td> </td>
<td className="py-5 px-5 text-center"> <td className="py-5 px-5 text-center">
{vendor.clientsInSector > 0 ? ( {vendor.clientsInSector > 0 ? (
<Badge className="bg-purple-100 text-purple-700"> <Badge className="bg-blue-100 text-blue-700 border border-blue-200">
<UserCheck className="w-3 h-3 mr-1" /> <UserCheck className="w-3 h-3 mr-1" />
{vendor.clientsInSector} {vendor.clientsInSector}
</Badge> </Badge>
@@ -898,12 +867,12 @@ export default function VendorMarketplace() {
)} )}
</td> </td>
<td className="py-5 px-5 text-center"> <td className="py-5 px-5 text-center">
<Badge variant="outline" className="font-bold">{vendor.staffCount}</Badge> <Badge variant="outline" className="font-bold border-slate-300">{vendor.staffCount}</Badge>
</td> </td>
<td className="py-5 px-5 text-center"> <td className="py-5 px-5 text-center">
<div className="inline-flex flex-col bg-blue-50 px-4 py-2 rounded-xl"> <div className="inline-flex flex-col bg-blue-50 border border-blue-200 px-4 py-2 rounded-xl">
<span className="font-bold text-xl text-[#0A39DF]">${vendor.minRate}</span> <span className="font-bold text-xl text-slate-900">${vendor.minRate}</span>
<span className="text-xs text-slate-500">/hour</span> <span className="text-xs text-slate-600">/hour</span>
</div> </div>
</td> </td>
<td className="py-5 px-5"> <td className="py-5 px-5">
@@ -912,15 +881,15 @@ export default function VendorMarketplace() {
size="sm" size="sm"
onClick={() => setPreferredMutation.mutate(vendor)} onClick={() => setPreferredMutation.mutate(vendor)}
disabled={setPreferredMutation.isPending} 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"
> >
<Award className="w-3 h-3 mr-1" /> <Award className="w-3 h-3 mr-1" />
Set Preferred Set Preferred
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline"
onClick={() => handleContactVendor(vendor)} onClick={() => handleContactVendor(vendor)}
className="bg-amber-50 hover:bg-amber-100 text-slate-800 border border-amber-200"
> >
<MessageSquare className="w-3 h-3 mr-1" /> <MessageSquare className="w-3 h-3 mr-1" />
Contact Contact
@@ -968,8 +937,11 @@ export default function VendorMarketplace() {
<div className="space-y-5 py-4"> <div className="space-y-5 py-4">
<div className="flex items-center gap-4 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border-2 border-blue-200"> <div className="flex items-center gap-4 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border-2 border-blue-200">
<Avatar className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-indigo-600 ring-2 ring-white shadow-md"> <Avatar className="w-16 h-16 bg-blue-100 ring-2 ring-white shadow-md">
<AvatarFallback className="text-white text-xl font-bold"> {contactModal.vendor?.company_logo ? (
<AvatarImage src={contactModal.vendor?.company_logo} alt={contactModal.vendor?.legal_name} />
) : null}
<AvatarFallback className="text-blue-700 text-xl font-bold bg-blue-100">
{contactModal.vendor?.legal_name?.charAt(0)} {contactModal.vendor?.legal_name?.charAt(0)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>

View File

@@ -130,6 +130,10 @@ import NotificationSettings from "./NotificationSettings";
import TaskBoard from "./TaskBoard"; import TaskBoard from "./TaskBoard";
import InvoiceDetail from "./InvoiceDetail";
import InvoiceEditor from "./InvoiceEditor";
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
const PAGES = { const PAGES = {
@@ -264,6 +268,10 @@ const PAGES = {
TaskBoard: TaskBoard, TaskBoard: TaskBoard,
InvoiceDetail: InvoiceDetail,
InvoiceEditor: InvoiceEditor,
} }
function _getCurrentPage(url) { function _getCurrentPage(url) {
@@ -421,6 +429,10 @@ function PagesContent() {
<Route path="/TaskBoard" element={<TaskBoard />} /> <Route path="/TaskBoard" element={<TaskBoard />} />
<Route path="/InvoiceDetail" element={<InvoiceDetail />} />
<Route path="/InvoiceEditor" element={<InvoiceEditor />} />
</Routes> </Routes>
</Layout> </Layout>
); );