new version frontend-webpage
This commit is contained in:
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>
|
||||
))}
|
||||
@@ -665,4 +684,4 @@ export default function StaffAssignment({ assignedStaff = [], onChange, requeste
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
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>
|
||||
@@ -293,4 +619,4 @@ export default function NotificationPanel({ isOpen, onClose }) {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user