Files
Krow-workspace/frontend-web/src/pages/VendorOrders.jsx
2025-11-18 21:32:16 -05:00

505 lines
24 KiB
JavaScript

import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Eye, Edit, Copy, UserCheck, Zap, Clock, Users, RefreshCw, Calendar as CalendarIcon, AlertTriangle, List, LayoutGrid, CheckCircle, FileText, X, MapPin } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { format, parseISO, isValid } from "date-fns";
import { Alert, AlertDescription } from "@/components/ui/alert";
import SmartAssignModal from "@/components/events/SmartAssignModal";
import { autoFillShifts } from "@/components/scheduling/SmartAssignmentEngine";
import { detectAllConflicts, ConflictAlert } from "@/components/scheduling/ConflictDetection";
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, formatStr) => {
const date = safeParseDate(dateString);
if (!date) return "-";
try { return format(date, formatStr); } catch { return "-"; }
};
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 = (event, hasConflicts) => {
if (event.is_rapid) {
return (
<div className="relative inline-flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md">
<Zap className="w-3.5 h-3.5 fill-white" />
RAPID
{hasConflicts && (
<AlertTriangle className="w-3 h-3 absolute -top-1 -right-1 text-orange-500 bg-white rounded-full p-0.5" />
)}
</div>
);
}
const statusConfig = {
'Draft': { bg: 'bg-slate-500', icon: FileText },
'Pending': { bg: 'bg-amber-500', icon: Clock },
'Partial Staffed': { bg: 'bg-orange-500', icon: AlertTriangle },
'Fully Staffed': { bg: 'bg-emerald-500', icon: CheckCircle },
'Active': { bg: 'bg-blue-500', icon: Users },
'Completed': { bg: 'bg-slate-400', icon: CheckCircle },
'Canceled': { bg: 'bg-red-500', icon: X },
};
const config = statusConfig[event.status] || { bg: 'bg-slate-400', icon: Clock };
const Icon = config.icon;
return (
<div className={`relative inline-flex items-center gap-2 ${config.bg} text-white px-4 py-2 rounded-lg font-semibold text-xs shadow-md`}>
<Icon className="w-3.5 h-3.5" />
{event.status}
{hasConflicts && (
<AlertTriangle className="w-3 h-3 absolute -top-1 -right-1 text-orange-500 bg-white rounded-full p-0.5" />
)}
</div>
);
};
export default function VendorOrders() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [viewMode, setViewMode] = useState("table");
const [showConflicts, setShowConflicts] = useState(true);
const [assignModal, setAssignModal] = useState({ open: false, event: null });
const [assignmentOptions] = useState({
prioritizeSkill: true,
prioritizeReliability: true,
prioritizeVendor: true,
prioritizeFatigue: true,
prioritizeCompliance: true,
prioritizeProximity: true,
prioritizeCost: false,
});
const { data: user } = useQuery({
queryKey: ['current-user-vendor-orders'],
queryFn: () => base44.auth.me(),
});
const { data: allEvents = [] } = useQuery({
queryKey: ['all-events-vendor'],
queryFn: () => base44.entities.Event.list('-date'),
});
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-auto-assign'],
queryFn: () => base44.entities.Staff.list(),
});
const { data: vendorRates = [] } = useQuery({
queryKey: ['vendor-rates-auto-assign'],
queryFn: () => base44.entities.VendorRate.list(),
initialData: [],
});
const updateEventMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['all-events-vendor'] }),
});
const autoAssignMutation = useMutation({
mutationFn: async (event) => {
const assignments = await autoFillShifts(event, allStaff, vendorEvents, vendorRates, assignmentOptions);
if (assignments.length === 0) throw new Error("No suitable staff found");
const updatedAssignedStaff = [...(event.assigned_staff || []), ...assignments];
const updatedShifts = (event.shifts || []).map(shift => {
const updatedRoles = (shift.roles || []).map(role => {
const roleAssignments = assignments.filter(a => a.role === role.role);
return { ...role, assigned: (role.assigned || 0) + roleAssignments.length };
});
return { ...shift, roles: updatedRoles };
});
const totalRequested = updatedShifts.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0);
const totalAssigned = updatedAssignedStaff.length;
let newStatus = event.status;
if (totalAssigned >= totalRequested && totalRequested > 0) {
newStatus = 'Fully Staffed';
} else if (totalAssigned > 0 && totalAssigned < totalRequested) {
newStatus = 'Partial Staffed';
} else if (totalAssigned === 0) {
newStatus = 'Pending';
}
await base44.entities.Event.update(event.id, {
assigned_staff: updatedAssignedStaff,
shifts: updatedShifts,
requested: (event.requested || 0) + assignments.length,
status: newStatus,
});
return assignments.length;
},
onSuccess: (count) => {
queryClient.invalidateQueries({ queryKey: ['all-events-vendor'] });
toast({ title: "✅ Auto-Assigned", description: `Assigned ${count} staff automatically` });
},
onError: (error) => {
toast({ title: "⚠️ Auto-Assign Failed", description: error.message, variant: "destructive" });
},
});
const vendorEvents = useMemo(() => {
return allEvents.filter(e =>
e.vendor_name === user?.company_name ||
e.vendor_id === user?.id ||
e.created_by === user?.email
);
}, [allEvents, user]);
const eventsWithConflicts = useMemo(() => {
return vendorEvents.map(event => {
const conflicts = detectAllConflicts(event, vendorEvents);
return { ...event, detected_conflicts: conflicts };
});
}, [vendorEvents]);
const totalConflicts = eventsWithConflicts.reduce((sum, e) => sum + (e.detected_conflicts?.length || 0), 0);
const filteredEvents = useMemo(() => {
let filtered = eventsWithConflicts;
if (activeTab === "upcoming") filtered = filtered.filter(e => { const eventDate = safeParseDate(e.date); return eventDate && eventDate > new Date(); });
else if (activeTab === "active") filtered = filtered.filter(e => e.status === "Active");
else if (activeTab === "past") filtered = filtered.filter(e => e.status === "Completed");
else if (activeTab === "conflicts") filtered = filtered.filter(e => e.detected_conflicts && e.detected_conflicts.length > 0);
if (searchTerm) {
const lower = searchTerm.toLowerCase();
filtered = filtered.filter(e =>
e.event_name?.toLowerCase().includes(lower) ||
e.business_name?.toLowerCase().includes(lower) ||
e.hub?.toLowerCase().includes(lower)
);
}
return filtered;
}, [eventsWithConflicts, searchTerm, activeTab]);
const getAssignmentStatus = (event) => {
const totalRequested = event.shifts?.reduce((accShift, shift) => {
return accShift + (shift.roles?.reduce((accRole, role) => accRole + (role.count || 0), 0) || 0);
}, 0) || 0;
const assigned = event.assigned_staff?.length || 0;
const fillPercent = totalRequested > 0 ? Math.round((assigned / totalRequested) * 100) : 0;
if (assigned === 0) return { color: 'bg-slate-100 text-slate-600', text: '0', percent: '0%', status: 'empty' };
if (totalRequested > 0 && assigned >= totalRequested) return { color: 'bg-emerald-500 text-white', text: assigned, percent: '100%', status: 'full' };
if (totalRequested > 0 && assigned < totalRequested) return { color: 'bg-orange-500 text-white', text: assigned, percent: `${fillPercent}%`, status: 'partial' };
return { color: 'bg-slate-500 text-white', text: assigned, percent: '0%', status: 'partial' };
};
const getTabCount = (tab) => {
if (tab === "all") return vendorEvents.length;
if (tab === "conflicts") return eventsWithConflicts.filter(e => e.detected_conflicts && e.detected_conflicts.length > 0).length;
if (tab === "upcoming") return vendorEvents.filter(e => { const eventDate = safeParseDate(e.date); return eventDate && eventDate > new Date(); }).length;
if (tab === "active") return vendorEvents.filter(e => e.status === "Active").length;
if (tab === "past") return vendorEvents.filter(e => e.status === "Completed").length;
return 0;
};
// The original handleAutoAssignEvent is removed as the button now opens the modal directly.
// const handleAutoAssignEvent = (event) => autoAssignMutation.mutate(event);
const getEventTimes = (event) => {
const firstShift = event.shifts?.[0];
const rolesInFirstShift = firstShift?.roles || [];
let startTime = null;
let endTime = null;
if (rolesInFirstShift.length > 0) {
startTime = rolesInFirstShift[0].start_time || null;
endTime = rolesInFirstShift[0].end_time || null;
}
return {
startTime: startTime ? convertTo12Hour(startTime) : "-",
endTime: endTime ? convertTo12Hour(endTime) : "-"
};
};
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1800px] mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900">Order Management</h1>
<p className="text-sm text-slate-500 mt-1">View, assign, and track all your orders</p>
</div>
{showConflicts && totalConflicts > 0 && (
<Alert className="mb-6 border-2 border-orange-500 bg-orange-50">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<AlertDescription className="font-semibold text-orange-900">
{totalConflicts} scheduling conflict{totalConflicts !== 1 ? 's' : ''} detected
</AlertDescription>
</div>
<Button variant="ghost" size="icon" onClick={() => setShowConflicts(false)} className="flex-shrink-0">
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border border-red-200 bg-red-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-500 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-red-600 font-semibold uppercase">RAPID</p>
<p className="text-2xl font-bold text-red-700">{vendorEvents.filter(e => e.is_rapid).length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-amber-200 bg-amber-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
<Clock className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-amber-600 font-semibold uppercase">REQUESTED</p>
<p className="text-2xl font-bold text-amber-700">{vendorEvents.filter(e => e.status === 'Pending' || e.status === 'Draft').length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-orange-200 bg-orange-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-orange-600 font-semibold uppercase">PARTIAL</p>
<p className="text-2xl font-bold text-orange-700">{vendorEvents.filter(e => {
const status = getAssignmentStatus(e);
return status.status === 'partial';
}).length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border border-emerald-200 bg-emerald-50">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-emerald-500 rounded-lg flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-emerald-600 font-semibold uppercase">FULLY STAFFED</p>
<p className="text-2xl font-bold text-emerald-700">{vendorEvents.filter(e => {
const status = getAssignmentStatus(e);
return status.status === 'full';
}).length}</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="bg-white rounded-xl p-4 mb-6 flex items-center gap-4 border shadow-sm">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input placeholder="Search by event, business, or location..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10 border-slate-200 h-10" />
</div>
<div className="flex items-center gap-2">
<Button variant={viewMode === "table" ? "default" : "outline"} size="sm" onClick={() => setViewMode("table")} className={viewMode === "table" ? "bg-[#0A39DF]" : ""}>
<List className="w-4 h-4" />
</Button>
<Button variant={viewMode === "scheduler" ? "default" : "outline"} size="sm" onClick={() => setViewMode("scheduler")} className={viewMode === "scheduler" ? "bg-[#0A39DF]" : ""}>
<LayoutGrid className="w-4 h-4" />
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-white border">
<TabsTrigger value="all">All ({getTabCount("all")})</TabsTrigger>
<TabsTrigger value="conflicts" className="data-[state=active]:bg-orange-500 data-[state=active]:text-white">
<AlertTriangle className="w-4 h-4 mr-2" />
Conflicts ({getTabCount("conflicts")})
</TabsTrigger>
<TabsTrigger value="upcoming">Upcoming ({getTabCount("upcoming")})</TabsTrigger>
<TabsTrigger value="active">Active ({getTabCount("active")})</TabsTrigger>
<TabsTrigger value="past">Past ({getTabCount("past")})</TabsTrigger>
</TabsList>
</Tabs>
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">INVOICE</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEvents.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center py-12 text-slate-500"><CalendarIcon className="w-12 h-12 mx-auto mb-3 text-slate-300" /><p className="font-medium">No orders found</p></TableCell></TableRow>
) : (
filteredEvents.map((event) => {
const assignmentStatus = getAssignmentStatus(event);
const showAutoButton = assignmentStatus.status !== 'full' && event.status !== 'Canceled' && event.status !== 'Completed';
const hasConflicts = event.detected_conflicts && event.detected_conflicts.length > 0;
const eventTimes = getEventTimes(event);
const eventDate = safeParseDate(event.date);
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
const invoiceReady = event.status === "Completed";
return (
<React.Fragment key={event.id}>
<TableRow className="hover:bg-slate-50 transition-colors border-b">
<TableCell className="py-3">
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center gap-1.5 text-sm text-slate-500">
<MapPin className="w-3.5 h-3.5" />
{event.hub || event.event_location || "Main Hub"}
</div>
</TableCell>
<TableCell className="py-3">
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
</TableCell>
<TableCell className="py-3">
<div className="space-y-0.5">
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
<p className="text-xs text-slate-500">{dayOfWeek}</p>
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
<Clock className="w-3 h-3" />
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
</div>
</div>
</TableCell>
<TableCell className="py-3">
{getStatusBadge(event, hasConflicts)}
</TableCell>
<TableCell className="text-center py-3">
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex flex-col items-center gap-1">
<Badge className={`${assignmentStatus.color} font-bold px-3 py-1 rounded-full text-xs`}>
{assignmentStatus.text}
</Badge>
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
</div>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex items-center justify-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${invoiceReady ? 'bg-blue-100' : 'bg-slate-100'}`}>
<FileText className={`w-5 h-5 ${invoiceReady ? 'text-blue-600' : 'text-slate-400'}`} />
</div>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => setAssignModal({ open: true, event: event })}
className="h-8 px-2 hover:bg-slate-100"
title="Smart Assign"
>
<UserCheck className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
{hasConflicts && activeTab === "conflicts" && (
<TableRow>
<TableCell colSpan={9} className="bg-orange-50/50 py-4">
<ConflictAlert conflicts={event.detected_conflicts} />
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})
)}
</TableBody>
</Table>
</div>
</div>
<SmartAssignModal
open={assignModal.open}
onClose={() => setAssignModal({ open: false, event: null })}
event={assignModal.event}
/>
</div>
);
}