feat(Makefile): patch Layout.jsx queryKey for local development feat(frontend-web): mock base44 client for local development with role switching feat(frontend-web): add event assignment modal with conflict detection and bulk assign feat(frontend-web): add client dashboard with key metrics and quick actions feat(frontend-web): add layout component with role-based navigation feat(frontend-web): update various pages to use "@/components" alias feat(frontend-web): update create event page with ai assistant toggle feat(frontend-web): update dashboard page with new components feat(frontend-web): update events page with quick assign popover feat(frontend-web): update invite vendor page with hover card feat(frontend-web): update messages page with conversation list and message thread feat(frontend-web): update operator dashboard page with new components feat(frontend-web): update partner management page with new components feat(frontend-web): update permissions page with new components feat(frontend-web): update procurement dashboard page with new components feat(frontend-web): update smart vendor onboarding page with new components feat(frontend-web): update staff directory page with new components feat(frontend-web): update teams page with new components feat(frontend-web): update user management page with new components feat(frontend-web): update vendor compliance page with new components feat(frontend-web): update main.jsx to include react query provider feat: add vendor marketplace page feat: add global import fix to prepare-export script feat: add patch-layout-query-key script to fix query key feat: update patch-base44-client script to use a more robust method
864 lines
35 KiB
JavaScript
864 lines
35 KiB
JavaScript
|
||
import React, { useState } from "react";
|
||
import { base44 } from "@/api/base44Client";
|
||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Plus, Search, Filter, Download, LayoutGrid, List, Eye, Edit, Trash2, MoreHorizontal, Users, UserPlus, RefreshCw, Copy, Calendar as CalendarIcon, ArrowUpDown, Check, Bell, Send, FileText, Zap } from "lucide-react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
HoverCard,
|
||
HoverCardContent,
|
||
HoverCardTrigger,
|
||
} from "@/components/ui/hover-card";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import { Calendar } from "@/components/ui/calendar";
|
||
import { format, isToday, addDays } from "date-fns";
|
||
import { useToast } from "@/components/ui/use-toast";
|
||
import { Link, useNavigate } from "react-router-dom";
|
||
import { createPageUrl } from "@/utils";
|
||
import EventAssignmentModal from "@/components/events/EventAssignmentModal";
|
||
|
||
const getStatusColor = (order) => {
|
||
// Check for Rapid Request first
|
||
if (order.is_rapid_request) {
|
||
return "bg-red-500 text-white";
|
||
}
|
||
|
||
if (!order.shifts_data || order.shifts_data.length === 0) {
|
||
return "bg-orange-500 text-white";
|
||
}
|
||
|
||
let totalNeeded = 0;
|
||
let totalAssigned = 0;
|
||
|
||
order.shifts_data.forEach(shift => {
|
||
shift.roles.forEach(role => {
|
||
const needed = parseInt(role.count) || 0;
|
||
const assigned = role.assignments?.length || 0;
|
||
totalNeeded += needed;
|
||
totalAssigned += assigned;
|
||
});
|
||
});
|
||
|
||
if (totalNeeded === 0) return "bg-orange-500 text-white";
|
||
if (totalAssigned === 0) return "bg-orange-500 text-white";
|
||
if (totalAssigned < totalNeeded) return "bg-blue-500 text-white";
|
||
if (totalAssigned >= totalNeeded) return "bg-green-500 text-white";
|
||
|
||
return "bg-slate-500 text-white";
|
||
};
|
||
|
||
const getStatusText = (order) => {
|
||
if (order.is_rapid_request) return "Rapid Request";
|
||
|
||
if (!order.shifts_data || order.shifts_data.length === 0) return "Pending";
|
||
|
||
let totalNeeded = 0;
|
||
let totalAssigned = 0;
|
||
|
||
order.shifts_data.forEach(shift => {
|
||
shift.roles.forEach(role => {
|
||
totalNeeded += parseInt(role.count) || 0;
|
||
totalAssigned += role.assignments?.length || 0;
|
||
});
|
||
});
|
||
|
||
if (totalAssigned === 0) return "Pending";
|
||
if (totalAssigned < totalNeeded) return "Partially Filled";
|
||
if (totalAssigned >= totalNeeded && totalNeeded > 0) return "Fully Staffed";
|
||
return "Pending";
|
||
};
|
||
|
||
const convertTo12Hour = (time24) => {
|
||
if (!time24) return '';
|
||
const [hours, minutes] = time24.split(':');
|
||
const hour = parseInt(hours);
|
||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||
const hour12 = hour % 12 || 12;
|
||
return `${hour12}:${minutes} ${ampm}`;
|
||
};
|
||
|
||
export default function VendorOrders() {
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [filters, setFilters] = useState({ status: "all", hub: "all" });
|
||
const [viewMode, setViewMode] = useState("list");
|
||
const [sortBy, setSortBy] = useState("date");
|
||
const [sortOrder, setSortOrder] = useState("desc");
|
||
const [selectedDate, setSelectedDate] = useState(null);
|
||
const [notifyingOrders, setNotifyingOrders] = useState(new Set());
|
||
const [assignmentModal, setAssignmentModal] = useState({ open: false, order: null });
|
||
|
||
const queryClient = useQueryClient();
|
||
const { toast } = useToast();
|
||
const navigate = useNavigate();
|
||
|
||
const { data: user } = useQuery({
|
||
queryKey: ['current-user-vendor-orders'],
|
||
queryFn: () => base44.auth.me(),
|
||
});
|
||
|
||
const { data: allOrders = [], isLoading } = useQuery({
|
||
queryKey: ['orders'],
|
||
queryFn: () => base44.entities.Order.list('-created_date'),
|
||
});
|
||
|
||
const { data: invoices = [] } = useQuery({
|
||
queryKey: ['invoices'],
|
||
queryFn: () => base44.entities.Invoice.list(),
|
||
});
|
||
|
||
// Filter orders for this vendor only
|
||
const orders = allOrders.filter(order =>
|
||
order.vendor_id === user?.id ||
|
||
order.vendor_name === user?.company_name ||
|
||
order.created_by === user?.email
|
||
);
|
||
|
||
const getInvoiceForOrder = (orderId) => {
|
||
return invoices.find(inv => inv.order_id === orderId);
|
||
};
|
||
|
||
const sendNotificationToStaff = async (order, assignment) => {
|
||
const employees = await base44.entities.Employee.filter({ id: assignment.employee_id });
|
||
if (employees.length === 0) return { success: false, method: 'none', reason: 'Employee not found' };
|
||
|
||
const employee = employees[0];
|
||
|
||
const formattedDate = assignment.shift_date ?
|
||
format(new Date(assignment.shift_date), 'EEEE, MMMM d, yyyy') :
|
||
'TBD';
|
||
|
||
const startTime = assignment.shift_start ? convertTo12Hour(assignment.shift_start) : 'TBD';
|
||
const endTime = assignment.shift_end ? convertTo12Hour(assignment.shift_end) : 'TBD';
|
||
|
||
const emailBody = `
|
||
Hi ${assignment.employee_name},
|
||
|
||
Great news! You've been assigned to a new shift.
|
||
|
||
EVENT DETAILS:
|
||
━━━━━━━━━━━━━━━━━━━━
|
||
📅 Event: ${order.event_name}
|
||
🏢 Client: ${order.client_business}
|
||
📍 Location: ${assignment.location || 'TBD'}
|
||
🏠 Hub: ${assignment.hub_location || order.hub_location}
|
||
|
||
SHIFT DETAILS:
|
||
━━━━━━━━━━━━━━━━━━━━
|
||
📆 Date: ${formattedDate}
|
||
🕐 Time: ${startTime} - ${endTime}
|
||
👔 Position: ${assignment.position}
|
||
|
||
${order.notes ? `\nADDITIONAL NOTES:\n${order.notes}\n` : ''}
|
||
|
||
Please confirm your availability as soon as possible.
|
||
|
||
If you have any questions, please contact your manager.
|
||
|
||
Thank you,
|
||
Legendary Event Staffing Team
|
||
`.trim();
|
||
|
||
const smsBody = `🎉 Legendary Event Staffing\n\nYou're assigned to ${order.event_name}!\n📆 ${formattedDate}\n🕐 ${startTime}-${endTime}\n👔 ${assignment.position}\n📍 ${assignment.location || order.hub_location}\n\nPlease confirm ASAP. Check email for full details.`;
|
||
|
||
let emailSent = false;
|
||
let smsSent = false;
|
||
|
||
if (employee.phone) {
|
||
try {
|
||
const cleanPhone = employee.phone.replace(/\D/g, '');
|
||
|
||
await base44.integrations.Core.InvokeLLM({
|
||
prompt: `Send an SMS text message to phone number ${cleanPhone} with the following content:\n\n${smsBody}\n\nUse a reliable SMS service to send this message. Return success status.`,
|
||
add_context_from_internet: false,
|
||
});
|
||
|
||
smsSent = true;
|
||
console.log(`✅ SMS sent to ${employee.phone}`);
|
||
} catch (error) {
|
||
console.error(`❌ Failed to send SMS to ${employee.phone}:`, error);
|
||
}
|
||
}
|
||
|
||
if (employee.email) {
|
||
try {
|
||
await base44.integrations.Core.SendEmail({
|
||
from_name: 'Legendary Event Staffing',
|
||
to: employee.email,
|
||
subject: `🎉 You've been assigned to ${order.event_name}`,
|
||
body: emailBody
|
||
});
|
||
|
||
emailSent = true;
|
||
console.log(`✅ Email sent to ${employee.email}`);
|
||
} catch (error) {
|
||
console.error(`❌ Failed to send email to ${employee.email}:`, error);
|
||
}
|
||
}
|
||
|
||
if (emailSent || smsSent) {
|
||
const methods = [];
|
||
if (smsSent) methods.push('SMS');
|
||
if (emailSent) methods.push('Email');
|
||
return { success: true, method: methods.join(' & '), employee: employee.full_name };
|
||
}
|
||
|
||
return { success: false, method: 'none', reason: 'No contact info or failed to send' };
|
||
};
|
||
|
||
const handleNotifyStaff = async (order) => {
|
||
if (notifyingOrders.has(order.id)) return;
|
||
|
||
setNotifyingOrders(prev => new Set(prev).add(order.id));
|
||
|
||
const allAssignments = [];
|
||
if (order.shifts_data) {
|
||
order.shifts_data.forEach((shift, shiftIdx) => {
|
||
shift.roles.forEach(role => {
|
||
if (role.assignments && role.assignments.length > 0) {
|
||
role.assignments.forEach(assignment => {
|
||
allAssignments.push({
|
||
...assignment,
|
||
shift_date: order.event_date,
|
||
shift_start: role.start_time,
|
||
shift_end: role.end_time,
|
||
position: role.service,
|
||
location: shift.address || order.event_address,
|
||
hub_location: order.hub_location,
|
||
});
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
if (allAssignments.length === 0) {
|
||
toast({
|
||
title: "No staff assigned",
|
||
description: "Please assign staff before sending notifications.",
|
||
variant: "destructive",
|
||
});
|
||
setNotifyingOrders(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(order.id);
|
||
return next;
|
||
});
|
||
return;
|
||
}
|
||
|
||
toast({
|
||
title: "Sending notifications...",
|
||
description: `Notifying ${allAssignments.length} staff member${allAssignments.length > 1 ? 's' : ''} for "${order.event_name}"...`,
|
||
});
|
||
|
||
let successCount = 0;
|
||
const results = [];
|
||
|
||
for (const assignment of allAssignments) {
|
||
const result = await sendNotificationToStaff(order, assignment);
|
||
if (result.success) {
|
||
successCount++;
|
||
results.push(`✅ ${result.employee} (${result.method})`);
|
||
} else {
|
||
results.push(`❌ ${assignment.employee_name} (${result.reason})`);
|
||
}
|
||
}
|
||
|
||
toast({
|
||
title: `Notifications sent!`,
|
||
description: `Successfully notified ${successCount} of ${allAssignments.length} staff members for "${order.event_name}".`,
|
||
});
|
||
|
||
setNotifyingOrders(prev => {
|
||
const next = new Set(prev);
|
||
next.delete(order.id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleSort = (field) => {
|
||
if (sortBy === field) {
|
||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
} else {
|
||
setSortBy(field);
|
||
setSortOrder('asc');
|
||
}
|
||
};
|
||
|
||
const handleOpenAssignment = (order) => {
|
||
setAssignmentModal({ open: true, order });
|
||
};
|
||
|
||
const handleCloseAssignment = () => {
|
||
setAssignmentModal({ open: false, order: null });
|
||
};
|
||
|
||
const filteredOrders = orders.filter(order => {
|
||
const matchesSearch = order.event_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
order.client_business?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
order.manager?.toLowerCase().includes(searchQuery.toLowerCase());
|
||
|
||
// Updated status filtering to include "Rapid Request" and "Partially Filled"
|
||
let matchesStatus = filters.status === 'all';
|
||
if (!matchesStatus) {
|
||
const statusText = getStatusText(order);
|
||
if (filters.status === 'Rapid Request') {
|
||
matchesStatus = order.is_rapid_request;
|
||
} else {
|
||
matchesStatus = statusText === filters.status;
|
||
}
|
||
}
|
||
|
||
const matchesHub = filters.hub === 'all' || order.hub_location === filters.hub;
|
||
const matchesDate = !selectedDate || order.event_date === format(selectedDate, 'yyyy-MM-dd');
|
||
return matchesSearch && matchesStatus && matchesHub && matchesDate;
|
||
}).sort((a, b) => {
|
||
let compareA, compareB;
|
||
|
||
switch(sortBy) {
|
||
case 'business':
|
||
compareA = a.client_business || '';
|
||
compareB = b.client_business || '';
|
||
break;
|
||
case 'status':
|
||
compareA = getStatusText(a);
|
||
compareB = getStatusText(b);
|
||
break;
|
||
case 'date':
|
||
compareA = a.event_date || '';
|
||
compareB = b.event_date || '';
|
||
break;
|
||
default:
|
||
return 0;
|
||
}
|
||
|
||
if (compareA < compareB) return sortOrder === 'asc' ? -1 : 1;
|
||
if (compareA > compareB) return sortOrder === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
const hubs = [...new Set(orders.map(o => o.hub_location).filter(Boolean))];
|
||
|
||
const today = format(new Date(), 'yyyy-MM-dd');
|
||
const todaysOrders = orders.filter(o => o.event_date === today);
|
||
|
||
const totalStaffToday = todaysOrders.reduce((sum, order) => {
|
||
if (!order.shifts_data) return sum;
|
||
return sum + order.shifts_data.reduce((shiftSum, shift) => {
|
||
return shiftSum + shift.roles.reduce((roleSum, role) => {
|
||
return roleSum + (role.assignments?.length || 0);
|
||
}, 0);
|
||
}, 0);
|
||
}, 0);
|
||
|
||
const rapidRequestToday = todaysOrders.filter(o => o.is_rapid_request).length;
|
||
const pendingToday = todaysOrders.filter(o => getStatusText(o) === 'Pending').length;
|
||
const partiallyFilledToday = todaysOrders.filter(o => getStatusText(o) === 'Partially Filled').length;
|
||
const fullyStaffedToday = todaysOrders.filter(o => getStatusText(o) === 'Fully Staffed').length;
|
||
|
||
return (
|
||
<div className="p-6 md:p-8 space-y-6 bg-slate-50 min-h-screen">
|
||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">Orders Dashboard</h1>
|
||
<p className="text-slate-500 mt-1">Manage and track all event orders</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" className="gap-2">
|
||
Draft
|
||
</Button>
|
||
<Link to={createPageUrl("CreateEvent")}>
|
||
<Button className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white shadow-lg gap-2">
|
||
<Plus className="w-5 h-5" />
|
||
Create Order
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||
<Card className="bg-gradient-to-br from-blue-500 to-blue-600 border-0 text-white">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-blue-100 text-sm mb-2">Total Staff Today</p>
|
||
<p className="text-4xl font-bold">{totalStaffToday}</p>
|
||
</div>
|
||
<Users className="w-12 h-12 text-blue-200 opacity-50" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-white border-slate-200 bg-gradient-to-br from-red-50 to-white">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-slate-500 mb-2">Rapid Request</p>
|
||
<p className="text-3xl font-bold text-red-600">{rapidRequestToday}</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||
<Zap className="w-6 h-6 text-red-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-white border-slate-200">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-slate-500 mb-2">Pending</p>
|
||
<p className="text-3xl font-bold text-orange-600">{pendingToday}</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||
<CalendarIcon className="w-6 h-6 text-orange-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-white border-slate-200">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-slate-500 mb-2">Partially Filled</p>
|
||
<p className="text-3xl font-bold text-blue-600">{partiallyFilledToday}</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||
<Users className="w-6 h-6 text-blue-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-white border-slate-200">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm text-slate-500 mb-2">Fully Staffed</p>
|
||
<p className="text-3xl font-bold text-green-600">{fullyStaffedToday}</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||
<Check className="w-6 h-6 text-green-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row gap-4 items-center">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||
<Input
|
||
placeholder="Search by event, business, or manager..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-10 bg-white border-slate-200"
|
||
/>
|
||
</div>
|
||
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button variant="outline" className="gap-2">
|
||
<CalendarIcon className="w-4 h-4" />
|
||
{selectedDate ? format(selectedDate, 'MMM d, yyyy') : 'Filter by Date'}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0">
|
||
<Calendar
|
||
mode="single"
|
||
selected={selectedDate}
|
||
onSelect={setSelectedDate}
|
||
/>
|
||
{selectedDate && (
|
||
<div className="p-2 border-t">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={() => setSelectedDate(null)}
|
||
>
|
||
Clear Filter
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
<Select value={filters.status} onValueChange={(value) => setFilters({...filters, status: value})}>
|
||
<SelectTrigger className="w-[180px]">
|
||
<SelectValue placeholder="All Status" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">All Status</SelectItem>
|
||
<SelectItem value="Rapid Request">
|
||
<div className="flex items-center gap-2">
|
||
<Zap className="w-4 h-4 text-red-600" />
|
||
Rapid Request
|
||
</div>
|
||
</SelectItem>
|
||
<SelectItem value="Pending">Pending</SelectItem>
|
||
<SelectItem value="Partially Filled">Partially Filled</SelectItem>
|
||
<SelectItem value="Fully Staffed">Fully Staffed</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant={viewMode === 'cards' ? 'default' : 'outline'}
|
||
size="icon"
|
||
onClick={() => setViewMode('cards')}
|
||
>
|
||
<LayoutGrid className="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
variant={viewMode === 'list' ? 'default' : 'outline'}
|
||
size="icon"
|
||
onClick={() => setViewMode('list')}
|
||
>
|
||
<List className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{viewMode === 'list' ? (
|
||
<Card className="bg-white border-slate-200 shadow-sm">
|
||
<CardContent className="p-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-slate-50 border-b border-slate-200">
|
||
<tr>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">
|
||
<button
|
||
onClick={() => handleSort('business')}
|
||
className="flex items-center gap-1 hover:text-blue-600"
|
||
>
|
||
Business
|
||
<ArrowUpDown className="w-3 h-3" />
|
||
</button>
|
||
</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Hub</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Event Name</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">
|
||
<button
|
||
onClick={() => handleSort('status')}
|
||
className="flex items-center gap-1 hover:text-blue-600"
|
||
>
|
||
Status
|
||
<ArrowUpDown className="w-3 h-3" />
|
||
</button>
|
||
</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">
|
||
<button
|
||
onClick={() => handleSort('date')}
|
||
className="flex items-center gap-1 hover:text-blue-600"
|
||
>
|
||
Date
|
||
<ArrowUpDown className="w-3 h-3" />
|
||
</button>
|
||
</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Requested</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Assigned</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Invoice</th>
|
||
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-700">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredOrders.map((order) => {
|
||
let totalNeeded = 0;
|
||
let totalAssigned = 0;
|
||
|
||
if (order.shifts_data) {
|
||
order.shifts_data.forEach(shift => {
|
||
shift.roles.forEach(role => {
|
||
totalNeeded += parseInt(role.count) || 0;
|
||
totalAssigned += role.assignments?.length || 0;
|
||
});
|
||
});
|
||
}
|
||
|
||
const statusText = getStatusText(order);
|
||
const isNotifying = notifyingOrders.has(order.id);
|
||
const invoice = getInvoiceForOrder(order.id);
|
||
|
||
return (
|
||
<tr
|
||
key={order.id}
|
||
className="border-b border-slate-100 hover:bg-slate-50 transition-colors cursor-pointer"
|
||
onClick={() => handleOpenAssignment(order)}
|
||
>
|
||
<td className="px-4 py-3">
|
||
<div className="text-sm font-semibold text-slate-900">
|
||
{order.client_business || 'N/A'}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<Badge variant="outline" className="text-xs">
|
||
{order.hub_location}
|
||
</Badge>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="text-sm font-medium text-slate-900">
|
||
{order.event_name}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<Badge className={`${getStatusColor(order)} rounded-full px-3 text-xs`}>
|
||
{statusText}
|
||
</Badge>
|
||
{order.include_backup && order.backup_staff_count > 0 && (
|
||
<Badge className="bg-green-100 text-green-700 text-xs">
|
||
🛡️ {order.backup_staff_count}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-sm text-slate-600">
|
||
{order.event_date ? format(new Date(order.event_date), 'MM/dd/yy') : 'N/A'}
|
||
</td>
|
||
<td className="px-4 py-3 text-sm text-slate-600">{totalNeeded}</td>
|
||
<td className="px-4 py-3">
|
||
<Badge className={`${totalAssigned >= totalNeeded && totalNeeded > 0 ? 'bg-green-100 text-green-700' : 'bg-orange-100 text-orange-700'} font-semibold`}>
|
||
{totalAssigned}/{totalNeeded}
|
||
</Badge>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
{invoice ? (
|
||
<Link to={`${createPageUrl("InvoiceDetail")}?id=${invoice.id}`} onClick={(e) => e.stopPropagation()}>
|
||
<Button variant="ghost" size="sm" className="gap-2 text-blue-600 hover:text-blue-700" title="View Invoice">
|
||
<FileText className="w-4 h-4" />
|
||
<span className="text-xs font-mono">{invoice.invoice_number}</span>
|
||
</Button>
|
||
</Link>
|
||
) : (
|
||
<span className="text-xs text-slate-400">—</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleNotifyStaff(order);
|
||
}}
|
||
disabled={isNotifying || totalAssigned === 0}
|
||
title="Notify all assigned staff"
|
||
>
|
||
{isNotifying ? (
|
||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Send className="w-4 h-4" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleOpenAssignment(order);
|
||
}}
|
||
title="Assign staff"
|
||
>
|
||
<Users className="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
title="Edit order"
|
||
>
|
||
<Edit className="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
title="Copy order"
|
||
>
|
||
<Copy className="w-4 h-4" />
|
||
</Button>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||
<MoreHorizontal className="w-4 h-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
{invoice && (
|
||
<DropdownMenuItem onClick={() => navigate(`${createPageUrl("InvoiceDetail")}?id=${invoice.id}`)}>
|
||
<FileText className="w-4 h-4 mr-2" />
|
||
View Invoice
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuItem
|
||
onClick={() => handleNotifyStaff(order)}
|
||
disabled={totalAssigned === 0 || isNotifying}
|
||
>
|
||
<Send className="w-4 h-4 mr-2" />
|
||
Notify All Staff
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200">
|
||
<p className="text-sm text-slate-600">Showing {filteredOrders.length} orders</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm">Previous</Button>
|
||
<Button variant="outline" size="sm">Next</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||
{filteredOrders.map((order) => {
|
||
let totalNeeded = 0;
|
||
let totalAssigned = 0;
|
||
|
||
if (order.shifts_data) {
|
||
order.shifts_data.forEach(shift => {
|
||
shift.roles.forEach(role => {
|
||
totalNeeded += parseInt(role.count) || 0;
|
||
totalAssigned += role.assignments?.length || 0;
|
||
});
|
||
});
|
||
}
|
||
|
||
const statusText = getStatusText(order);
|
||
const invoice = getInvoiceForOrder(order.id);
|
||
|
||
return (
|
||
<Card
|
||
key={order.id}
|
||
className="relative hover:shadow-lg transition-all cursor-pointer"
|
||
onClick={() => handleOpenAssignment(order)}
|
||
>
|
||
{invoice && (
|
||
<Link to={`${createPageUrl("InvoiceDetail")}?id=${invoice.id}`} onClick={(e) => e.stopPropagation()}>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="absolute top-2 right-2 z-10 gap-2 bg-white/90 hover:bg-white shadow-sm"
|
||
title="View Invoice"
|
||
>
|
||
<FileText className="w-4 h-4 text-blue-600" />
|
||
<span className="text-xs font-mono text-blue-600">{invoice.invoice_number}</span>
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
<CardContent className="p-6">
|
||
<div className="space-y-4">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<h3 className="text-xl font-bold text-slate-900 mb-1">
|
||
{order.event_name}
|
||
</h3>
|
||
<p className="text-sm text-slate-600">
|
||
{order.client_business}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<Badge className={`${getStatusColor(order)} rounded-full px-3`}>
|
||
{statusText}
|
||
</Badge>
|
||
{order.include_backup && order.backup_staff_count > 0 && (
|
||
<Badge className="bg-green-100 text-green-700">
|
||
🛡️ {order.backup_staff_count} Backup
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 text-slate-700">
|
||
<CalendarIcon className="w-5 h-5 text-blue-600" />
|
||
<span className="font-medium">
|
||
{order.event_date ? format(new Date(order.event_date), 'MMMM d, yyyy') : 'No date'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||
<div className="flex items-center gap-2">
|
||
<Users className="w-5 h-5 text-purple-600" />
|
||
<span className="font-semibold text-slate-700">Staff</span>
|
||
</div>
|
||
<Badge
|
||
className={`${totalAssigned >= totalNeeded && totalNeeded > 0 ? 'bg-green-100 text-green-700' : 'bg-orange-100 text-orange-700'} font-bold border-2`}
|
||
style={{
|
||
borderColor: totalAssigned >= totalNeeded && totalNeeded > 0 ? '#10b981' : '#f97316'
|
||
}}
|
||
>
|
||
{totalAssigned} / {totalNeeded}
|
||
</Badge>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
className="flex-1"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleNotifyStaff(order);
|
||
}}
|
||
disabled={totalAssigned === 0}
|
||
>
|
||
<Send className="w-4 h-4 mr-2" />
|
||
Notify Staff
|
||
</Button>
|
||
{invoice && (
|
||
<Link to={`${createPageUrl("InvoiceDetail")}?id=${invoice.id}`} className="flex-1" onClick={(e) => e.stopPropagation()}>
|
||
<Button variant="outline" className="w-full">
|
||
<FileText className="w-4 h-4 mr-2" />
|
||
View Invoice
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{filteredOrders.length === 0 && (
|
||
<div className="text-center py-12">
|
||
<p className="text-slate-400 text-lg">No orders found</p>
|
||
<p className="text-sm text-slate-500 mt-2">Try adjusting your filters</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Event Assignment Modal */}
|
||
<EventAssignmentModal
|
||
open={assignmentModal.open}
|
||
onClose={handleCloseAssignment}
|
||
order={assignmentModal.order}
|
||
onUpdate={() => queryClient.invalidateQueries({ queryKey: ['orders'] })}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|