new version frontend-webpage
This commit is contained in:
2
Makefile
2
Makefile
@@ -12,7 +12,7 @@
|
||||
# --- Flutter check ---
|
||||
FLUTTER := $(shell which 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
|
||||
|
||||
# --- Firebase & GCP Configuration ---
|
||||
|
||||
72
frontend-web/src/components/common/GoogleAddressInput.jsx
Normal file
72
frontend-web/src/components/common/GoogleAddressInput.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Edit2, Trash2, ArrowLeftRight, Clock, MapPin, Check, X } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { calculateOrderStatus } from "../orders/OrderStatusUtils";
|
||||
|
||||
export default function AssignedStaffManager({ event, shift, role }) {
|
||||
const { toast } = useToast();
|
||||
@@ -52,11 +53,19 @@ export default function AssignedStaffManager({ event, shift, role }) {
|
||||
return s;
|
||||
});
|
||||
|
||||
await base44.entities.Event.update(event.id, {
|
||||
const updatedEvent = {
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
@@ -135,7 +144,7 @@ export default function AssignedStaffManager({ event, shift, role }) {
|
||||
key={staff.staff_id}
|
||||
className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<Avatar className="w-10 h-10 bg-gradient-to-br from-green-600 to-emerald-600">
|
||||
<Avatar className="w-10 h-10 bg-gradient-to-br from-blue-400 to-blue-500">
|
||||
<AvatarFallback className="text-white font-bold">
|
||||
{staff.staff_name?.charAt(0) || 'S'}
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -62,7 +62,7 @@ const hasTimeConflict = (existingStart, existingEnd, newStart, newEnd, existingD
|
||||
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 [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||
const [selectedStaffIds, setSelectedStaffIds] = useState([]);
|
||||
@@ -196,6 +196,20 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
const updatedOrder = { ...order };
|
||||
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
|
||||
const conflictingStaff = [];
|
||||
selectedStaffIds.forEach(staffId => {
|
||||
@@ -215,13 +229,9 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
return;
|
||||
}
|
||||
|
||||
const needed = parseInt(currentRole.count) || 0;
|
||||
const currentAssigned = assignments.length;
|
||||
const remaining = needed - currentAssigned;
|
||||
|
||||
if (selectedStaffIds.length > remaining) {
|
||||
toast({
|
||||
title: "Too many selected",
|
||||
title: "Assignment Limit",
|
||||
description: `Only ${remaining} more staff ${remaining === 1 ? 'is' : 'are'} needed.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
@@ -255,6 +265,16 @@ export default function EventAssignmentModal({ open, onClose, order, onUpdate })
|
||||
const updatedOrder = { ...order };
|
||||
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
|
||||
if (assignments.some(a => a.employee_id === staffMember.id)) {
|
||||
toast({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 roles = shift?.roles || [];
|
||||
const isVendor = currentUser?.user_role === 'vendor' || currentUser?.role === 'vendor';
|
||||
const canAssignStaff = isVendor;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -99,7 +101,7 @@ export default function ShiftCard({ shift, event }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remainingCount > 0 && (
|
||||
{canAssignStaff && remainingCount > 0 && (
|
||||
<Button
|
||||
onClick={() => setAssignModal({ open: true, role })}
|
||||
className="bg-[#0A39DF] hover:bg-blue-700 gap-2 font-semibold"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -32,7 +31,7 @@ import {
|
||||
} from "@/components/ui/alert";
|
||||
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 [selectedStaff, setSelectedStaff] = useState([]);
|
||||
const [filterDepartment, setFilterDepartment] = useState("all");
|
||||
@@ -40,6 +39,9 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isVendor = currentUser?.user_role === 'vendor' || currentUser?.role === 'vendor';
|
||||
const canAssignStaff = isVendor;
|
||||
|
||||
const { data: allStaff, isLoading } = useQuery({
|
||||
queryKey: ['staff'],
|
||||
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 uniqueHubs = [...new Set(allStaff.map(s => s.hub_location).filter(Boolean))];
|
||||
|
||||
const remainingSlots = requestedCount - assignedStaff.length;
|
||||
const isFull = assignedStaff.length >= requestedCount && requestedCount > 0;
|
||||
const remainingSlots = requestedCount > 0 ? requestedCount - assignedStaff.length : Infinity;
|
||||
const isFull = requestedCount > 0 && assignedStaff.length >= requestedCount;
|
||||
|
||||
// Get available (unassigned) staff
|
||||
const availableStaff = allStaff.filter(staff =>
|
||||
@@ -167,10 +169,10 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (isFull && requestedCount > 0) {
|
||||
if (requestedCount > 0 && assignedStaff.length >= requestedCount) {
|
||||
toast({
|
||||
title: "Event Fully Staffed",
|
||||
description: `All ${requestedCount} positions are filled. Cannot select more staff.`,
|
||||
title: "Assignment Limit Reached",
|
||||
description: `All ${requestedCount} positions are filled. Cannot select more.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
@@ -190,10 +192,11 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
};
|
||||
|
||||
const handleAddStaff = async (staff) => {
|
||||
if (isFull && requestedCount > 0) {
|
||||
// Strictly enforce the requested count
|
||||
if (requestedCount > 0 && assignedStaff.length >= requestedCount) {
|
||||
toast({
|
||||
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"
|
||||
});
|
||||
return;
|
||||
@@ -234,8 +237,8 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
toast({
|
||||
title: "Event Fully Staffed",
|
||||
description: `All ${requestedCount} positions are filled. Cannot assign more staff.`,
|
||||
title: "Assignment Limit Reached",
|
||||
description: `All ${requestedCount} positions are filled. Cannot assign more.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
@@ -366,7 +369,12 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
<CardTitle className="text-slate-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#0A39DF]" />
|
||||
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"}>
|
||||
{assignedStaff.length} / {requestedCount}
|
||||
{isFull && " ✓ Full"}
|
||||
@@ -374,7 +382,7 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{assignedStaff.length > 0 && (
|
||||
{canAssignStaff && assignedStaff.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -386,21 +394,22 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
Notify All
|
||||
</Button>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white"
|
||||
disabled={isFull && requestedCount > 0}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{isFull && requestedCount > 0
|
||||
? "Event Fully Staffed"
|
||||
: remainingSlots > 0
|
||||
? `Add Staff (${remainingSlots} needed)`
|
||||
: "Add Staff"
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{canAssignStaff && (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white"
|
||||
disabled={isFull && requestedCount > 0}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{isFull && requestedCount > 0
|
||||
? "Event Fully Staffed"
|
||||
: remainingSlots > 0
|
||||
? `Add Staff (${remainingSlots} needed)`
|
||||
: "Add Staff"
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[500px] p-0" align="end">
|
||||
<div className="p-4 border-b border-slate-200 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -556,7 +565,7 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -617,7 +626,7 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!staff.notified && (
|
||||
{canAssignStaff && !staff.notified && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -629,33 +638,43 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
Notify
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={staff.confirmed ? "default" : "outline"}
|
||||
onClick={() => handleToggleConfirmation(staff.staff_id)}
|
||||
className={staff.confirmed ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{staff.confirmed ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />
|
||||
Confirmed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
Pending
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{canAssignStaff && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={staff.confirmed ? "default" : "outline"}
|
||||
onClick={() => handleToggleConfirmation(staff.staff_id)}
|
||||
className={staff.confirmed ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{staff.confirmed ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />
|
||||
Confirmed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
Pending
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!canAssignStaff && staff.confirmed && (
|
||||
<Badge className="bg-green-600 text-white">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Confirmed
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveStaff(staff.staff_id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
{canAssignStaff && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveStaff(staff.staff_id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
175
frontend-web/src/components/invoices/AutoInvoiceGenerator.jsx
Normal file
175
frontend-web/src/components/invoices/AutoInvoiceGenerator.jsx
Normal 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
|
||||
}
|
||||
200
frontend-web/src/components/invoices/CreateInvoiceModal.jsx
Normal file
200
frontend-web/src/components/invoices/CreateInvoiceModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
444
frontend-web/src/components/invoices/InvoiceDetailModal.jsx
Normal file
444
frontend-web/src/components/invoices/InvoiceDetailModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
390
frontend-web/src/components/invoices/InvoiceDetailView.jsx
Normal file
390
frontend-web/src/components/invoices/InvoiceDetailView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -16,10 +15,12 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
ArrowRight,
|
||||
MoreVertical
|
||||
MoreVertical,
|
||||
CheckSquare,
|
||||
Package
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { formatDistanceToNow, format, isToday, isYesterday, isThisWeek, startOfDay } from "date-fns";
|
||||
|
||||
const iconMap = {
|
||||
calendar: Calendar,
|
||||
@@ -41,6 +42,7 @@ const colorMap = {
|
||||
export default function NotificationPanel({ isOpen, onClose }) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-notifications'],
|
||||
@@ -126,15 +128,96 @@ export default function NotificationPanel({ isOpen, onClose }) {
|
||||
},
|
||||
});
|
||||
|
||||
const newNotifications = notifications.filter(n => !n.is_read);
|
||||
const olderNotifications = notifications.filter(n => n.is_read);
|
||||
// Categorize by type
|
||||
const categorizeByType = (notif) => {
|
||||
const type = notif.activity_type || '';
|
||||
const title = (notif.title || '').toLowerCase();
|
||||
|
||||
if (type.includes('message') || title.includes('message') || title.includes('comment') || title.includes('mentioned')) {
|
||||
return 'mentions';
|
||||
} else if (type.includes('staff_assigned') || type.includes('user') || title.includes('invited') || title.includes('followed')) {
|
||||
return 'invites';
|
||||
} else {
|
||||
return 'all';
|
||||
}
|
||||
};
|
||||
|
||||
// Filter notifications based on active filter
|
||||
const filteredNotifications = notifications.filter(notif => {
|
||||
if (activeFilter === 'all') return true;
|
||||
return categorizeByType(notif) === activeFilter;
|
||||
});
|
||||
|
||||
// Group by day
|
||||
const groupByDay = (notifList) => {
|
||||
const groups = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
thisWeek: [],
|
||||
older: []
|
||||
};
|
||||
|
||||
notifList.forEach(notif => {
|
||||
const date = new Date(notif.created_date);
|
||||
if (isToday(date)) {
|
||||
groups.today.push(notif);
|
||||
} else if (isYesterday(date)) {
|
||||
groups.yesterday.push(notif);
|
||||
} else if (isThisWeek(date)) {
|
||||
groups.thisWeek.push(notif);
|
||||
} else {
|
||||
groups.older.push(notif);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const groupedNotifications = groupByDay(filteredNotifications);
|
||||
|
||||
// Count by type
|
||||
const allCount = notifications.length;
|
||||
const mentionsCount = notifications.filter(n => categorizeByType(n) === 'mentions').length;
|
||||
const invitesCount = notifications.filter(n => categorizeByType(n) === 'invites').length;
|
||||
|
||||
const handleAction = (notification) => {
|
||||
if (notification.action_link) {
|
||||
navigate(createPageUrl(notification.action_link));
|
||||
// Mark as read when clicking
|
||||
if (!notification.is_read) {
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
onClose();
|
||||
}
|
||||
|
||||
const entityType = notification.related_entity_type;
|
||||
const entityId = notification.related_entity_id;
|
||||
const activityType = notification.activity_type || '';
|
||||
|
||||
// Route based on entity type
|
||||
if (entityType === 'event' || activityType.includes('event') || activityType.includes('order')) {
|
||||
if (entityId) {
|
||||
navigate(createPageUrl(`EventDetail?id=${entityId}`));
|
||||
} else {
|
||||
navigate(createPageUrl('Events'));
|
||||
}
|
||||
} else if (entityType === 'task' || activityType.includes('task')) {
|
||||
navigate(createPageUrl('TaskBoard'));
|
||||
} else if (entityType === 'invoice' || activityType.includes('invoice')) {
|
||||
if (entityId) {
|
||||
navigate(createPageUrl(`Invoices?id=${entityId}`));
|
||||
} else {
|
||||
navigate(createPageUrl('Invoices'));
|
||||
}
|
||||
} else if (entityType === 'staff' || activityType.includes('staff')) {
|
||||
if (entityId) {
|
||||
navigate(createPageUrl(`EditStaff?id=${entityId}`));
|
||||
} else {
|
||||
navigate(createPageUrl('StaffDirectory'));
|
||||
}
|
||||
} else if (entityType === 'message' || activityType.includes('message')) {
|
||||
navigate(createPageUrl('Messages'));
|
||||
} else if (notification.action_link) {
|
||||
navigate(createPageUrl(notification.action_link));
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -159,133 +242,376 @@ export default function NotificationPanel({ isOpen, onClose }) {
|
||||
className="fixed right-0 top-0 h-full w-full sm:w-[440px] bg-white shadow-2xl z-50 flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-6 h-6 text-[#1C323E]" />
|
||||
<h2 className="text-xl font-bold text-[#1C323E]">Notifications</h2>
|
||||
</div>
|
||||
<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 className="border-b border-slate-200">
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-6 h-6 text-[#1C323E]" />
|
||||
<h2 className="text-xl font-bold text-[#1C323E]">Notifications</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{newNotifications.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 && (
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<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" />
|
||||
<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>
|
||||
</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>
|
||||
</motion.div>
|
||||
|
||||
161
frontend-web/src/components/orders/CancellationFeeModal.jsx
Normal file
161
frontend-web/src/components/orders/CancellationFeeModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
336
frontend-web/src/components/orders/OrderDetailModal.jsx
Normal file
336
frontend-web/src/components/orders/OrderDetailModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
frontend-web/src/components/orders/OrderReductionAlert.jsx
Normal file
109
frontend-web/src/components/orders/OrderReductionAlert.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend-web/src/components/orders/OrderStatusUtils.jsx
Normal file
37
frontend-web/src/components/orders/OrderStatusUtils.jsx
Normal 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";
|
||||
}
|
||||
276
frontend-web/src/components/orders/RapidOrderInterface.jsx
Normal file
276
frontend-web/src/components/orders/RapidOrderInterface.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '' };
|
||||
};
|
||||
@@ -5,6 +5,11 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Calendar, Clock, MapPin, Star } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { validateWorkerAssignment } from "./DoubleBookingValidator";
|
||||
import DoubleBookingOverrideDialog from "./DoubleBookingOverrideDialog";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
|
||||
/**
|
||||
* Drag & Drop Scheduler Widget
|
||||
@@ -14,6 +19,21 @@ import { format } from "date-fns";
|
||||
export default function DragDropScheduler({ events, staff, onAssign, onUnassign }) {
|
||||
const [localEvents, setLocalEvents] = useState(events || []);
|
||||
const [localStaff, setLocalStaff] = useState(staff || []);
|
||||
const [overrideDialog, setOverrideDialog] = useState({ open: false, conflict: null, staffMember: null, eventId: null });
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-scheduler'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: allEvents = [] } = useQuery({
|
||||
queryKey: ['all-events-conflict-check'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: events,
|
||||
});
|
||||
|
||||
const userRole = user?.user_role || user?.role || 'admin';
|
||||
|
||||
const handleDragEnd = (result) => {
|
||||
const { source, destination, draggableId } = result;
|
||||
@@ -24,6 +44,39 @@ export default function DragDropScheduler({ events, staff, onAssign, onUnassign
|
||||
if (source.droppableId === "unassigned" && destination.droppableId.startsWith("event-")) {
|
||||
const eventId = destination.droppableId.replace("event-", "");
|
||||
const staffMember = localStaff.find(s => s.id === draggableId);
|
||||
const targetEvent = localEvents.find(e => e.id === eventId);
|
||||
|
||||
if (!staffMember || !targetEvent) return;
|
||||
|
||||
// Validate double booking
|
||||
const targetShift = targetEvent.shifts?.[0] || {};
|
||||
const validation = validateWorkerAssignment(
|
||||
staffMember.id,
|
||||
targetEvent,
|
||||
targetShift,
|
||||
allEvents,
|
||||
userRole
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
if (validation.conflict?.canOverride) {
|
||||
// Show override dialog for vendors
|
||||
setOverrideDialog({
|
||||
open: true,
|
||||
conflict: validation.conflict,
|
||||
staffMember,
|
||||
eventId
|
||||
});
|
||||
} else {
|
||||
// Hard block
|
||||
toast({
|
||||
title: "❌ Assignment Blocked",
|
||||
description: validation.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (staffMember && onAssign) {
|
||||
onAssign(eventId, staffMember);
|
||||
@@ -106,8 +159,34 @@ export default function DragDropScheduler({ events, staff, onAssign, onUnassign
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverrideConfirm = () => {
|
||||
const { staffMember, eventId } = overrideDialog;
|
||||
|
||||
// Proceed with assignment
|
||||
setLocalStaff(localStaff.filter(s => s.id !== staffMember.id));
|
||||
|
||||
setLocalEvents(localEvents.map(event => {
|
||||
if (event.id === eventId) {
|
||||
return {
|
||||
...event,
|
||||
assigned_staff: [...(event.assigned_staff || []), { staff_id: staffMember.id, staff_name: staffMember.employee_name }]
|
||||
};
|
||||
}
|
||||
return event;
|
||||
}));
|
||||
|
||||
onAssign(eventId, staffMember);
|
||||
setOverrideDialog({ open: false, conflict: null, staffMember: null, eventId: null });
|
||||
|
||||
toast({
|
||||
title: "✅ Double Shift Assigned",
|
||||
description: `${staffMember.employee_name} has been assigned with vendor override`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<>
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Unassigned Staff Pool */}
|
||||
<Card className="lg:col-span-1">
|
||||
@@ -251,5 +330,14 @@ export default function DragDropScheduler({ events, staff, onAssign, onUnassign
|
||||
</div>
|
||||
</div>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
221
frontend-web/src/components/scheduling/OvertimeCalculator.jsx
Normal file
221
frontend-web/src/components/scheduling/OvertimeCalculator.jsx
Normal 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,18 +18,28 @@ const progressColor = (progress) => {
|
||||
return "bg-slate-400";
|
||||
};
|
||||
|
||||
export default function TaskCard({ task, provided, onClick }) {
|
||||
export default function TaskCard({ task, provided, onClick, itemHeight = "normal", conditionalColoring = true }) {
|
||||
const heightClasses = {
|
||||
compact: "p-2",
|
||||
normal: "p-4",
|
||||
comfortable: "p-5"
|
||||
};
|
||||
|
||||
const cardHeight = heightClasses[itemHeight] || heightClasses.normal;
|
||||
const priority = priorityConfig[task.priority] || priorityConfig.normal;
|
||||
|
||||
const priorityBorder = conditionalColoring && task.priority === 'high' ? 'border-l-4 border-l-red-500' : '';
|
||||
const priorityBg = conditionalColoring && task.priority === 'high' ? 'bg-red-50/50' : 'bg-white';
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={provided?.innerRef}
|
||||
{...provided?.draggableProps}
|
||||
{...provided?.dragHandleProps}
|
||||
onClick={onClick}
|
||||
className="bg-white border border-slate-200 hover:shadow-md transition-all cursor-pointer mb-3"
|
||||
className={`${priorityBg} border border-slate-200 ${priorityBorder} hover:shadow-md transition-all cursor-pointer mb-3`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className={cardHeight}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h4 className="font-semibold text-slate-900 text-sm flex-1">{task.task_name}</h4>
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
```
|
||||
@@ -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);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"type":"module"}
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"type":"module"}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -25,17 +25,23 @@ const COLORS = ['#0A39DF', '#6366f1', '#8b5cf6', '#a855f7', '#c026d3', '#d946ef'
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24 || time24 === "—") return time24;
|
||||
|
||||
try {
|
||||
const parts = time24.split(':');
|
||||
if (!parts || parts.length < 2) return time24;
|
||||
|
||||
const hours = parseInt(parts[0], 10);
|
||||
const minutes = parseInt(parts[1], 10);
|
||||
|
||||
if (isNaN(hours) || isNaN(minutes)) return time24;
|
||||
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const hours12 = hours % 12 || 12;
|
||||
const minutesStr = minutes.toString().padStart(2, '0');
|
||||
|
||||
return `${hours12}:${minutesStr} ${period}`;
|
||||
} catch (error) {
|
||||
console.error('Error converting time:', error);
|
||||
return time24;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -24,10 +23,11 @@ import {
|
||||
} from "@/components/ui/tabs"; // New import
|
||||
import {
|
||||
Search, Calendar, MapPin, Users, Eye, Edit, X, Trash2, FileText, // Edit instead of Edit2
|
||||
Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus
|
||||
Clock, DollarSign, Package, CheckCircle, AlertTriangle, Grid, List, Zap, Plus, Building2, Bell, Edit3
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
import OrderDetailModal from "@/components/orders/OrderDetailModal";
|
||||
|
||||
const safeParseDate = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
@@ -94,6 +94,8 @@ export default function ClientOrders() {
|
||||
const [statusFilter, setStatusFilter] = useState("all"); // Updated values for Tabs
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false); // Changed from cancelDialog.open
|
||||
const [orderToCancel, setOrderToCancel] = useState(null); // Changed from cancelDialog.order
|
||||
const [viewOrderModal, setViewOrderModal] = useState(false);
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-client-orders'],
|
||||
@@ -180,6 +182,11 @@ export default function ClientOrders() {
|
||||
setCancelDialogOpen(true); // Updated
|
||||
};
|
||||
|
||||
const handleViewOrder = (order) => {
|
||||
setSelectedOrder(order);
|
||||
setViewOrderModal(true);
|
||||
};
|
||||
|
||||
const confirmCancel = () => {
|
||||
if (orderToCancel) { // Updated
|
||||
cancelOrderMutation.mutate(orderToCancel.id); // Updated
|
||||
@@ -332,118 +339,115 @@ export default function ClientOrders() {
|
||||
<CardContent className="p-0"> {/* CardContent padding updated */}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50"> {/* TableRow class updated */}
|
||||
<TableHead className="font-semibold text-slate-700">Order</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700">Date</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700">Location</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700">Time</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700">Status</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700 text-center">Staff</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700 text-center">Invoice</TableHead> {/* Updated */}
|
||||
<TableHead className="font-semibold text-slate-700 text-center">Actions</TableHead> {/* Updated */}
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50">
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Business</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Hub</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Event</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Date & Time</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Status</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Requested</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700 text-xs uppercase">Assigned</TableHead>
|
||||
<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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredOrders.length === 0 ? ( // Using filteredOrders
|
||||
{filteredOrders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-12 text-slate-500"> {/* Colspan updated */}
|
||||
<Package className="w-12 h-12 mx-auto mb-3 text-slate-300" /> {/* Icon updated */}
|
||||
<TableCell colSpan={9} className="text-center py-12 text-slate-500">
|
||||
<Package className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="font-medium">No orders found</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredOrders.map((order) => { // Using filteredOrders, renamed event to order
|
||||
const assignment = getAssignmentStatus(order);
|
||||
filteredOrders.map((order) => {
|
||||
const assignedCount = order.assigned_staff?.length || 0;
|
||||
const requestedCount = order.requested || 0;
|
||||
const assignmentProgress = requestedCount > 0 ? Math.round((assignedCount / requestedCount) * 100) : 0;
|
||||
const { startTime, endTime } = getEventTimes(order);
|
||||
const invoiceReady = order.status === "Completed";
|
||||
// const eventDate = safeParseDate(order.date); // Not directly used here, safeFormatDate handles it.
|
||||
|
||||
return (
|
||||
<TableRow key={order.id} className="hover:bg-slate-50">
|
||||
<TableCell> {/* Order cell */}
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{order.event_name}</p>
|
||||
<p className="text-xs text-slate-500">{order.business_name || "—"}</p>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-slate-900">{order.business_name || "Primary Location"}</span>
|
||||
</div>
|
||||
</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>
|
||||
<p className="font-semibold text-slate-900">
|
||||
{safeFormatDate(order.date, 'MMM dd, yyyy')}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{safeFormatDate(order.date, 'EEEE')}
|
||||
<p className="font-medium text-slate-900">{safeFormatDate(order.date, 'MM.dd.yyyy')}</p>
|
||||
<p className="text-xs text-slate-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{startTime} - {endTime}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell> {/* Location cell */}
|
||||
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400" />
|
||||
{order.hub || order.event_location || "—"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell> {/* Time cell */}
|
||||
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||
<Clock className="w-3.5 h-3.5 text-slate-400" />
|
||||
{startTime} - {endTime}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell> {/* Status cell */}
|
||||
<TableCell>
|
||||
{getStatusBadge(order)}
|
||||
</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">
|
||||
<Badge className={assignment.badgeClass}>
|
||||
{assignment.assigned} / {assignment.requested}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-slate-500 font-medium">
|
||||
{assignment.percentage}%
|
||||
</span>
|
||||
<div className="w-10 h-10 bg-emerald-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">{assignedCount}</span>
|
||||
</div>
|
||||
<span className="text-xs text-emerald-600 font-semibold">{assignmentProgress}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center"> {/* Invoice cell */}
|
||||
<div className="flex items-center justify-center">
|
||||
<Button // Changed from a div to a Button for better accessibility
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => invoiceReady && navigate(createPageUrl('Invoices'))}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${invoiceReady ? 'bg-blue-100' : 'bg-slate-100'} ${invoiceReady ? 'cursor-pointer hover:bg-blue-200' : 'cursor-not-allowed opacity-50'}`}
|
||||
disabled={!invoiceReady}
|
||||
title={invoiceReady ? "View Invoice" : "Invoice not available"}
|
||||
>
|
||||
<FileText className={`w-5 h-5 ${invoiceReady ? 'text-blue-600' : 'text-slate-400'}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<TableCell>
|
||||
<button className="w-8 h-8 flex items-center justify-center hover:bg-slate-100 rounded transition-colors">
|
||||
<FileText className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell> {/* Actions cell */}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl(`EventDetail?id=${order.id}`))}
|
||||
className="hover:bg-slate-100"
|
||||
title="View details"
|
||||
onClick={() => handleViewOrder(order)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="View"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 p-0"
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
</Button>
|
||||
{canEditOrder(order) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(createPageUrl(`EditEvent?id=${order.id}`))}
|
||||
className="hover:bg-slate-100"
|
||||
title="Edit order"
|
||||
className="h-8 w-8 p-0"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" /> {/* Changed from Edit2 */}
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canCancelOrder(order) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleCancelOrder(order)} // Updated
|
||||
className="hover:bg-red-50 hover:text-red-600"
|
||||
title="Cancel order"
|
||||
onClick={() => handleCancelOrder(order)}
|
||||
className="h-8 w-8 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -460,6 +464,13 @@ export default function ClientOrders() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<OrderDetailModal
|
||||
open={viewOrderModal}
|
||||
onClose={() => setViewOrderModal(false)}
|
||||
order={selectedOrder}
|
||||
onCancel={handleCancelOrder}
|
||||
/>
|
||||
|
||||
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}> {/* Updated open and onOpenChange */}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import EventFormWizard from "@/components/events/EventFormWizard";
|
||||
import RapidOrderInterface from "@/components/orders/RapidOrderInterface";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, AlertTriangle } from "lucide-react";
|
||||
@@ -16,6 +17,7 @@ export default function CreateEvent() {
|
||||
const { toast } = useToast();
|
||||
const [pendingEvent, setPendingEvent] = React.useState(null);
|
||||
const [showConflictWarning, setShowConflictWarning] = React.useState(false);
|
||||
const [showRapidInterface, setShowRapidInterface] = React.useState(false);
|
||||
|
||||
const { data: currentUser } = useQuery({
|
||||
queryKey: ['current-user-create-event'],
|
||||
@@ -48,15 +50,56 @@ export default function CreateEvent() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleRapidSubmit = (rapidData) => {
|
||||
// Convert rapid order message to event data
|
||||
const eventData = {
|
||||
event_name: "RAPID Order",
|
||||
order_type: "rapid",
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
status: "Active",
|
||||
notes: rapidData.rawMessage,
|
||||
shifts: [{
|
||||
shift_name: "Shift 1",
|
||||
location_address: "",
|
||||
same_as_billing: true,
|
||||
roles: [{
|
||||
role: "",
|
||||
department: "",
|
||||
count: 1,
|
||||
start_time: "09:00",
|
||||
end_time: "17:00",
|
||||
hours: 8,
|
||||
uniform: "Type 1",
|
||||
break_minutes: 15,
|
||||
rate_per_hour: 0,
|
||||
total_value: 0
|
||||
}]
|
||||
}],
|
||||
requested: 1
|
||||
};
|
||||
|
||||
createEventMutation.mutate(eventData);
|
||||
};
|
||||
|
||||
const handleSubmit = (eventData) => {
|
||||
// CRITICAL: Calculate total requested count from all roles before creating
|
||||
const totalRequested = eventData.shifts.reduce((sum, shift) => {
|
||||
return sum + shift.roles.reduce((roleSum, role) => roleSum + (parseInt(role.count) || 0), 0);
|
||||
}, 0);
|
||||
|
||||
const eventDataWithRequested = {
|
||||
...eventData,
|
||||
requested: totalRequested // Set exact requested count
|
||||
};
|
||||
|
||||
// Detect conflicts before creating
|
||||
const conflicts = detectAllConflicts(eventData, allEvents);
|
||||
const conflicts = detectAllConflicts(eventDataWithRequested, allEvents);
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
setPendingEvent({ ...eventData, detected_conflicts: conflicts });
|
||||
setPendingEvent({ ...eventDataWithRequested, detected_conflicts: conflicts });
|
||||
setShowConflictWarning(true);
|
||||
} else {
|
||||
createEventMutation.mutate(eventData);
|
||||
createEventMutation.mutate(eventDataWithRequested);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,6 +180,7 @@ export default function CreateEvent() {
|
||||
<EventFormWizard
|
||||
event={null}
|
||||
onSubmit={handleSubmit}
|
||||
onRapidSubmit={handleRapidSubmit}
|
||||
isSubmitting={createEventMutation.isPending}
|
||||
currentUser={currentUser}
|
||||
onCancel={() => navigate(createPageUrl("ClientDashboard"))}
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import EventForm from "@/components/events/EventForm";
|
||||
import EventFormWizard from "@/components/events/EventFormWizard";
|
||||
import OrderReductionAlert from "@/components/orders/OrderReductionAlert";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function EditEvent() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const eventId = urlParams.get('id');
|
||||
|
||||
const [showReductionAlert, setShowReductionAlert] = useState(false);
|
||||
const [pendingUpdate, setPendingUpdate] = useState(null);
|
||||
const [originalRequested, setOriginalRequested] = useState(0);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-edit-event'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: allEvents, isLoading } = useQuery({
|
||||
queryKey: ['events'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-reduction'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const event = allEvents.find(e => e.id === eventId);
|
||||
|
||||
useEffect(() => {
|
||||
if (event) {
|
||||
setOriginalRequested(event.requested || 0);
|
||||
}
|
||||
}, [event]);
|
||||
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
|
||||
onSuccess: () => {
|
||||
@@ -30,7 +54,97 @@ export default function EditEvent() {
|
||||
});
|
||||
|
||||
const handleSubmit = (eventData) => {
|
||||
updateEventMutation.mutate({ id: eventId, data: eventData });
|
||||
// CRITICAL: Recalculate requested count from current roles
|
||||
const totalRequested = eventData.shifts.reduce((sum, shift) => {
|
||||
return sum + shift.roles.reduce((roleSum, role) => roleSum + (parseInt(role.count) || 0), 0);
|
||||
}, 0);
|
||||
|
||||
const assignedCount = event.assigned_staff?.length || 0;
|
||||
const isVendor = user?.user_role === 'vendor' || user?.role === 'vendor';
|
||||
|
||||
// If client is reducing headcount and vendor has already assigned staff
|
||||
if (!isVendor && totalRequested < originalRequested && assignedCount > totalRequested) {
|
||||
setPendingUpdate({ ...eventData, requested: totalRequested });
|
||||
setShowReductionAlert(true);
|
||||
|
||||
// Notify vendor via email
|
||||
if (event.vendor_name) {
|
||||
base44.integrations.Core.SendEmail({
|
||||
to: `${event.vendor_name}@example.com`,
|
||||
subject: `⚠️ Order Reduced: ${event.event_name}`,
|
||||
body: `Client has reduced headcount for order: ${event.event_name}\n\nOriginal: ${originalRequested} staff\nNew: ${totalRequested} staff\nCurrently Assigned: ${assignedCount} staff\n\nExcess: ${assignedCount - totalRequested} staff must be removed.\n\nPlease log in to adjust assignments.`
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "⚠️ Headcount Reduced",
|
||||
description: "Vendor has been notified to adjust staff assignments",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal update
|
||||
updateEventMutation.mutate({
|
||||
id: eventId,
|
||||
data: {
|
||||
...eventData,
|
||||
requested: totalRequested
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutoUnassign = async () => {
|
||||
if (!pendingUpdate) return;
|
||||
|
||||
const assignedStaff = event.assigned_staff || [];
|
||||
const excessCount = assignedStaff.length - pendingUpdate.requested;
|
||||
|
||||
// Calculate reliability scores for assigned staff
|
||||
const staffWithScores = assignedStaff.map(assigned => {
|
||||
const staffData = allStaff.find(s => s.id === assigned.staff_id);
|
||||
return {
|
||||
...assigned,
|
||||
reliability: staffData?.reliability_score || 50,
|
||||
total_shifts: staffData?.total_shifts || 0,
|
||||
no_shows: staffData?.no_show_count || 0,
|
||||
cancellations: staffData?.cancellation_count || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by reliability (lowest first)
|
||||
staffWithScores.sort((a, b) => a.reliability - b.reliability);
|
||||
|
||||
// Remove lowest reliability staff
|
||||
const staffToKeep = staffWithScores.slice(excessCount);
|
||||
|
||||
await updateEventMutation.mutateAsync({
|
||||
id: eventId,
|
||||
data: {
|
||||
...pendingUpdate,
|
||||
assigned_staff: staffToKeep.map(s => ({
|
||||
staff_id: s.staff_id,
|
||||
staff_name: s.staff_name,
|
||||
email: s.email,
|
||||
role: s.role
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
setShowReductionAlert(false);
|
||||
setPendingUpdate(null);
|
||||
|
||||
toast({
|
||||
title: "✅ Staff Auto-Unassigned",
|
||||
description: `Removed ${excessCount} lowest reliability staff members`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleManualUnassign = () => {
|
||||
setShowReductionAlert(false);
|
||||
toast({
|
||||
title: "Manual Adjustment Required",
|
||||
description: "Please manually remove excess staff from the order",
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@@ -54,7 +168,7 @@ export default function EditEvent() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -68,10 +182,31 @@ export default function EditEvent() {
|
||||
<p className="text-slate-600">Update information for {event.event_name}</p>
|
||||
</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}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={updateEventMutation.isPending}
|
||||
currentUser={user}
|
||||
onCancel={() => navigate(createPageUrl("Events"))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { ArrowLeft, Calendar, MapPin, Users, DollarSign, Send, Edit3, X, AlertTriangle } from "lucide-react";
|
||||
import ShiftCard from "@/components/events/ShiftCard";
|
||||
import OrderStatusBadge from "@/components/orders/OrderStatusBadge";
|
||||
import CancellationFeeModal from "@/components/orders/CancellationFeeModal";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format } from "date-fns";
|
||||
|
||||
@@ -35,6 +36,7 @@ export default function EventDetail() {
|
||||
const { toast } = useToast();
|
||||
const [notifyDialog, setNotifyDialog] = useState(false);
|
||||
const [cancelDialog, setCancelDialog] = useState(false);
|
||||
const [showCancellationFeeModal, setShowCancellationFeeModal] = useState(false);
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const eventId = urlParams.get("id");
|
||||
@@ -58,11 +60,21 @@ export default function EventDetail() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['all-events-client'] });
|
||||
|
||||
// Notify vendor
|
||||
if (event.vendor_name && event.vendor_id) {
|
||||
base44.integrations.Core.SendEmail({
|
||||
to: `${event.vendor_name}@example.com`,
|
||||
subject: `Order Canceled: ${event.event_name}`,
|
||||
body: `Client has canceled order: ${event.event_name}\nDate: ${event.date}\nLocation: ${event.hub || event.event_location}`
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "✅ Order Canceled",
|
||||
description: "Your order has been canceled successfully",
|
||||
});
|
||||
setCancelDialog(false);
|
||||
setShowCancellationFeeModal(false);
|
||||
navigate(createPageUrl("ClientOrders"));
|
||||
},
|
||||
onError: () => {
|
||||
@@ -74,6 +86,14 @@ export default function EventDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancelClick = () => {
|
||||
setShowCancellationFeeModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmCancellation = () => {
|
||||
cancelOrderMutation.mutate();
|
||||
};
|
||||
|
||||
const handleNotifyStaff = async () => {
|
||||
const assignedStaff = event?.assigned_staff || [];
|
||||
|
||||
@@ -171,11 +191,11 @@ export default function EventDetail() {
|
||||
)}
|
||||
{canCancelOrder() && (
|
||||
<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"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
cancel
|
||||
Cancel Order
|
||||
</button>
|
||||
)}
|
||||
{!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>
|
||||
{eventShifts.length > 0 ? (
|
||||
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">
|
||||
@@ -316,48 +336,14 @@ export default function EventDetail() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Cancel Order Dialog */}
|
||||
<Dialog open={cancelDialog} onOpenChange={setCancelDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Cancel Order?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to cancel this order? This action cannot be undone and the vendor will be notified immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
|
||||
<p className="font-bold text-slate-900">{event.event_name}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{safeFormatDate(event.date)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{event.hub || event.event_location}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCancelDialog(false)}
|
||||
>
|
||||
Keep Order
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => cancelOrderMutation.mutate()}
|
||||
disabled={cancelOrderMutation.isPending}
|
||||
>
|
||||
{cancelOrderMutation.isPending ? "Canceling..." : "Yes, Cancel Order"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Cancellation Fee Modal */}
|
||||
<CancellationFeeModal
|
||||
open={showCancellationFeeModal}
|
||||
onClose={() => setShowCancellationFeeModal(false)}
|
||||
onConfirm={handleConfirmCancellation}
|
||||
event={event}
|
||||
isSubmitting={cancelOrderMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
@@ -444,18 +443,32 @@ export default function Events() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 mb-6 flex items-center gap-4 border shadow-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input placeholder="Search by event, business, or location..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10 border-slate-200 h-10" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant={viewMode === "table" ? "default" : "outline"} size="sm" onClick={() => setViewMode("table")} className={viewMode === "table" ? "bg-[#0A39DF]" : ""}>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant={viewMode === "scheduler" ? "default" : "outline"} size="sm" onClick={() => setViewMode("scheduler")} className={viewMode === "scheduler" ? "bg-[#0A39DF]" : ""}>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="bg-white rounded-xl p-4 mb-6 border-2 shadow-md">
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<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>
|
||||
<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">
|
||||
<Button
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="lg"
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`${viewMode === "table" ? "bg-blue-600 text-white hover:bg-blue-700 shadow-lg" : "hover:bg-white/50"} h-11 px-6 font-semibold cursor-pointer`}
|
||||
>
|
||||
<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>
|
||||
|
||||
|
||||
68
frontend-web/src/pages/InvoiceDetail.jsx
Normal file
68
frontend-web/src/pages/InvoiceDetail.jsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
869
frontend-web/src/pages/InvoiceEditor.jsx
Normal file
869
frontend-web/src/pages/InvoiceEditor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,38 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { FileText, Plus, DollarSign, Search, Eye, Download } from "lucide-react";
|
||||
import { FileText, Plus, Search, Eye, AlertTriangle, CheckCircle, Clock, DollarSign, Edit } from "lucide-react";
|
||||
import { format, parseISO, isPast } from "date-fns";
|
||||
import PageHeader from "@/components/common/PageHeader";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import AutoInvoiceGenerator from "@/components/invoices/AutoInvoiceGenerator";
|
||||
import CreateInvoiceModal from "@/components/invoices/CreateInvoiceModal";
|
||||
|
||||
const statusColors = {
|
||||
'Open': 'bg-orange-500 text-white',
|
||||
'Confirmed': 'bg-purple-500 text-white',
|
||||
'Overdue': 'bg-red-500 text-white',
|
||||
'Draft': 'bg-slate-500 text-white',
|
||||
'Pending Review': 'bg-amber-500 text-white',
|
||||
'Approved': 'bg-green-500 text-white',
|
||||
'Disputed': 'bg-red-500 text-white',
|
||||
'Under Review': 'bg-orange-500 text-white',
|
||||
'Resolved': 'bg-blue-500 text-white',
|
||||
'Paid': 'bg-green-500 text-white',
|
||||
'Reconciled': 'bg-amber-600 text-white', // Changed from bg-yellow-600
|
||||
'Disputed': 'bg-gray-500 text-white',
|
||||
'Verified': 'bg-teal-500 text-white',
|
||||
'Pending': 'bg-amber-500 text-white',
|
||||
'Overdue': 'bg-red-600 text-white',
|
||||
'Paid': 'bg-emerald-500 text-white',
|
||||
'Reconciled': 'bg-purple-500 text-white',
|
||||
'Cancelled': 'bg-slate-400 text-white',
|
||||
};
|
||||
|
||||
export default function Invoices() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [paymentMethod, setPaymentMethod] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-invoices'],
|
||||
@@ -57,6 +47,19 @@ export default function Invoices() {
|
||||
|
||||
const userRole = user?.user_role || user?.role;
|
||||
|
||||
// Auto-mark overdue invoices
|
||||
React.useEffect(() => {
|
||||
invoices.forEach(async (invoice) => {
|
||||
if (invoice.status === "Approved" && isPast(parseISO(invoice.due_date))) {
|
||||
try {
|
||||
await base44.entities.Invoice.update(invoice.id, { status: "Overdue" });
|
||||
} catch (error) {
|
||||
console.error('Failed to mark invoice as overdue:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [invoices]);
|
||||
|
||||
// Filter invoices based on user role
|
||||
const visibleInvoices = React.useMemo(() => {
|
||||
if (userRole === "client") {
|
||||
@@ -67,39 +70,29 @@ export default function Invoices() {
|
||||
);
|
||||
}
|
||||
if (userRole === "vendor") {
|
||||
return invoices.filter(inv => inv.vendor_name === user?.company_name);
|
||||
return invoices.filter(inv =>
|
||||
inv.vendor_name === user?.company_name ||
|
||||
inv.vendor_id === user?.vendor_id
|
||||
);
|
||||
}
|
||||
// Admin, procurement, operator can see all
|
||||
return invoices;
|
||||
}, [invoices, userRole, user]);
|
||||
|
||||
const updateInvoiceMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Invoice.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
setShowPaymentDialog(false);
|
||||
setSelectedInvoice(null);
|
||||
},
|
||||
});
|
||||
|
||||
const getFilteredInvoices = () => {
|
||||
let filtered = visibleInvoices;
|
||||
|
||||
// Status filter
|
||||
if (activeTab !== "all") {
|
||||
const statusMap = {
|
||||
open: "Open",
|
||||
disputed: "Disputed",
|
||||
resolved: "Resolved",
|
||||
verified: "Verified",
|
||||
overdue: "Overdue",
|
||||
reconciled: "Reconciled",
|
||||
paid: "Paid"
|
||||
'pending': 'Pending Review',
|
||||
'approved': 'Approved',
|
||||
'disputed': 'Disputed',
|
||||
'overdue': 'Overdue',
|
||||
'paid': 'Paid',
|
||||
'reconciled': 'Reconciled',
|
||||
};
|
||||
filtered = filtered.filter(inv => inv.status === statusMap[activeTab]);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(inv =>
|
||||
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
@@ -114,7 +107,6 @@ export default function Invoices() {
|
||||
|
||||
const filteredInvoices = getFilteredInvoices();
|
||||
|
||||
// Calculate metrics
|
||||
const getStatusCount = (status) => {
|
||||
if (status === "all") return visibleInvoices.length;
|
||||
return visibleInvoices.filter(inv => inv.status === status).length;
|
||||
@@ -127,304 +119,233 @@ export default function Invoices() {
|
||||
return filtered.reduce((sum, inv) => sum + (inv.amount || 0), 0);
|
||||
};
|
||||
|
||||
const allTotal = getTotalAmount("all");
|
||||
const openTotal = getTotalAmount("Open");
|
||||
const overdueTotal = getTotalAmount("Overdue");
|
||||
const paidTotal = getTotalAmount("Paid");
|
||||
|
||||
const openPercentage = allTotal > 0 ? ((openTotal / allTotal) * 100).toFixed(1) : 0;
|
||||
const overduePercentage = allTotal > 0 ? ((overdueTotal / allTotal) * 100).toFixed(1) : 0;
|
||||
const paidPercentage = allTotal > 0 ? ((paidTotal / allTotal) * 100).toFixed(1) : 0;
|
||||
|
||||
const handleRecordPayment = () => {
|
||||
if (selectedInvoice && paymentMethod) {
|
||||
updateInvoiceMutation.mutate({
|
||||
id: selectedInvoice.id,
|
||||
data: {
|
||||
...selectedInvoice,
|
||||
status: "Paid",
|
||||
paid_date: new Date().toISOString().split('T')[0],
|
||||
payment_method: paymentMethod
|
||||
}
|
||||
});
|
||||
}
|
||||
const metrics = {
|
||||
all: getTotalAmount("all"),
|
||||
pending: getTotalAmount("Pending Review"),
|
||||
approved: getTotalAmount("Approved"),
|
||||
disputed: getTotalAmount("Disputed"),
|
||||
overdue: getTotalAmount("Overdue"),
|
||||
paid: getTotalAmount("Paid"),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-[1600px] mx-auto">
|
||||
<PageHeader
|
||||
title="Invoices"
|
||||
subtitle={`${filteredInvoices.length} ${filteredInvoices.length === 1 ? 'invoice' : 'invoices'} • $${allTotal.toLocaleString()} total`}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setShowPaymentDialog(true)}
|
||||
variant="outline"
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white border-0 font-semibold" // Changed className
|
||||
>
|
||||
Record Payment
|
||||
</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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<AutoInvoiceGenerator />
|
||||
|
||||
{/* Status Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
|
||||
<TabsList className="bg-white border border-slate-200 h-auto p-1">
|
||||
<TabsTrigger value="all" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white">
|
||||
All Invoices <Badge variant="secondary" className="ml-2">{getStatusCount("all")}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="open">
|
||||
Open <Badge variant="secondary" className="ml-2">{getStatusCount("Open")}</Badge>
|
||||
</TabsTrigger>
|
||||
<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>
|
||||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||||
<div className="max-w-[1600px] mx-auto">
|
||||
<PageHeader
|
||||
title="Invoices"
|
||||
subtitle={`${filteredInvoices.length} invoices • $${metrics.all.toLocaleString()} total`}
|
||||
actions={
|
||||
userRole === "vendor" && (
|
||||
<Button onClick={() => setShowCreateModal(true)} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Create Invoice
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<Card className="bg-white border-slate-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 mb-1">All</p>
|
||||
<p className="text-3xl font-bold text-[#1C323E]">${allTotal.toLocaleString()}</p>
|
||||
{/* Alert Banners */}
|
||||
{metrics.disputed > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 border-l-4 border-red-500 rounded-lg flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<p className="font-semibold text-red-900">Disputed Invoices Require Attention</p>
|
||||
<p className="text-sm text-red-700">{getStatusCount("Disputed")} invoices are currently disputed</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.overdue > 0 && userRole === "client" && (
|
||||
<div className="mb-6 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-lg flex items-center gap-3">
|
||||
<Clock className="w-5 h-5 text-amber-600" />
|
||||
<div>
|
||||
<p className="font-semibold text-amber-900">Overdue Payments</p>
|
||||
<p className="text-sm text-amber-700">${metrics.overdue.toLocaleString()} in overdue invoices</p>
|
||||
</div>
|
||||
</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>
|
||||
<Badge className="bg-[#1C323E] text-white">{getStatusCount("all")} invoices</Badge>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white border-slate-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 mb-1">Open</p>
|
||||
<p className="text-3xl font-bold text-[#1C323E]">${openTotal.toLocaleString()}</p>
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-amber-600" />
|
||||
</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>
|
||||
<Badge className="bg-orange-500 text-white">{getStatusCount("Open")} invoices</Badge>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white border-slate-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 mb-1">Overdue</p>
|
||||
<p className="text-3xl font-bold text-[#1C323E]">${overdueTotal.toLocaleString()}</p>
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</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>
|
||||
<Badge className="bg-red-500 text-white">{getStatusCount("Overdue")} invoices</Badge>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white border-slate-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 mb-1">Paid</p>
|
||||
<p className="text-3xl font-bold text-[#1C323E]">${paidTotal.toLocaleString()}</p>
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
</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>
|
||||
<Badge className="bg-green-500 text-white">{getStatusCount("Paid")} invoices</Badge>
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoices Table */}
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50">
|
||||
<TableHead className="font-semibold text-slate-700">S #</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">Manager Name</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">Hub</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">Invoice ID</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">Cost Center</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">Event</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">Value $</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">Count</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">Payment Status</TableHead>
|
||||
<TableHead className="font-semibold text-slate-700">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredInvoices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-center py-12 text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="font-medium">No invoices found</p>
|
||||
</TableCell>
|
||||
{/* Search */}
|
||||
<div className="bg-white rounded-lg p-4 mb-6 border border-slate-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search by invoice number, client, event..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoices Table */}
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50">
|
||||
<TableHead>Invoice #</TableHead>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Event</TableHead>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Issue Date</TableHead>
|
||||
<TableHead>Due Date</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredInvoices.map((invoice, idx) => (
|
||||
<TableRow key={invoice.id} className="hover:bg-slate-50">
|
||||
<TableCell>{idx + 1}</TableCell>
|
||||
<TableCell className="font-medium">{invoice.manager_name || invoice.business_name}</TableCell>
|
||||
<TableCell>{invoice.hub || "Hub Name"}</TableCell>
|
||||
<TableCell>
|
||||
<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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredInvoices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-12 text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="font-medium">No invoices found</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Record Payment Dialog */}
|
||||
<Dialog open={showPaymentDialog} onOpenChange={setShowPaymentDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Record Payment</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label>Select Invoice</Label>
|
||||
<Select onValueChange={(value) => setSelectedInvoice(filteredInvoices.find(i => i.id === value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose an invoice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredInvoices.filter(i => i.status !== "Paid").map((invoice) => (
|
||||
<SelectItem key={invoice.id} value={invoice.id}>
|
||||
{invoice.invoice_number} - ${invoice.amount} ({invoice.status})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Payment Method</Label>
|
||||
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select payment 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>
|
||||
<SelectItem value="Cash">Cash</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowPaymentDialog(false)}>
|
||||
Cancel
|
||||
</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>
|
||||
) : (
|
||||
filteredInvoices.map((invoice) => (
|
||||
<TableRow key={invoice.id} className="hover:bg-slate-50">
|
||||
<TableCell className="font-semibold">{invoice.invoice_number}</TableCell>
|
||||
<TableCell>{invoice.business_name}</TableCell>
|
||||
<TableCell>{invoice.event_name}</TableCell>
|
||||
<TableCell>{invoice.vendor_name || "—"}</TableCell>
|
||||
<TableCell>{format(parseISO(invoice.issue_date), 'MMM dd, yyyy')}</TableCell>
|
||||
<TableCell className={isPast(parseISO(invoice.due_date)) && invoice.status !== "Paid" ? "text-red-600 font-semibold" : ""}>
|
||||
{format(parseISO(invoice.due_date), 'MMM dd, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold">${invoice.amount?.toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={statusColors[invoice.status]}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))}
|
||||
className="font-semibold"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View
|
||||
</Button>
|
||||
{userRole === "vendor" && invoice.status === "Draft" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(createPageUrl(`InvoiceEditor?id=${invoice.id}`))}
|
||||
className="font-semibold text-blue-600"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateInvoiceModal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import React from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
|
||||
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
|
||||
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
|
||||
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap
|
||||
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -37,7 +37,7 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
// Navigation items for each role
|
||||
const roleNavigationMap = {
|
||||
admin: [
|
||||
{ title: "Dashboard", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
|
||||
{ title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
||||
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
@@ -58,7 +58,7 @@ const roleNavigationMap = {
|
||||
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
|
||||
],
|
||||
procurement: [
|
||||
{ title: "Dashboard", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Home", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
|
||||
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
@@ -73,7 +73,7 @@ const roleNavigationMap = {
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
],
|
||||
operator: [
|
||||
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
@@ -87,7 +87,7 @@ const roleNavigationMap = {
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
],
|
||||
sector: [
|
||||
{ title: "Dashboard", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
|
||||
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
|
||||
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
|
||||
@@ -100,7 +100,7 @@ const roleNavigationMap = {
|
||||
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
|
||||
],
|
||||
client: [
|
||||
{ title: "Dashboard", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Home", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
|
||||
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
|
||||
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
|
||||
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
|
||||
@@ -113,7 +113,7 @@ const roleNavigationMap = {
|
||||
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
|
||||
],
|
||||
vendor: [
|
||||
{ title: "Dashboard", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Home", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
|
||||
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
|
||||
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
|
||||
@@ -131,7 +131,7 @@ const roleNavigationMap = {
|
||||
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
|
||||
],
|
||||
workforce: [
|
||||
{ title: "Dashboard", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
|
||||
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
|
||||
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
|
||||
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
|
||||
@@ -241,6 +241,7 @@ function NavigationMenu({ location, userRole, closeSheet }) {
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [showNotifications, setShowNotifications] = React.useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
|
||||
@@ -323,6 +324,16 @@ export default function Layout({ children }) {
|
||||
<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="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}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="lg:hidden hover:bg-slate-100">
|
||||
|
||||
@@ -44,7 +44,11 @@ export default function Onboarding() {
|
||||
...prev,
|
||||
email: foundInvite.email,
|
||||
first_name: nameParts[0] || "",
|
||||
last_name: nameParts.slice(1).join(' ') || ""
|
||||
last_name: nameParts.slice(1).join(' ') || "",
|
||||
phone: foundInvite.phone || "",
|
||||
department: foundInvite.department || "",
|
||||
hub: foundInvite.hub || "",
|
||||
title: foundInvite.title || ""
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -65,6 +69,40 @@ export default function Onboarding() {
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Fetch team to get departments
|
||||
const { data: team } = useQuery({
|
||||
queryKey: ['team-for-departments', invite?.team_id],
|
||||
queryFn: async () => {
|
||||
if (!invite?.team_id) return null;
|
||||
const allTeams = await base44.entities.Team.list();
|
||||
return allTeams.find(t => t.id === invite.team_id);
|
||||
},
|
||||
enabled: !!invite?.team_id,
|
||||
});
|
||||
|
||||
// Get all unique departments from team and hubs
|
||||
const availableDepartments = React.useMemo(() => {
|
||||
const depts = new Set();
|
||||
|
||||
// Add team departments
|
||||
if (team?.departments) {
|
||||
team.departments.forEach(d => depts.add(d));
|
||||
}
|
||||
|
||||
// Add hub departments
|
||||
hubs.forEach(hub => {
|
||||
if (hub.departments) {
|
||||
hub.departments.forEach(dept => {
|
||||
if (dept.department_name) {
|
||||
depts.add(dept.department_name);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(depts);
|
||||
}, [team, hubs]);
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (data) => {
|
||||
if (!invite) {
|
||||
@@ -233,8 +271,14 @@ export default function Onboarding() {
|
||||
<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}
|
||||
</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">
|
||||
You've been invited by {invite.invited_by} as a <strong>{invite.role}</strong>
|
||||
{invite.department && <span> in <strong>{invite.department}</strong></span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -313,6 +357,7 @@ export default function Onboarding() {
|
||||
placeholder="+1 (555) 123-4567"
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">You can edit this if needed</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -353,23 +398,25 @@ export default function Onboarding() {
|
||||
<SelectValue placeholder="Select department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Operations">Operations</SelectItem>
|
||||
<SelectItem value="Sales">Sales</SelectItem>
|
||||
<SelectItem value="HR">HR</SelectItem>
|
||||
<SelectItem value="Finance">Finance</SelectItem>
|
||||
<SelectItem value="IT">IT</SelectItem>
|
||||
<SelectItem value="Marketing">Marketing</SelectItem>
|
||||
<SelectItem value="Customer Service">Customer Service</SelectItem>
|
||||
<SelectItem value="Logistics">Logistics</SelectItem>
|
||||
<SelectItem value="Management">Management</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
{availableDepartments.length > 0 ? (
|
||||
availableDepartments.map((dept) => (
|
||||
<SelectItem key={dept} value={dept}>
|
||||
{dept}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="Operations">Operations</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.department && (
|
||||
<p className="text-xs text-slate-500 mt-1">✓ Pre-filled from your invitation</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hubs.length > 0 && (
|
||||
<div>
|
||||
<Label htmlFor="hub">Hub Location (Optional)</Label>
|
||||
<Label htmlFor="hub">Hub Location</Label>
|
||||
<Select value={formData.hub} onValueChange={(value) => setFormData({ ...formData, hub: value })}>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue placeholder="Select hub location" />
|
||||
@@ -383,6 +430,9 @@ export default function Onboarding() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.hub && (
|
||||
<p className="text-xs text-blue-600 font-semibold mt-1">📍 You're joining {formData.hub}!</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -130,7 +130,8 @@ Return a concise summary.`,
|
||||
const primaryLocation = businesses[0]?.business_name || "Primary Location";
|
||||
|
||||
// Ensure count is properly set - default to 1 if not detected
|
||||
const staffCount = parsed.count && parsed.count > 0 ? parsed.count : 1;
|
||||
// CRITICAL: For RAPID orders, use the EXACT count parsed, no modifications
|
||||
const staffCount = parsed.count && parsed.count > 0 ? Math.floor(parsed.count) : 1;
|
||||
|
||||
// Get current time for start_time (when ASAP)
|
||||
const now = new Date();
|
||||
@@ -221,15 +222,17 @@ Return a concise summary.`,
|
||||
const confirmTime12Hour = convertTo12Hour(confirmTime);
|
||||
|
||||
// Create comprehensive order data with proper requested field and actual times
|
||||
// CRITICAL: For RAPID orders, requested must exactly match the count - no additions
|
||||
const exactCount = Math.floor(Number(detectedOrder.count));
|
||||
const orderData = {
|
||||
event_name: `RAPID: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`,
|
||||
event_name: `RAPID: ${exactCount} ${detectedOrder.role}${exactCount > 1 ? 's' : ''}`,
|
||||
is_rapid: true,
|
||||
status: "Pending",
|
||||
business_name: detectedOrder.business_name,
|
||||
hub: detectedOrder.hub,
|
||||
event_location: detectedOrder.location,
|
||||
date: now.toISOString().split('T')[0],
|
||||
requested: Number(detectedOrder.count), // Ensure it's a number
|
||||
requested: exactCount, // EXACT count requested, no modifications
|
||||
client_name: user?.full_name,
|
||||
client_email: user?.email,
|
||||
notes: `RAPID ORDER - Submitted at ${detectedOrder.start_time_display} - Confirmed at ${confirmTime12Hour}\nStart: ${detectedOrder.start_time_display} | End: ${detectedOrder.end_time_display}`,
|
||||
@@ -238,7 +241,7 @@ Return a concise summary.`,
|
||||
location: detectedOrder.location,
|
||||
roles: [{
|
||||
role: detectedOrder.role,
|
||||
count: Number(detectedOrder.count), // Ensure it's a number
|
||||
count: exactCount, // Use exact count, no modifications
|
||||
start_time: detectedOrder.start_time, // Store in 24-hour format
|
||||
end_time: detectedOrder.end_time // Store in 24-hour format
|
||||
}]
|
||||
|
||||
@@ -11,7 +11,15 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { DragDropContext, Draggable } from "@hello-pangea/dnd";
|
||||
import { Link2, Plus, Users } from "lucide-react";
|
||||
import { Link2, Plus, Users, Search, UserCircle, Filter, ArrowUpDown, EyeOff, Grid3x3, MoreVertical, Pin, Ruler, Palette } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import TaskCard from "@/components/tasks/TaskCard";
|
||||
import TaskColumn from "@/components/tasks/TaskColumn";
|
||||
import TaskDetailModal from "@/components/tasks/TaskDetailModal";
|
||||
@@ -32,6 +40,15 @@ export default function TaskBoard() {
|
||||
assigned_members: []
|
||||
});
|
||||
const [selectedMembers, setSelectedMembers] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterPerson, setFilterPerson] = useState("all");
|
||||
const [filterPriority, setFilterPriority] = useState("all");
|
||||
const [sortBy, setSortBy] = useState("due_date");
|
||||
const [showCompleted, setShowCompleted] = useState(true);
|
||||
const [groupBy, setGroupBy] = useState("status");
|
||||
const [pinnedColumns, setPinnedColumns] = useState([]);
|
||||
const [itemHeight, setItemHeight] = useState("normal");
|
||||
const [conditionalColoring, setConditionalColoring] = useState(true);
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-taskboard'],
|
||||
@@ -57,7 +74,30 @@ export default function TaskBoard() {
|
||||
});
|
||||
|
||||
const userTeam = teams.find(t => t.owner_id === user?.id) || teams[0];
|
||||
const teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
|
||||
let teamTasks = tasks.filter(t => t.team_id === userTeam?.id);
|
||||
|
||||
// Apply filters
|
||||
if (searchQuery) {
|
||||
teamTasks = teamTasks.filter(t =>
|
||||
t.task_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (filterPerson !== "all") {
|
||||
teamTasks = teamTasks.filter(t =>
|
||||
t.assigned_members?.some(m => m.member_id === filterPerson)
|
||||
);
|
||||
}
|
||||
|
||||
if (filterPriority !== "all") {
|
||||
teamTasks = teamTasks.filter(t => t.priority === filterPriority);
|
||||
}
|
||||
|
||||
if (!showCompleted) {
|
||||
teamTasks = teamTasks.filter(t => t.status !== "completed");
|
||||
}
|
||||
|
||||
const currentTeamMembers = teamMembers.filter(m => m.team_id === userTeam?.id);
|
||||
|
||||
const leadMembers = currentTeamMembers.filter(m => m.role === 'admin' || m.role === 'manager');
|
||||
@@ -66,12 +106,30 @@ export default function TaskBoard() {
|
||||
// Get unique departments from team members
|
||||
const departments = [...new Set(currentTeamMembers.map(m => m.department).filter(Boolean))];
|
||||
|
||||
const sortTasks = (tasks) => {
|
||||
return [...tasks].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case "due_date":
|
||||
return new Date(a.due_date || '9999-12-31') - new Date(b.due_date || '9999-12-31');
|
||||
case "priority":
|
||||
const priorityOrder = { high: 0, normal: 1, low: 2 };
|
||||
return (priorityOrder[a.priority] || 1) - (priorityOrder[b.priority] || 1);
|
||||
case "created_date":
|
||||
return new Date(b.created_date || 0) - new Date(a.created_date || 0);
|
||||
case "task_name":
|
||||
return (a.task_name || '').localeCompare(b.task_name || '');
|
||||
default:
|
||||
return (a.order_index || 0) - (b.order_index || 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const tasksByStatus = useMemo(() => ({
|
||||
pending: teamTasks.filter(t => t.status === 'pending').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||
in_progress: teamTasks.filter(t => t.status === 'in_progress').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||
on_hold: teamTasks.filter(t => t.status === 'on_hold').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||
completed: teamTasks.filter(t => t.status === 'completed').sort((a, b) => (a.order_index || 0) - (b.order_index || 0)),
|
||||
}), [teamTasks]);
|
||||
pending: sortTasks(teamTasks.filter(t => t.status === 'pending')),
|
||||
in_progress: sortTasks(teamTasks.filter(t => t.status === 'in_progress')),
|
||||
on_hold: sortTasks(teamTasks.filter(t => t.status === 'on_hold')),
|
||||
completed: sortTasks(teamTasks.filter(t => t.status === 'completed')),
|
||||
}), [teamTasks, sortBy]);
|
||||
|
||||
const overallProgress = useMemo(() => {
|
||||
if (teamTasks.length === 0) return 0;
|
||||
@@ -158,6 +216,130 @@ export default function TaskBoard() {
|
||||
<div className="max-w-[1800px] mx-auto">
|
||||
{/* Header */}
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-2">Task Board</h1>
|
||||
@@ -205,8 +387,8 @@ export default function TaskBoard() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" className="border-slate-300">
|
||||
<Link2 className="w-4 h-4 mr-2" />
|
||||
<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" />
|
||||
Share
|
||||
</Button>
|
||||
<Button
|
||||
@@ -214,10 +396,10 @@ export default function TaskBoard() {
|
||||
setSelectedStatus("pending");
|
||||
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" />
|
||||
Create List
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,6 +438,8 @@ export default function TaskBoard() {
|
||||
task={task}
|
||||
provided={provided}
|
||||
onClick={() => setSelectedTask(task)}
|
||||
itemHeight={itemHeight}
|
||||
conditionalColoring={conditionalColoring}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -48,9 +47,24 @@ export default function TeamDetails() {
|
||||
state: "",
|
||||
zip_code: "",
|
||||
manager_name: "",
|
||||
manager_email: ""
|
||||
manager_email: "",
|
||||
departments: []
|
||||
});
|
||||
|
||||
const [showAddDepartmentDialog, setShowAddDepartmentDialog] = useState(false);
|
||||
const [selectedHub, setSelectedHub] = useState(null);
|
||||
const [newDepartment, setNewDepartment] = useState({
|
||||
department_name: "",
|
||||
cost_center: "",
|
||||
manager_name: ""
|
||||
});
|
||||
|
||||
const [favoriteSearch, setFavoriteSearch] = useState("");
|
||||
const [blockedSearch, setBlockedSearch] = useState("");
|
||||
const [showAddFavoriteDialog, setShowAddFavoriteDialog] = useState(false);
|
||||
const [showAddBlockedDialog, setShowAddBlockedDialog] = useState(false);
|
||||
const [blockReason, setBlockReason] = useState("");
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-team-details'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
@@ -85,6 +99,13 @@ export default function TeamDetails() {
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-favorites'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
enabled: !!teamId,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const updateMemberMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.TeamMember.update(id, data),
|
||||
onSuccess: () => {
|
||||
@@ -200,7 +221,8 @@ export default function TeamDetails() {
|
||||
state: "",
|
||||
zip_code: "",
|
||||
manager_name: "",
|
||||
manager_email: ""
|
||||
manager_email: "",
|
||||
departments: []
|
||||
});
|
||||
toast({
|
||||
title: "Hub Created",
|
||||
@@ -209,6 +231,78 @@ export default function TeamDetails() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateTeamMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Team.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['team', teamId] });
|
||||
toast({
|
||||
title: "Updated",
|
||||
description: "Team updated successfully",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const addToFavorites = (staff) => {
|
||||
const favoriteStaff = team.favorite_staff || [];
|
||||
const newFavorite = {
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
position: staff.position,
|
||||
added_date: new Date().toISOString()
|
||||
};
|
||||
|
||||
updateTeamMutation.mutate({
|
||||
id: teamId,
|
||||
data: {
|
||||
favorite_staff: [...favoriteStaff, newFavorite],
|
||||
favorite_staff_count: favoriteStaff.length + 1
|
||||
}
|
||||
});
|
||||
setShowAddFavoriteDialog(false);
|
||||
};
|
||||
|
||||
const removeFromFavorites = (staffId) => {
|
||||
const favoriteStaff = (team.favorite_staff || []).filter(f => f.staff_id !== staffId);
|
||||
updateTeamMutation.mutate({
|
||||
id: teamId,
|
||||
data: {
|
||||
favorite_staff: favoriteStaff,
|
||||
favorite_staff_count: favoriteStaff.length
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const addToBlocked = (staff) => {
|
||||
const blockedStaff = team.blocked_staff || [];
|
||||
const newBlocked = {
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
reason: blockReason,
|
||||
blocked_date: new Date().toISOString()
|
||||
};
|
||||
|
||||
updateTeamMutation.mutate({
|
||||
id: teamId,
|
||||
data: {
|
||||
blocked_staff: [...blockedStaff, newBlocked],
|
||||
blocked_staff_count: blockedStaff.length + 1
|
||||
}
|
||||
});
|
||||
setShowAddBlockedDialog(false);
|
||||
setBlockReason("");
|
||||
};
|
||||
|
||||
const removeFromBlocked = (staffId) => {
|
||||
const blockedStaff = (team.blocked_staff || []).filter(b => b.staff_id !== staffId);
|
||||
updateTeamMutation.mutate({
|
||||
id: teamId,
|
||||
data: {
|
||||
blocked_staff: blockedStaff,
|
||||
blocked_staff_count: blockedStaff.length
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditMember = (member) => {
|
||||
setEditingMember(member);
|
||||
setShowEditMemberDialog(true);
|
||||
@@ -559,7 +653,7 @@ export default function TeamDetails() {
|
||||
)}
|
||||
</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.city && (
|
||||
<p className="text-slate-600">
|
||||
@@ -570,6 +664,38 @@ export default function TeamDetails() {
|
||||
<p className="text-slate-600">{hub.manager_email}</p>
|
||||
)}
|
||||
</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>
|
||||
</Card>
|
||||
))
|
||||
@@ -592,10 +718,69 @@ export default function TeamDetails() {
|
||||
{/* Favorite Staff Tab */}
|
||||
<TabsContent value="favorite" className="mt-6">
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-12 text-center">
|
||||
<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">Mark staff as favorites to see them here</p>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<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>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -603,10 +788,64 @@ export default function TeamDetails() {
|
||||
{/* Blocked Staff Tab */}
|
||||
<TabsContent value="blocked" className="mt-6">
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-12 text-center">
|
||||
<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>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<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>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -806,7 +1045,7 @@ export default function TeamDetails() {
|
||||
<Input
|
||||
value={newHub.hub_name}
|
||||
onChange={(e) => setNewHub({ ...newHub, hub_name: e.target.value })}
|
||||
placeholder="Downtown Office"
|
||||
placeholder="BVG 300"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
@@ -814,7 +1053,7 @@ export default function TeamDetails() {
|
||||
<Input
|
||||
value={newHub.address}
|
||||
onChange={(e) => setNewHub({ ...newHub, address: e.target.value })}
|
||||
placeholder="123 Main Street"
|
||||
placeholder="300 Bayview Dr, Mountain View, CA 94043"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -822,7 +1061,7 @@ export default function TeamDetails() {
|
||||
<Input
|
||||
value={newHub.city}
|
||||
onChange={(e) => setNewHub({ ...newHub, city: e.target.value })}
|
||||
placeholder="San Francisco"
|
||||
placeholder="Mountain View"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -838,7 +1077,7 @@ export default function TeamDetails() {
|
||||
<Input
|
||||
value={newHub.zip_code}
|
||||
onChange={(e) => setNewHub({ ...newHub, zip_code: e.target.value })}
|
||||
placeholder="94102"
|
||||
placeholder="94043"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -867,6 +1106,138 @@ export default function TeamDetails() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Search, MapPin, Users, Star, DollarSign, TrendingUp, MessageSquare, CheckCircle, Award, Filter, Grid, List, Phone, Mail, Building2, Zap, ArrowRight, ChevronDown, ChevronUp, UserCheck, Briefcase, Shield, Crown, X, Edit2, Clock, Target } from "lucide-react";
|
||||
import { Search, MapPin, Users, Star, DollarSign, TrendingUp, MessageSquare, CheckCircle, Award, Filter, Grid, List, Phone, Mail, Building2, Zap, ArrowRight, ChevronDown, ChevronUp, UserCheck, Briefcase, Shield, Crown, X, Edit2, Clock, Target, Handshake } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function VendorMarketplace() {
|
||||
@@ -287,29 +287,25 @@ export default function VendorMarketplace() {
|
||||
<div className="max-w-[1600px] mx-auto space-y-6">
|
||||
|
||||
{/* Hero Header */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-r from-[#0A39DF] to-[#1C323E] rounded-xl p-8 shadow-lg">
|
||||
<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 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="relative z-10">
|
||||
<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">
|
||||
<Building2 className="w-7 h-7 text-white" />
|
||||
<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-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Vendor Marketplace</h1>
|
||||
<p className="text-blue-100 text-sm mt-1">Find the perfect vendor partner for your staffing needs</p>
|
||||
<h1 className="text-3xl font-bold text-slate-800">Vendor Marketplace</h1>
|
||||
<p className="text-slate-600 text-sm mt-1">Find the perfect vendor partner for your staffing needs</p>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<Users className="w-4 h-4 text-white" />
|
||||
<span className="text-white font-semibold">{filteredVendors.length} Active Vendors</span>
|
||||
<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-indigo-600" />
|
||||
<span className="text-slate-700 font-semibold">{filteredVendors.length} Active Vendors</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-sm rounded-lg">
|
||||
<Star className="w-4 h-4 text-amber-300 fill-amber-300" />
|
||||
<span className="text-white font-semibold">Verified & Approved</span>
|
||||
<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-500 fill-amber-400" />
|
||||
<span className="text-slate-700 font-semibold">Verified & Approved</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,11 +318,11 @@ export default function VendorMarketplace() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-5">
|
||||
<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">
|
||||
<Crown className="w-8 h-8 text-amber-400" />
|
||||
<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">
|
||||
<Handshake className="w-8 h-8 text-white" />
|
||||
</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">
|
||||
<Star className="w-3 h-3 text-white fill-white" />
|
||||
<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">
|
||||
<DollarSign className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -354,32 +350,32 @@ export default function VendorMarketplace() {
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-6 gap-3 mb-5">
|
||||
{/* Stats Grid */}
|
||||
<div className="text-center p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<Users className="w-5 h-5 mx-auto mb-2 text-[#0A39DF]" />
|
||||
<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-slate-600" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-amber-50 rounded-lg border border-amber-200">
|
||||
<Star className="w-5 h-5 mx-auto mb-2 text-amber-600 fill-amber-600" />
|
||||
<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-500" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-emerald-50 rounded-lg border border-emerald-200">
|
||||
<Target className="w-5 h-5 mx-auto mb-2 text-emerald-600" />
|
||||
<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-teal-600" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<Clock className="w-5 h-5 mx-auto mb-2 text-purple-600" />
|
||||
<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-blue-600" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-indigo-50 rounded-lg border border-indigo-200">
|
||||
<DollarSign className="w-5 h-5 mx-auto mb-2 text-indigo-600" />
|
||||
<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-blue-600" />
|
||||
<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>
|
||||
</div>
|
||||
@@ -394,32 +390,32 @@ export default function VendorMarketplace() {
|
||||
{/* Benefits Banner */}
|
||||
<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="w-9 h-9 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
<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-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-green-900 text-sm">Priority Support</p>
|
||||
<p className="text-xs text-green-700">Faster responses</p>
|
||||
<p className="font-bold text-slate-800 text-sm">Priority Support</p>
|
||||
<p className="text-xs text-slate-600">Faster responses</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Shield className="w-4 h-4 text-white" />
|
||||
<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-blue-600" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-indigo-50 border border-indigo-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">
|
||||
<TrendingUp className="w-4 h-4 text-white" />
|
||||
<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-white border border-blue-200 shadow-sm rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<TrendingUp className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-indigo-900 text-sm">Better Rates</p>
|
||||
<p className="text-xs text-indigo-700">Volume pricing</p>
|
||||
<p className="font-bold text-slate-800 text-sm">Better Rates</p>
|
||||
<p className="text-xs text-slate-600">Volume pricing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -457,7 +453,7 @@ export default function VendorMarketplace() {
|
||||
|
||||
{/* Stats Cards */}
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<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-slate-500 text-xs">Approved</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center">
|
||||
<Building2 className="w-6 h-6 text-[#0A39DF]" />
|
||||
<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-slate-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-slate-200 bg-white hover:border-emerald-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">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">
|
||||
<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>
|
||||
@@ -515,12 +479,42 @@ export default function VendorMarketplace() {
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs">Average</p>
|
||||
</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" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -635,13 +629,16 @@ export default function VendorMarketplace() {
|
||||
const isExpanded = expandedVendors[vendor.id];
|
||||
|
||||
return (
|
||||
<Card key={vendor.id} className="bg-white border border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all group">
|
||||
<CardHeader className="bg-slate-50 border-b border-slate-200 pb-4">
|
||||
<Card key={vendor.id} className="bg-white border border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all group">
|
||||
<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-center gap-4 flex-1">
|
||||
<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">
|
||||
<AvatarFallback className="text-white text-xl font-bold">
|
||||
<Avatar className="w-16 h-16 bg-blue-100 shadow-lg ring-2 ring-blue-200">
|
||||
{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)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -652,12 +649,12 @@ export default function VendorMarketplace() {
|
||||
|
||||
<div className="flex-1">
|
||||
<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}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1.5 bg-amber-50 px-3 py-1.5 rounded-full border border-amber-200">
|
||||
<Star className="w-4 h-4 text-amber-600 fill-amber-600" />
|
||||
<span className="text-sm font-bold text-amber-700">{vendor.rating.toFixed(1)}</span>
|
||||
<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-500 fill-amber-500" />
|
||||
<span className="text-sm font-bold text-slate-800">{vendor.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -667,20 +664,20 @@ export default function VendorMarketplace() {
|
||||
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{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}
|
||||
</Badge>
|
||||
)}
|
||||
<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}
|
||||
</span>
|
||||
<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
|
||||
</span>
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -688,19 +685,19 @@ export default function VendorMarketplace() {
|
||||
</div>
|
||||
|
||||
<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]">
|
||||
<p className="text-blue-100 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-blue-200 text-xs">per hour</p>
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-xl shadow-sm text-center min-w-[140px]">
|
||||
<p className="text-slate-600 text-[10px] mb-1 font-semibold uppercase tracking-wide">Starting from</p>
|
||||
<p className="text-3xl font-bold text-slate-900 mb-1">${vendor.minRate}</p>
|
||||
<p className="text-slate-600 text-xs">per hour</p>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<UserCheck className="w-5 h-5 text-purple-700" />
|
||||
<span className="text-2xl font-bold text-purple-700">{vendor.clientsInSector}</span>
|
||||
<UserCheck className="w-5 h-5 text-blue-600" />
|
||||
<span className="text-2xl font-bold text-slate-900">{vendor.clientsInSector}</span>
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
@@ -711,7 +708,7 @@ export default function VendorMarketplace() {
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
{vendor.completedJobs} jobs
|
||||
</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
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -719,17 +716,17 @@ export default function VendorMarketplace() {
|
||||
</div>
|
||||
</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">
|
||||
<Collapsible open={isExpanded} onOpenChange={() => toggleVendorRates(vendor.id)} className="flex-1">
|
||||
<CollapsibleTrigger asChild>
|
||||
<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="w-9 h-9 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<TrendingUp className="w-4 h-4 text-[#0A39DF]" />
|
||||
<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-blue-600" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{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
|
||||
onClick={() => setPreferredMutation.mutate(vendor)}
|
||||
disabled={setPreferredMutation.isPending}
|
||||
className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 font-bold shadow-md"
|
||||
className="bg-cyan-100 hover:bg-cyan-200 text-slate-800 font-bold shadow-sm border border-cyan-200"
|
||||
>
|
||||
<Award className="w-4 h-4 mr-2" />
|
||||
Set as Preferred
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
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" />
|
||||
Contact
|
||||
</Button>
|
||||
<Button
|
||||
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" />
|
||||
Order Now
|
||||
@@ -768,69 +764,39 @@ export default function VendorMarketplace() {
|
||||
|
||||
<Collapsible open={isExpanded}>
|
||||
<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">
|
||||
{Object.entries(vendor.ratesByCategory).map(([category, categoryRates]) => (
|
||||
<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">
|
||||
<h4 className="font-bold text-white text-sm flex items-center gap-2">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
<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-slate-800 text-sm flex items-center gap-2">
|
||||
<Briefcase className="w-4 h-4 text-slate-600" />
|
||||
{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}
|
||||
</Badge>
|
||||
</h4>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{categoryRates.map((rate, idx) => {
|
||||
const baseWage = rate.employee_wage || 0;
|
||||
const markupAmount = baseWage * ((rate.markup_percentage || 0) / 100);
|
||||
const feeAmount = (baseWage + markupAmount) * ((rate.vendor_fee_percentage || 0) / 100);
|
||||
|
||||
return (
|
||||
<div key={rate.id} className="p-4 hover:bg-blue-50 transition-all">
|
||||
<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>
|
||||
{categoryRates.map((rate, idx) => {
|
||||
return (
|
||||
<div key={rate.id} className="p-4 hover:bg-blue-50/30 transition-all">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center font-bold text-blue-700 text-sm">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<h5 className="font-bold text-slate-900 text-base">{rate.role_name}</h5>
|
||||
</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 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>
|
||||
))}
|
||||
@@ -860,16 +826,19 @@ export default function VendorMarketplace() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-indigo-600 shadow-md">
|
||||
<AvatarFallback className="text-white font-bold">
|
||||
<Avatar className="w-12 h-12 bg-blue-100 shadow-md">
|
||||
{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)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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>
|
||||
</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">
|
||||
<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}
|
||||
</span>
|
||||
</td>
|
||||
<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" />
|
||||
<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>
|
||||
</td>
|
||||
<td className="py-5 px-5 text-center">
|
||||
{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" />
|
||||
{vendor.clientsInSector}
|
||||
</Badge>
|
||||
@@ -898,12 +867,12 @@ export default function VendorMarketplace() {
|
||||
)}
|
||||
</td>
|
||||
<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 className="py-5 px-5 text-center">
|
||||
<div className="inline-flex flex-col bg-blue-50 px-4 py-2 rounded-xl">
|
||||
<span className="font-bold text-xl text-[#0A39DF]">${vendor.minRate}</span>
|
||||
<span className="text-xs text-slate-500">/hour</span>
|
||||
<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-slate-900">${vendor.minRate}</span>
|
||||
<span className="text-xs text-slate-600">/hour</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-5 px-5">
|
||||
@@ -912,15 +881,15 @@ export default function VendorMarketplace() {
|
||||
size="sm"
|
||||
onClick={() => setPreferredMutation.mutate(vendor)}
|
||||
disabled={setPreferredMutation.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
className="bg-cyan-100 hover:bg-cyan-200 text-slate-800 border border-cyan-200"
|
||||
>
|
||||
<Award className="w-3 h-3 mr-1" />
|
||||
Set Preferred
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
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" />
|
||||
Contact
|
||||
@@ -968,8 +937,11 @@ export default function VendorMarketplace() {
|
||||
|
||||
<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">
|
||||
<Avatar className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-indigo-600 ring-2 ring-white shadow-md">
|
||||
<AvatarFallback className="text-white text-xl font-bold">
|
||||
<Avatar className="w-16 h-16 bg-blue-100 ring-2 ring-white shadow-md">
|
||||
{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)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
@@ -130,6 +130,10 @@ import NotificationSettings from "./NotificationSettings";
|
||||
|
||||
import TaskBoard from "./TaskBoard";
|
||||
|
||||
import InvoiceDetail from "./InvoiceDetail";
|
||||
|
||||
import InvoiceEditor from "./InvoiceEditor";
|
||||
|
||||
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
const PAGES = {
|
||||
@@ -264,6 +268,10 @@ const PAGES = {
|
||||
|
||||
TaskBoard: TaskBoard,
|
||||
|
||||
InvoiceDetail: InvoiceDetail,
|
||||
|
||||
InvoiceEditor: InvoiceEditor,
|
||||
|
||||
}
|
||||
|
||||
function _getCurrentPage(url) {
|
||||
@@ -421,6 +429,10 @@ function PagesContent() {
|
||||
|
||||
<Route path="/TaskBoard" element={<TaskBoard />} />
|
||||
|
||||
<Route path="/InvoiceDetail" element={<InvoiceDetail />} />
|
||||
|
||||
<Route path="/InvoiceEditor" element={<InvoiceEditor />} />
|
||||
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user