1148 lines
70 KiB
JavaScript
1148 lines
70 KiB
JavaScript
|
||
import React, { useState } from "react";
|
||
import { base44 } from "@/api/base44Client";
|
||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||
import { Link } from "react-router-dom";
|
||
import { createPageUrl } from "@/utils";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import {
|
||
Package, Plus, Search, Users, Edit, ChevronLeft, ChevronRight,
|
||
Building2, DollarSign, Mail, CheckCircle2, XCircle, Clock, Eye,
|
||
Archive, LayoutGrid, List as ListIcon
|
||
} from "lucide-react";
|
||
import PageHeader from "../components/common/PageHeader";
|
||
import VendorScoreHoverCard from "../components/procurement/VendorScoreHoverCard";
|
||
import VendorDetailModal from "../components/procurement/VendorDetailModal";
|
||
import { useToast } from "@/components/ui/use-toast";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from "@/components/ui/dialog";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
|
||
export default function VendorManagement() {
|
||
const [searchTerm, setSearchTerm] = useState("");
|
||
const [regionFilter, setRegionFilter] = useState("all");
|
||
const [stateFilter, setStateFilter] = useState("all");
|
||
const [specialtyFilter, setSpecialtyFilter] = useState("all");
|
||
const [softwareFilter, setSoftwareFilter] = useState("all");
|
||
const [statusFilter, setStatusFilter] = useState("all");
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||
const [editingVendor, setEditingVendor] = useState(null);
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
const [reviewingVendor, setReviewingVendor] = useState(null);
|
||
const [showReviewModal, setShowReviewModal] = useState(false);
|
||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||
const [rejectionReason, setRejectionReason] = useState("");
|
||
const [viewMode, setViewMode] = useState("list"); // "list" or "grid"
|
||
const { toast } = useToast();
|
||
const queryClient = useQueryClient();
|
||
|
||
const { data: user } = useQuery({
|
||
queryKey: ['current-user-vendor-mgmt'],
|
||
queryFn: () => base44.auth.me(),
|
||
});
|
||
|
||
const userRole = user?.user_role || user?.role;
|
||
|
||
// Fetch pending vendors from database
|
||
const { data: pendingVendors = [] } = useQuery({
|
||
queryKey: ['pending-vendors'],
|
||
queryFn: () => base44.entities.Vendor.filter({ approval_status: 'pending' }, '-created_date'),
|
||
initialData: [],
|
||
});
|
||
|
||
// Fetch approved vendors
|
||
const { data: approvedVendors = [] } = useQuery({
|
||
queryKey: ['approved-vendors'],
|
||
queryFn: () => base44.entities.Vendor.list('-created_date'),
|
||
initialData: [],
|
||
});
|
||
|
||
// Fetch all vendor rates
|
||
const { data: allVendorRates = [] } = useQuery({
|
||
queryKey: ['all-vendor-rates'],
|
||
queryFn: () => base44.entities.VendorRate.list('-created_date'),
|
||
initialData: [],
|
||
});
|
||
|
||
// Approve vendor mutation
|
||
const approveVendorMutation = useMutation({
|
||
mutationFn: async (vendorId) => {
|
||
await base44.entities.Vendor.update(vendorId, {
|
||
approval_status: 'approved',
|
||
approved_date: new Date().toISOString(),
|
||
approved_by: user?.email,
|
||
is_active: true,
|
||
});
|
||
|
||
// Activate all vendor rates
|
||
const vendorRates = allVendorRates.filter(r => r.vendor_id === vendorId);
|
||
await Promise.all(
|
||
vendorRates.map(rate =>
|
||
base44.entities.VendorRate.update(rate.id, { is_active: true })
|
||
)
|
||
);
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries(['pending-vendors']);
|
||
queryClient.invalidateQueries(['approved-vendors']);
|
||
queryClient.invalidateQueries(['all-vendor-rates']);
|
||
toast({
|
||
title: "Vendor Approved",
|
||
description: "Vendor has been approved and activated",
|
||
});
|
||
setShowReviewModal(false);
|
||
setReviewingVendor(null);
|
||
},
|
||
});
|
||
|
||
// Reject vendor mutation
|
||
const rejectVendorMutation = useMutation({
|
||
mutationFn: async ({ vendorId, reason }) => {
|
||
await base44.entities.Vendor.update(vendorId, {
|
||
approval_status: 'suspended',
|
||
is_active: false,
|
||
notes: `Rejected: ${reason}`,
|
||
});
|
||
|
||
// Send rejection email
|
||
const vendor = [...pendingVendors, ...approvedVendors].find(v => v.id === vendorId);
|
||
if (vendor?.primary_contact_email) {
|
||
await base44.integrations.Core.SendEmail({
|
||
from_name: "KROW Platform",
|
||
to: vendor.primary_contact_email,
|
||
subject: "KROW Vendor Application - Additional Information Required",
|
||
body: `
|
||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||
<h2>Vendor Application Update</h2>
|
||
<p>Dear ${vendor.primary_contact_name || 'Vendor'},</p>
|
||
<p>Thank you for your interest in joining the KROW vendor network.</p>
|
||
<p>We've reviewed your application and need some additional information before we can proceed:</p>
|
||
<div style="background: #fef3c7; padding: 16px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #f59e0b;">
|
||
<p style="margin: 0; color: #92400e;"><strong>Reason:</strong> ${reason}</p>
|
||
</div>
|
||
<p>Please address the above and resubmit your application.</p>
|
||
<p>Best regards,<br>KROW Procurement Team</p>
|
||
</div>
|
||
`
|
||
});
|
||
}
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries(['pending-vendors']);
|
||
queryClient.invalidateQueries(['approved-vendors']);
|
||
toast({
|
||
title: "Vendor Rejected",
|
||
description: "Vendor has been notified and can resubmit",
|
||
});
|
||
setShowReviewModal(false);
|
||
setShowRejectModal(false);
|
||
setReviewingVendor(null);
|
||
setRejectionReason("");
|
||
},
|
||
});
|
||
|
||
// Archive vendor mutation
|
||
const archiveVendorMutation = useMutation({
|
||
mutationFn: async (vendorId) => {
|
||
await base44.entities.Vendor.update(vendorId, {
|
||
approval_status: 'archived',
|
||
is_active: false,
|
||
});
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries(['pending-vendors']);
|
||
queryClient.invalidateQueries(['approved-vendors']);
|
||
toast({
|
||
title: "Vendor Archived",
|
||
description: "Vendor has been archived",
|
||
});
|
||
},
|
||
});
|
||
|
||
// Mock vendors for existing list
|
||
const mockApprovedVendors = [
|
||
{ id: 'mock-1001', vendorNumber: "VN-1001", name: "Legendary Event Staffing", region: "Bay Area", state: "California", specialty: "Full Service Events", software: "Building platform (KROW)", softwareType: "building", employees: 450, fillRate: 97, onTimeRate: 98, csat: 4.7, spend: "$230k", approval_status: "approved", legal_name: "Legendary Event Staffing Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Jane Doe", primary_contact_email: "jane@legendary.com", w9_document: true, coi_document: true, coverage_regions: ["San Francisco", "Oakland", "San Jose"] },
|
||
{ id: 'mock-1002', vendorNumber: "VN-1002", name: "Instawork", region: "National", state: "Multiple", specialty: "On-Demand Gig Platform", software: "Full Platform", softwareType: "platform", employees: 5000, fillRate: 95, onTimeRate: 96, csat: 4.5, spend: "$215k", approval_status: "approved", legal_name: "Instawork Corp.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "John Smith", primary_contact_email: "john@instawork.com", w9_document: true, coi_document: true, coverage_regions: ["New York", "Los Angeles", "Chicago"] },
|
||
{ id: 'mock-1003', vendorNumber: "VN-1003", name: "The Party Staff", region: "Bay Area", state: "California", specialty: "Event Staffing", software: "Building platform (KROW)", softwareType: "building", employees: 320, fillRate: 94, onTimeRate: 97, csat: 4.6, spend: "$180k", approval_status: "approved", legal_name: "The Party Staff LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Sarah Johnson", primary_contact_email: "sarah@thepartystaff.com", w9_document: true, coi_document: true, coverage_regions: ["San Francisco", "Oakland"] },
|
||
{ id: 'mock-1004', vendorNumber: "VN-1004", name: "Elite Hospitality Staffing", region: "Bay Area", state: "California", specialty: "Hotels/Events", software: "Partial Tech", softwareType: "partial", employees: 280, fillRate: 92, onTimeRate: 95, csat: 4.4, spend: "$165k", approval_status: "approved", legal_name: "Elite Hospitality Staffing Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Michael Chen", primary_contact_email: "michael@elitehospitality.com", w9_document: true, coi_document: true, coverage_regions: ["Oakland", "San Jose"] },
|
||
{ id: 'mock-1005', vendorNumber: "VN-1005", name: "Jeff Duerson Staffing", region: "Bay Area", state: "California", specialty: "Catering/Events", software: "Traditional", softwareType: "traditional", employees: 150, fillRate: 90, onTimeRate: 93, csat: 4.3, spend: "$140k", approval_status: "approved", legal_name: "Jeff Duerson Staffing LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Jeff Duerson", primary_contact_email: "jeff@jdstaffing.com", w9_document: true, coi_document: true, coverage_regions: ["San Jose", "Palo Alto"] },
|
||
{ id: 'mock-1006', vendorNumber: "VN-1006", name: "Flagship Culinary Services", region: "Bay Area", state: "California", specialty: "Culinary", software: "Traditional", softwareType: "traditional", employees: 200, fillRate: 91, onTimeRate: 94, csat: 4.5, spend: "$155k", approval_status: "approved", legal_name: "Flagship Culinary Services Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Amanda Rodriguez", primary_contact_email: "amanda@flagshipculinary.com", w9_document: true, coi_document: true, coverage_regions: ["Palo Alto", "San Francisco"] },
|
||
{ id: 'mock-1007', vendorNumber: "VN-1007", name: "LA Event Professionals", region: "Southern California", state: "California", specialty: "Event Staffing", software: "Building platform (KROW)", softwareType: "building", employees: 380, fillRate: 93, onTimeRate: 96, csat: 4.5, spend: "$190k", approval_status: "approved", legal_name: "LA Event Professionals LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Carlos Martinez", primary_contact_email: "carlos@laeventpros.com", w9_document: true, coi_document: true, coverage_regions: ["Los Angeles", "Long Beach"] },
|
||
{ id: 'mock-1008', vendorNumber: "VN-1008", name: "Hollywood Hospitality", region: "Southern California", state: "California", specialty: "Full Service Events", software: "Full Platform", softwareType: "platform", employees: 420, fillRate: 94, onTimeRate: 97, csat: 4.6, spend: "$210k", approval_status: "approved", legal_name: "Hollywood Hospitality Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Jessica Lee", primary_contact_email: "jessica@hollywoodhospitality.com", w9_document: true, coi_document: true, coverage_regions: ["Los Angeles", "Beverly Hills"] },
|
||
{ id: 'mock-1009', vendorNumber: "VN-1009", name: "San Diego Event Staff", region: "Southern California", state: "California", specialty: "Hospitality", software: "Partial Tech", softwareType: "partial", employees: 250, fillRate: 91, onTimeRate: 94, csat: 4.4, spend: "$145k", approval_status: "approved", legal_name: "San Diego Event Staff LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "David Kim", primary_contact_email: "david@sdeventstaff.com", w9_document: true, coi_document: true, coverage_regions: ["San Diego", "Chula Vista"] },
|
||
{ id: 'mock-1010', vendorNumber: "VN-1010", name: "OC Staffing Solutions", region: "Southern California", state: "California", specialty: "Event-based staffing", software: "Traditional", softwareType: "traditional", employees: 180, fillRate: 89, onTimeRate: 92, csat: 4.2, spend: "$130k", approval_status: "approved", legal_name: "OC Staffing Solutions Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Emily Watson", primary_contact_email: "emily@ocstaffing.com", w9_document: true, coi_document: true, coverage_regions: ["Irvine", "Anaheim"] },
|
||
{ id: 'mock-1011', vendorNumber: "VN-1011", name: "NorCal Staffing", region: "Northern California", state: "California", specialty: "Event-based staffing", software: "Partial Tech", softwareType: "partial", employees: 220, fillRate: 90, onTimeRate: 93, csat: 4.3, spend: "$150k", approval_status: "approved", legal_name: "NorCal Staffing LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Robert Brown", primary_contact_email: "robert@norcalstaffing.com", w9_document: true, coi_document: true, coverage_regions: ["Sacramento", "Stockton"] },
|
||
{ id: 'mock-1012', vendorNumber: "VN-1012", name: "Kitchen & Cater Staffing", region: "Northern California", state: "California", specialty: "Catering", software: "Traditional", softwareType: "traditional", employees: 160, fillRate: 88, onTimeRate: 91, csat: 4.1, spend: "$125k", approval_status: "approved", legal_name: "Kitchen & Cater Staffing Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Lisa Chang", primary_contact_email: "lisa@kitchencater.com", w9_document: true, coi_document: true, coverage_regions: ["Sacramento", "Davis"] },
|
||
{ id: 'mock-1013', vendorNumber: "VN-1013", name: "NYC Event Masters", region: "East Coast", state: "New York", specialty: "Full Service Events", software: "Full Platform", softwareType: "platform", employees: 550, fillRate: 96, onTimeRate: 98, csat: 4.7, spend: "$240k", approval_status: "approved", legal_name: "NYC Event Masters Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "James Wilson", primary_contact_email: "james@nyceventmasters.com", w9_document: true, coi_document: true, coverage_regions: ["New York City", "Brooklyn", "Queens"] },
|
||
{ id: 'mock-1014', vendorNumber: "VN-1014", name: "Manhattan Staffing Group", region: "East Coast", state: "New York", specialty: "Hospitality", software: "Building platform (KROW)", softwareType: "building", employees: 410, fillRate: 94, onTimeRate: 96, csat: 4.5, spend: "$200k", approval_status: "approved", legal_name: "Manhattan Staffing Group LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Rachel Green", primary_contact_email: "rachel@manhattanstaffing.com", w9_document: true, coi_document: true, coverage_regions: ["Manhattan", "Bronx"] },
|
||
{ id: 'mock-1015', vendorNumber: "VN-1015", name: "Brooklyn Event Pros", region: "East Coast", state: "New York", specialty: "Event Staffing", software: "Partial Tech", softwareType: "partial", employees: 290, fillRate: 92, onTimeRate: 95, csat: 4.4, spend: "$170k", approval_status: "approved", legal_name: "Brooklyn Event Pros Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Daniel Park", primary_contact_email: "daniel@brooklyneventpros.com", w9_document: true, coi_document: true, coverage_regions: ["Brooklyn", "Queens"] },
|
||
{ id: 'mock-1016', vendorNumber: "VN-1016", name: "Upstate Event Services", region: "East Coast", state: "New York", specialty: "Regional Events", software: "Traditional", softwareType: "traditional", employees: 140, fillRate: 87, onTimeRate: 90, csat: 4.0, spend: "$115k", approval_status: "approved", legal_name: "Upstate Event Services LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Karen Miller", primary_contact_email: "karen@upstateeventservices.com", w9_document: true, coi_document: true, coverage_regions: ["Buffalo", "Rochester"] },
|
||
{ id: 'mock-1017', vendorNumber: "VN-1017", name: "Lone Star Staffing", region: "South", state: "Texas", specialty: "Full Service Events", software: "Building platform (KROW)", softwareType: "building", employees: 370, fillRate: 93, onTimeRate: 96, csat: 4.5, spend: "$185k", approval_status: "approved", legal_name: "Lone Star Staffing Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Austin Davis", primary_contact_email: "austin@lonestarstaffing.com", w9_document: true, coi_document: true, coverage_regions: ["Austin", "Round Rock"] },
|
||
{ id: 'mock-1018', vendorNumber: "VN-1018", name: "Houston Hospitality Hub", region: "South", state: "Texas", specialty: "Hospitality", software: "Full Platform", softwareType: "platform", employees: 460, fillRate: 95, onTimeRate: 97, csat: 4.6, spend: "$220k", approval_status: "approved", legal_name: "Houston Hospitality Hub LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Maria Garcia", primary_contact_email: "maria@houstonhospitality.com", w9_document: true, coi_document: true, coverage_regions: ["Houston", "Galveston"] },
|
||
{ id: 'mock-1019', vendorNumber: "VN-1019", name: "Dallas Event Specialists", region: "South", state: "Texas", specialty: "Event Staffing", software: "Partial Tech", softwareType: "partial", employees: 340, fillRate: 92, onTimeRate: 95, csat: 4.4, spend: "$175k", approval_status: "approved", legal_name: "Dallas Event Specialists Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Brandon White", primary_contact_email: "brandon@dallaseventspecialists.com", w9_document: true, coi_document: true, coverage_regions: ["Dallas", "Fort Worth"] },
|
||
{ id: 'mock-1020', vendorNumber: "VN-1020", name: "San Antonio Services", region: "South", state: "Texas", specialty: "Catering/Events", software: "Traditional", softwareType: "traditional", employees: 190, fillRate: 89, onTimeRate: 92, csat: 4.2, spend: "$135k", approval_status: "approved", legal_name: "San Antonio Services LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Jennifer Lopez", primary_contact_email: "jennifer@sanantonioservices.com", w9_document: true, coi_document: true, coverage_regions: ["San Antonio"] },
|
||
{ id: 'mock-1021', vendorNumber: "VN-1021", name: "Chicago Event Staffing", region: "Midwest", state: "Illinois", specialty: "Full Service Events", software: "Full Platform", softwareType: "platform", employees: 490, fillRate: 95, onTimeRate: 97, csat: 4.6, spend: "$225k", approval_status: "approved", legal_name: "Chicago Event Staffing Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Thomas Anderson", primary_contact_email: "thomas@chicagoeventstaffing.com", w9_document: true, coi_document: true, coverage_regions: ["Chicago", "Naperville"] },
|
||
{ id: 'mock-1022', vendorNumber: "VN-1022", name: "Windy City Hospitality", region: "Midwest", state: "Illinois", specialty: "Hotels/Events", software: "Building platform (KROW)", softwareType: "building", employees: 380, fillRate: 93, onTimeRate: 96, csat: 4.5, spend: "$190k", approval_status: "approved", legal_name: "Windy City Hospitality LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Nicole Taylor", primary_contact_email: "nicole@windycityhospitality.com", w9_document: true, coi_document: true, coverage_regions: ["Chicago", "Evanston"] },
|
||
{ id: 'mock-1023', vendorNumber: "VN-1023", name: "Miami Event Solutions", region: "South", state: "Florida", specialty: "Full Service Events", software: "Full Platform", softwareType: "platform", employees: 440, fillRate: 94, onTimeRate: 96, csat: 4.5, spend: "$205k", approval_status: "approved", legal_name: "Miami Event Solutions Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Carlos Hernandez", primary_contact_email: "carlos@miamievertsolutions.com", w9_document: true, coi_document: true, coverage_regions: ["Miami", "Fort Lauderdale"] },
|
||
{ id: 'mock-1024', vendorNumber: "VN-1024", name: "Orlando Hospitality Group", region: "South", state: "Florida", specialty: "Theme Park Events", software: "Building platform (KROW)", softwareType: "building", employees: 520, fillRate: 95, onTimeRate: 97, csat: 4.7, spend: "$235k", approval_status: "approved", legal_name: "Orlando Hospitality Group LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Michelle Scott", primary_contact_email: "michelle@orlandohospitality.com", w9_document: true, coi_document: true, coverage_regions: ["Orlando", "Kissimmee"] },
|
||
{ id: 'mock-1025', vendorNumber: "VN-1025", name: "Tampa Bay Staffing", region: "South", state: "Florida", specialty: "Event Staffing", software: "Partial Tech", softwareType: "partial", employees: 310, fillRate: 91, onTimeRate: 94, csat: 4.3, spend: "$160k", approval_status: "approved", legal_name: "Tampa Bay Staffing Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Kevin Moore", primary_contact_email: "kevin@tampabaystaffing.com", w9_document: true, coi_document: true, coverage_regions: ["Tampa", "St. Petersburg"] },
|
||
{ id: 'mock-1026', vendorNumber: "VN-1026", name: "Seattle Event Pros", region: "West", state: "Washington", specialty: "Tech Events", software: "Full Platform", softwareType: "platform", employees: 400, fillRate: 94, onTimeRate: 96, csat: 4.6, spend: "$200k", approval_status: "approved", legal_name: "Seattle Event Pros LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Amy Chen", primary_contact_email: "amy@seattleeventpros.com", w9_document: true, coi_document: true, coverage_regions: ["Seattle", "Bellevue"] },
|
||
{ id: 'mock-1027', vendorNumber: "VN-1027", name: "Pacific Northwest Staffing", region: "West", state: "Washington", specialty: "Full Service Events", software: "Building platform (KROW)", softwareType: "building", employees: 350, fillRate: 93, onTimeRate: 95, csat: 4.5, spend: "$180k", approval_status: "approved", legal_name: "Pacific Northwest Staffing Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Brian Lee", primary_contact_email: "brian@pnwstaffing.com", w9_document: true, coi_document: true, coverage_regions: ["Seattle", "Tacoma"] },
|
||
{ id: 'mock-1028', vendorNumber: "VN-1028", name: "Boston Event Services", region: "East Coast", state: "Massachusetts", specialty: "Corporate Events", software: "Full Platform", softwareType: "platform", employees: 370, fillRate: 93, onTimeRate: 96, csat: 4.5, spend: "$185k", approval_status: "approved", legal_name: "Boston Event Services LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Patrick Sullivan", primary_contact_email: "patrick@bostoneventservices.com", w9_document: true, coi_document: true, coverage_regions: ["Boston", "Cambridge"] },
|
||
{ id: 'mock-1029', vendorNumber: "VN-1029", name: "New England Hospitality", region: "East Coast", state: "Massachusetts", specialty: "Full Service Events", software: "Building platform (KROW)", softwareType: "building", employees: 320, fillRate: 92, onTimeRate: 95, csat: 4.4, spend: "$170k", approval_status: "approved", legal_name: "New England Hospitality Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Laura Murphy", primary_contact_email: "laura@nehospitality.com", w9_document: true, coi_document: true, coverage_regions: ["Boston", "Worcester"] },
|
||
{ id: 'mock-1030', vendorNumber: "VN-1030", name: "Atlanta Event Staffing", region: "South", state: "Georgia", specialty: "Full Service Events", software: "Full Platform", softwareType: "platform", employees: 420, fillRate: 94, onTimeRate: 96, csat: 4.6, spend: "$210k", approval_status: "approved", legal_name: "Atlanta Event Staffing LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Marcus Johnson", primary_contact_email: "marcus@atlantaeventstaffing.com", w9_document: true, coi_document: true, coverage_regions: ["Atlanta", "Marietta"] },
|
||
{ id: 'mock-1031', vendorNumber: "VN-1031", name: "Peach State Hospitality", region: "South", state: "Georgia", specialty: "Hospitality", software: "Building platform (KROW)", softwareType: "building", employees: 340, fillRate: 92, onTimeRate: 95, csat: 4.4, spend: "$175k", approval_status: "approved", legal_name: "Peach State Hospitality Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Stephanie Wright", primary_contact_email: "stephanie@peachstatehospitality.com", w9_document: true, coi_document: true, coverage_regions: ["Atlanta", "Savannah"] },
|
||
{ id: 'mock-1032', vendorNumber: "VN-1032", name: "Denver Event Solutions", region: "West", state: "Colorado", specialty: "Mountain Events", software: "Partial Tech", softwareType: "partial", employees: 280, fillRate: 91, onTimeRate: 94, csat: 4.3, spend: "$150k", approval_status: "approved", legal_name: "Denver Event Solutions LLC", tax_id: "XX-XXXXXXX", business_type: "LLC", primary_contact_name: "Jake Thompson", primary_contact_email: "jake@denvereventsolutions.com", w9_document: true, coi_document: true, coverage_regions: ["Denver", "Boulder"] },
|
||
{ id: 'mock-1033', vendorNumber: "VN-1033", name: "Rocky Mountain Staffing", region: "West", state: "Colorado", specialty: "Event Staffing", software: "Traditional", softwareType: "traditional", employees: 230, fillRate: 89, onTimeRate: 92, csat: 4.2, spend: "$140k", approval_status: "approved", legal_name: "Rocky Mountain Staffing Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Hannah Davis", primary_contact_email: "hannah@rockymountainstaffing.com", w9_document: true, coi_document: true, coverage_regions: ["Denver", "Colorado Springs"] },
|
||
{ id: 'mock-1034', vendorNumber: "VN-1034", name: "Hospitality Staffing Solutions (HSS)", region: "National", state: "Multiple", specialty: "Hospitality", software: "Full Platform", softwareType: "platform", employees: 4200, fillRate: 96, onTimeRate: 98, csat: 4.7, spend: "$195k", approval_status: "approved", legal_name: "HSS Corporation", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Richard Lawson", primary_contact_email: "richard@hss.com", w9_document: true, coi_document: true, coverage_regions: ["National Coverage"] },
|
||
{ id: 'mock-1035', vendorNumber: "VN-1035", name: "Bluecrew", region: "National", state: "Multiple", specialty: "W-2 Staffing Platform", software: "Full Platform", softwareType: "platform", employees: 3800, fillRate: 95, onTimeRate: 97, csat: 4.6, spend: "$185k", approval_status: "approved", legal_name: "Bluecrew Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Chris Miller", primary_contact_email: "chris@bluecrew.com", w9_document: true, coi_document: true, coverage_regions: ["National Coverage"] },
|
||
{ id: 'mock-1036', vendorNumber: "VN-1036", name: "Qwick", region: "National", state: "Multiple", specialty: "Hospitality Platform", software: "Full Platform", softwareType: "platform", employees: 3500, fillRate: 94, onTimeRate: 96, csat: 4.5, spend: "$175k", approval_status: "approved", legal_name: "Qwick Inc.", tax_id: "XX-XXXXXXX", business_type: "Corporation", primary_contact_name: "Sarah Mitchell", primary_contact_email: "sarah@qwick.com", w9_document: true, coi_document: true, coverage_regions: ["National Coverage"] },
|
||
];
|
||
|
||
// Combine all vendors (mock + database approved + database pending)
|
||
const allVendorsMap = new Map();
|
||
mockApprovedVendors.forEach(vendor => allVendorsMap.set(vendor.id, vendor));
|
||
approvedVendors.forEach(vendor => {
|
||
if (!allVendorsMap.has(vendor.id)) {
|
||
allVendorsMap.set(vendor.id, {
|
||
...vendor,
|
||
name: vendor.legal_name,
|
||
vendorNumber: vendor.vendor_number,
|
||
});
|
||
}
|
||
});
|
||
const allVendors = Array.from(allVendorsMap.values());
|
||
|
||
// Calculate status counts
|
||
const statusCounts = {
|
||
all: allVendors.length,
|
||
approved: allVendors.filter(v => v.approval_status === "approved").length,
|
||
pending: pendingVendors.length,
|
||
suspended: allVendors.filter(v => v.approval_status === "suspended").length,
|
||
terminated: allVendors.filter(v => v.approval_status === "terminated").length,
|
||
archived: allVendors.filter(v => v.approval_status === "archived").length,
|
||
};
|
||
|
||
// Get unique values for filters
|
||
const uniqueRegions = [...new Set(allVendors.map(v => v.region).filter(Boolean))].sort();
|
||
const uniqueStates = [...new Set(allVendors.map(v => v.state).filter(Boolean))].sort();
|
||
const uniqueSpecialties = [...new Set(allVendors.map(v => v.specialty).filter(Boolean))].sort();
|
||
|
||
// Apply filters with proper logic
|
||
const filteredVendors = allVendors.filter(vendor => {
|
||
const matchesSearch = !searchTerm ||
|
||
vendor.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
vendor.vendorNumber?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
vendor.vendor_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
vendor.specialty?.toLowerCase().includes(searchTerm.toLowerCase());
|
||
|
||
const matchesRegion = regionFilter === "all" || vendor.region === regionFilter;
|
||
const matchesSpecialty = specialtyFilter === "all" || vendor.specialty === specialtyFilter;
|
||
const matchesState = stateFilter === "all" || vendor.state === stateFilter;
|
||
const matchesSoftware = softwareFilter === "all" || vendor.softwareType === softwareFilter;
|
||
const matchesStatus = statusFilter === "all" || vendor.approval_status === statusFilter;
|
||
|
||
return matchesSearch && matchesRegion && matchesSpecialty && matchesState && matchesSoftware && matchesStatus;
|
||
});
|
||
|
||
// Reset to page 1 when filters change
|
||
React.useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [searchTerm, regionFilter, stateFilter, specialtyFilter, softwareFilter, statusFilter]);
|
||
|
||
// Pagination
|
||
const totalPages = Math.ceil(filteredVendors.length / itemsPerPage);
|
||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||
const endIndex = startIndex + itemsPerPage;
|
||
const paginatedVendors = filteredVendors.slice(startIndex, endIndex);
|
||
|
||
const getSoftwareBadge = (type, label) => {
|
||
if (type === "platform") {
|
||
return <Badge className="bg-green-100 text-green-700">✅ {label}</Badge>;
|
||
} else if (type === "building") {
|
||
return <Badge className="bg-blue-100 text-blue-700">⚙️ {label}</Badge>;
|
||
} else if (type === "partial") {
|
||
return <Badge className="bg-yellow-100 text-yellow-700">⚙️ {label}</Badge>;
|
||
} else {
|
||
return <Badge className="bg-gray-100 text-gray-700">❌ {label}</Badge>;
|
||
}
|
||
};
|
||
|
||
const handleEditVendor = (vendor) => {
|
||
setEditingVendor(vendor);
|
||
setShowEditModal(true);
|
||
};
|
||
|
||
const handleReviewVendor = (vendor) => {
|
||
setReviewingVendor(vendor);
|
||
setShowReviewModal(true);
|
||
};
|
||
|
||
const handleRejectClick = () => {
|
||
setShowRejectModal(true);
|
||
};
|
||
|
||
const getStatusColor = (status) => {
|
||
const colors = {
|
||
all: "from-slate-600 to-slate-700",
|
||
approved: "from-emerald-500 to-emerald-600",
|
||
pending: "from-amber-500 to-amber-600",
|
||
suspended: "from-orange-500 to-orange-600",
|
||
terminated: "from-red-500 to-red-600",
|
||
archived: "from-slate-400 to-slate-500"
|
||
};
|
||
return colors[status] || colors.all;
|
||
};
|
||
|
||
const getStatusIcon = (status) => {
|
||
const icons = {
|
||
all: Package,
|
||
approved: CheckCircle2,
|
||
pending: Clock,
|
||
suspended: XCircle,
|
||
terminated: XCircle,
|
||
archived: Archive
|
||
};
|
||
return icons[status] || Package;
|
||
};
|
||
|
||
const getStatusLabel = (status) => {
|
||
const labels = {
|
||
all: "All",
|
||
approved: "Active",
|
||
pending: "Review",
|
||
suspended: "Hold",
|
||
terminated: "Closed",
|
||
archived: "Archived"
|
||
};
|
||
return labels[status] || status;
|
||
};
|
||
|
||
return (
|
||
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
|
||
<div className="max-w-7xl mx-auto">
|
||
<PageHeader
|
||
title="Vendor Management"
|
||
subtitle={`${statusCounts.pending} pending • ${statusCounts.approved} approved`}
|
||
actions={
|
||
<div className="flex gap-2">
|
||
<Link to={createPageUrl("InviteVendor")}>
|
||
<Button className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white shadow-lg">
|
||
<Mail className="w-5 h-5 mr-2" />
|
||
Invite Vendor
|
||
</Button>
|
||
</Link>
|
||
<Link to={createPageUrl("SmartVendorOnboarding")}>
|
||
<Button className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg">
|
||
<Plus className="w-5 h-5 mr-2" />
|
||
Vendor Onboard
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{/* Status Cards */}
|
||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-6">
|
||
{/* All Vendors */}
|
||
<Card
|
||
className={`cursor-pointer transition-all duration-200 border-2 ${
|
||
statusFilter === "all" ? "border-slate-600 shadow-lg scale-105" : "border-slate-200 hover:shadow-md hover:border-slate-300"
|
||
}`}
|
||
onClick={() => setStatusFilter("all")}
|
||
>
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className={`w-12 h-12 bg-gradient-to-br ${getStatusColor("all")} rounded-xl flex items-center justify-center`}>
|
||
<Package className="w-6 h-6 text-white" />
|
||
</div>
|
||
<Badge className="bg-slate-100 text-slate-700 text-xs">{getStatusLabel("all")}</Badge>
|
||
</div>
|
||
<div className="text-3xl font-bold text-slate-900 mb-1">{statusCounts.all}</div>
|
||
<p className="text-sm text-slate-500">Total Vendors</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Approved */}
|
||
<Card
|
||
className={`cursor-pointer transition-all duration-200 border-2 ${
|
||
statusFilter === "approved" ? "border-emerald-600 shadow-lg scale-105" : "border-slate-200 hover:shadow-md hover:border-emerald-200"
|
||
}`}
|
||
onClick={() => setStatusFilter("approved")}
|
||
>
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className={`w-12 h-12 bg-gradient-to-br ${getStatusColor("approved")} rounded-xl flex items-center justify-center`}>
|
||
<CheckCircle2 className="w-6 h-6 text-white" />
|
||
</div>
|
||
<Badge className="bg-emerald-100 text-emerald-700 text-xs">{getStatusLabel("approved")}</Badge>
|
||
</div>
|
||
<div className="text-3xl font-bold text-emerald-700 mb-1">{statusCounts.approved}</div>
|
||
<p className="text-sm text-slate-500">Approved</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Pending */}
|
||
<Card
|
||
className={`cursor-pointer transition-all duration-200 border-2 ${
|
||
statusFilter === "pending" ? "border-amber-600 shadow-lg scale-105" : "border-slate-200 hover:shadow-md hover:border-amber-200"
|
||
}`}
|
||
onClick={() => setStatusFilter("pending")}
|
||
>
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className={`w-12 h-12 bg-gradient-to-br ${getStatusColor("pending")} rounded-xl flex items-center justify-center`}>
|
||
<Clock className="w-6 h-6 text-white" />
|
||
</div>
|
||
<Badge className="bg-amber-100 text-amber-700 text-xs">{getStatusLabel("pending")}</Badge>
|
||
</div>
|
||
<div className="text-3xl font-bold text-amber-700 mb-1">{statusCounts.pending}</div>
|
||
<p className="text-sm text-slate-500">Pending</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Suspended */}
|
||
<Card
|
||
className={`cursor-pointer transition-all duration-200 border-2 ${
|
||
statusFilter === "suspended" ? "border-orange-600 shadow-lg scale-105" : "border-slate-200 hover:shadow-md hover:border-orange-200"
|
||
}`}
|
||
onClick={() => setStatusFilter("suspended")}
|
||
>
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className={`w-12 h-12 bg-gradient-to-br ${getStatusColor("suspended")} rounded-xl flex items-center justify-center`}>
|
||
<XCircle className="w-6 h-6 text-white" />
|
||
</div>
|
||
<Badge className="bg-orange-100 text-orange-700 text-xs">{getStatusLabel("suspended")}</Badge>
|
||
</div>
|
||
<div className="text-3xl font-bold text-orange-700 mb-1">{statusCounts.suspended}</div>
|
||
<p className="text-sm text-slate-500">Suspended</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Terminated */}
|
||
<Card
|
||
className={`cursor-pointer transition-all duration-200 border-2 ${
|
||
statusFilter === "terminated" ? "border-red-600 shadow-lg scale-105" : "border-slate-200 hover:shadow-md hover:border-red-200"
|
||
}`}
|
||
onClick={() => setStatusFilter("terminated")}
|
||
>
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className={`w-12 h-12 bg-gradient-to-br ${getStatusColor("terminated")} rounded-xl flex items-center justify-center`}>
|
||
<XCircle className="w-6 h-6 text-white" />
|
||
</div>
|
||
<Badge className="bg-red-100 text-red-700 text-xs">{getStatusLabel("terminated")}</Badge>
|
||
</div>
|
||
<div className="text-3xl font-bold text-red-700 mb-1">{statusCounts.terminated}</div>
|
||
<p className="text-sm text-slate-500">Terminated</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Archived */}
|
||
<Card
|
||
className={`cursor-pointer transition-all duration-200 border-2 ${
|
||
statusFilter === "archived" ? "border-slate-600 shadow-lg scale-105" : "border-slate-200 hover:shadow-md hover:border-slate-300"
|
||
}`}
|
||
onClick={() => setStatusFilter("archived")}
|
||
>
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className={`w-12 h-12 bg-gradient-to-br ${getStatusColor("archived")} rounded-xl flex items-center justify-center`}>
|
||
<Archive className="w-6 h-6 text-white" />
|
||
</div>
|
||
<Badge className="bg-slate-100 text-slate-700 text-xs">{getStatusLabel("archived")}</Badge>
|
||
</div>
|
||
<div className="text-3xl font-bold text-slate-700 mb-1">{statusCounts.archived}</div>
|
||
<p className="text-sm text-slate-500">Archived</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Search and Filters */}
|
||
<Card className="mb-6 border-slate-200">
|
||
<CardContent className="p-4">
|
||
<div className="space-y-4">
|
||
<div className="flex gap-4 items-center">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||
<Input
|
||
placeholder="Search by vendor name, number, or specialty..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
|
||
{/* View Mode Toggle */}
|
||
<div className="flex items-center gap-1 bg-slate-100 p-1 rounded-lg">
|
||
<Button
|
||
size="sm"
|
||
variant={viewMode === "list" ? "default" : "ghost"}
|
||
onClick={() => setViewMode("list")}
|
||
className={viewMode === "list" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90" : "hover:bg-white"}
|
||
>
|
||
<ListIcon className="w-4 h-4" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||
onClick={() => setViewMode("grid")}
|
||
className={viewMode === "grid" ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90" : "hover:bg-white"}
|
||
>
|
||
<LayoutGrid className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||
<div>
|
||
<Label className="text-xs text-slate-600 mb-1 block">Region</Label>
|
||
<Select value={regionFilter} onValueChange={setRegionFilter}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">All Regions</SelectItem>
|
||
{uniqueRegions.map(region => (
|
||
<SelectItem key={region} value={region}>{region}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs text-slate-600 mb-1 block">State</Label>
|
||
<Select value={stateFilter} onValueChange={setStateFilter}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">All States</SelectItem>
|
||
{uniqueStates.map(state => (
|
||
<SelectItem key={state} value={state}>{state}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs text-slate-600 mb-1 block">Specialty</Label>
|
||
<Select value={specialtyFilter} onValueChange={setSpecialtyFilter}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">All Specialties</SelectItem>
|
||
{uniqueSpecialties.map(specialty => (
|
||
<SelectItem key={specialty} value={specialty}>{specialty}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs text-slate-600 mb-1 block">Software</Label>
|
||
<Select value={softwareFilter} onValueChange={setSoftwareFilter}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">All Software</SelectItem>
|
||
<SelectItem value="platform">Full Platform</SelectItem>
|
||
<SelectItem value="building">Building Platform</SelectItem>
|
||
<SelectItem value="partial">Partial Tech</SelectItem>
|
||
<SelectItem value="traditional">Traditional</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs text-slate-600 mb-1 block">Per Page</Label>
|
||
<Select value={itemsPerPage.toString()} onValueChange={(value) => setItemsPerPage(Number(value))}>
|
||
<SelectTrigger>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="10">10 per page</SelectItem>
|
||
<SelectItem value="25">25 per page</SelectItem>
|
||
<SelectItem value="50">50 per page</SelectItem>
|
||
<SelectItem value="100">100 per page</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Active Filters Display */}
|
||
{(searchTerm || regionFilter !== "all" || stateFilter !== "all" || specialtyFilter !== "all" || softwareFilter !== "all" || statusFilter !== "all") && (
|
||
<div className="flex flex-wrap gap-2 pt-2 border-t border-slate-200">
|
||
<span className="text-sm text-slate-600 font-medium">Active Filters:</span>
|
||
{searchTerm && (
|
||
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
||
Search: "{searchTerm}"
|
||
<button onClick={() => setSearchTerm("")} className="ml-2 hover:text-blue-900">×</button>
|
||
</Badge>
|
||
)}
|
||
{statusFilter !== "all" && (
|
||
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
||
Status: {statusFilter}
|
||
<button onClick={() => setStatusFilter("all")} className="ml-2 hover:text-emerald-900">×</button>
|
||
</Badge>
|
||
)}
|
||
{regionFilter !== "all" && (
|
||
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200">
|
||
Region: {regionFilter}
|
||
<button onClick={() => setRegionFilter("all")} className="ml-2 hover:text-purple-900">×</button>
|
||
</Badge>
|
||
)}
|
||
{stateFilter !== "all" && (
|
||
<Badge variant="outline" className="bg-pink-50 text-pink-700 border-pink-200">
|
||
State: {stateFilter}
|
||
<button onClick={() => setStateFilter("all")} className="ml-2 hover:text-pink-900">×</button>
|
||
</Badge>
|
||
)}
|
||
{specialtyFilter !== "all" && (
|
||
<Badge variant="outline" className="bg-indigo-50 text-indigo-700 border-indigo-200">
|
||
Specialty: {specialtyFilter}
|
||
<button onClick={() => setSpecialtyFilter("all")} className="ml-2 hover:text-indigo-900">×</button>
|
||
</Badge>
|
||
)}
|
||
{softwareFilter !== "all" && (
|
||
<Badge variant="outline" className="bg-cyan-50 text-cyan-700 border-cyan-200">
|
||
Software: {softwareFilter}
|
||
<button onClick={() => setSoftwareFilter("all")} className="ml-2 hover:text-cyan-900">×</button>
|
||
</Badge>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
setSearchTerm("");
|
||
setRegionFilter("all");
|
||
setStateFilter("all");
|
||
setSpecialtyFilter("all");
|
||
setSoftwareFilter("all");
|
||
setStatusFilter("all");
|
||
}}
|
||
className="text-red-600 hover:text-red-700 hover:bg-red-50 h-6"
|
||
>
|
||
Clear All
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Vendor Directory - Grid or List View */}
|
||
{viewMode === "grid" ? (
|
||
// Grid View
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{paginatedVendors.map((vendor, idx) => (
|
||
<Card key={vendor.id || idx} className={`border-slate-200 hover:shadow-lg transition-shadow ${vendor.approval_status === 'pending' ? 'border-2 border-amber-200 bg-amber-50/30' : ''}`}>
|
||
<CardContent className="p-6">
|
||
<div className="flex items-start gap-4 mb-4">
|
||
<div className={`w-12 h-12 bg-gradient-to-br ${vendor.approval_status === 'pending' ? 'from-amber-500 to-amber-600' : 'from-[#0A39DF] to-[#1C323E]'} rounded-lg flex items-center justify-center`}>
|
||
{vendor.approval_status === 'pending' ? (
|
||
<Clock className="w-6 h-6 text-white" />
|
||
) : (
|
||
<Building2 className="w-6 h-6 text-white" />
|
||
)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-bold text-[#1C323E] truncate">{vendor.name || vendor.legal_name}</h3>
|
||
<p className="text-xs text-slate-500">{vendor.vendorNumber || vendor.vendor_number}</p>
|
||
{vendor.approval_status === 'pending' && (
|
||
<Badge className="bg-amber-100 text-amber-700 text-xs mt-1">Pending Review</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2 mb-4 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-slate-500">Region:</span>
|
||
<span className="font-medium">{vendor.region || '—'}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-slate-500">Specialty:</span>
|
||
<span className="font-medium truncate ml-2">{vendor.specialty || '—'}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-slate-500">Staff:</span>
|
||
<span className="font-medium">{vendor.employees ? vendor.employees.toLocaleString() : 'N/A'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
{vendor.approval_status === 'pending' ? (
|
||
<Button
|
||
size="sm"
|
||
onClick={() => handleReviewVendor(vendor)}
|
||
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||
>
|
||
<Eye className="w-4 h-4 mr-1" />
|
||
Review
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="flex-1"
|
||
onClick={() => handleEditVendor(vendor)}
|
||
>
|
||
<Edit className="w-4 h-4 mr-1" />
|
||
Edit
|
||
</Button>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="outline" size="sm">
|
||
•••
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => archiveVendorMutation.mutate(vendor.id)}>
|
||
<Archive className="w-4 h-4 mr-2" />
|
||
Archive
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
) : (
|
||
// List View (Table)
|
||
<Card className="border-slate-200 shadow-lg">
|
||
<CardContent className="p-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-slate-50 border-b-2 border-slate-200">
|
||
<tr>
|
||
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Vendor Name</th>
|
||
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Region</th>
|
||
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">State</th>
|
||
<th className="text-left py-4 px-4 font-semibold text-sm text-slate-700">Specialty</th>
|
||
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Total Staff</th>
|
||
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Software/Platform</th>
|
||
<th className="text-center py-4 px-4 font-semibold text-sm text-slate-700">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paginatedVendors.length > 0 ? (
|
||
paginatedVendors.map((vendor, idx) => (
|
||
<tr key={vendor.id || idx} className={`border-b border-slate-100 hover:bg-slate-50 transition-colors ${vendor.approval_status === 'pending' ? 'bg-amber-50/30' : ''}`}>
|
||
<td className="py-4 px-4">
|
||
<VendorScoreHoverCard vendor={vendor}>
|
||
<div
|
||
className="flex items-center gap-3 cursor-pointer"
|
||
onClick={() => {
|
||
if (vendor.approval_status === 'pending') {
|
||
handleReviewVendor(vendor);
|
||
} else {
|
||
handleEditVendor(vendor);
|
||
}
|
||
}}
|
||
>
|
||
<div className={`w-10 h-10 bg-gradient-to-br ${vendor.approval_status === 'pending' ? 'from-amber-500 to-amber-600' : 'from-[#0A39DF] to-[#1C323E]'} rounded-lg flex items-center justify-center`}>
|
||
{vendor.approval_status === 'pending' ? (
|
||
<Clock className="w-5 h-5 text-white" />
|
||
) : (
|
||
<Building2 className="w-5 h-5 text-white" />
|
||
)}
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-[#1C323E] hover:text-[#0A39DF] flex items-center gap-2">
|
||
{vendor.name || vendor.legal_name}
|
||
{vendor.approval_status === 'pending' && (
|
||
<Badge className="bg-amber-100 text-amber-700 text-xs">Pending Review</Badge>
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</VendorScoreHoverCard>
|
||
</td>
|
||
<td className="py-4 px-4 text-sm text-slate-700">{vendor.region || '—'}</td>
|
||
<td className="py-4 px-4 text-sm text-slate-700">{vendor.state || '—'}</td>
|
||
<td className="py-4 px-4 text-sm text-slate-700">{vendor.specialty || '—'}</td>
|
||
<td className="py-4 px-4 text-center">
|
||
<Badge variant="outline" className="font-semibold text-[#0A39DF] border-[#0A39DF]">
|
||
<Users className="w-3 h-3 mr-1" />
|
||
{vendor.employees ? vendor.employees.toLocaleString() : 'N/A'}
|
||
</Badge>
|
||
</td>
|
||
<td className="py-4 px-4 text-center">
|
||
{vendor.software ? getSoftwareBadge(vendor.softwareType, vendor.software) : '—'}
|
||
</td>
|
||
<td className="py-4 px-4">
|
||
<div className="flex items-center justify-center gap-2">
|
||
{vendor.approval_status === 'pending' ? (
|
||
<Button
|
||
size="sm"
|
||
onClick={() => handleReviewVendor(vendor)}
|
||
className="bg-amber-600 hover:bg-amber-700 text-white"
|
||
>
|
||
<Eye className="w-4 h-4 mr-1" />
|
||
Review
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<Link to={createPageUrl("VendorRateCard")}>
|
||
<Button variant="ghost" size="sm" className="text-[#0A39DF] hover:bg-[#0A39DF]/10">
|
||
<DollarSign className="w-4 h-4 mr-1" />
|
||
Rates
|
||
</Button>
|
||
</Link>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-slate-600 hover:bg-slate-100"
|
||
onClick={() => handleEditVendor(vendor)}
|
||
>
|
||
<Edit className="w-4 h-4 mr-1" />
|
||
Edit
|
||
</Button>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="sm">
|
||
•••
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => archiveVendorMutation.mutate(vendor.id)}>
|
||
<Archive className="w-4 h-4 mr-2" />
|
||
Archive
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
) : (
|
||
<tr>
|
||
<td colSpan="7" className="py-12 text-center">
|
||
<Building2 className="w-12 h-12 mx-auto text-slate-300 mb-3" />
|
||
<p className="text-slate-600 font-medium">No vendors found</p>
|
||
<p className="text-sm text-slate-500 mt-1">Try adjusting your search or filters</p>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between p-4 border-t border-slate-200 bg-slate-50">
|
||
<div className="text-sm text-slate-600">
|
||
Page {currentPage} of {totalPages}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||
disabled={currentPage === 1}
|
||
>
|
||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||
Previous
|
||
</Button>
|
||
|
||
<div className="flex gap-1">
|
||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||
let pageNum;
|
||
if (totalPages <= 5) {
|
||
pageNum = i + 1;
|
||
} else if (currentPage <= 3) {
|
||
pageNum = i + 1;
|
||
} else if (currentPage >= totalPages - 2) {
|
||
pageNum = totalPages - 4 + i;
|
||
} else {
|
||
pageNum = currentPage - 2 + i;
|
||
}
|
||
|
||
return (
|
||
<Button
|
||
key={pageNum}
|
||
variant={currentPage === pageNum ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => setCurrentPage(pageNum)}
|
||
className={currentPage === pageNum ? "bg-[#0A39DF] hover:bg-[#0A39DF]/90" : ""}
|
||
>
|
||
{pageNum}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||
disabled={currentPage === totalPages}
|
||
>
|
||
Next
|
||
<ChevronRight className="w-4 h-4 ml-1" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
|
||
{/* Edit Vendor Modal */}
|
||
<VendorDetailModal
|
||
vendor={editingVendor}
|
||
open={showEditModal}
|
||
onClose={() => {
|
||
setShowEditModal(false);
|
||
setEditingVendor(null);
|
||
}}
|
||
onEdit={(vendor) => {}}
|
||
/>
|
||
|
||
{/* Quick Reject Modal (for reject button in header) */}
|
||
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Reject Pending Vendors</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-slate-600">
|
||
You have {pendingVendors.length} pending vendor{pendingVendors.length !== 1 ? 's' : ''}. Select vendors to reject:
|
||
</p>
|
||
|
||
<div className="max-h-60 overflow-y-auto space-y-2">
|
||
{pendingVendors.map((vendor) => (
|
||
<Card key={vendor.id} className="p-3 cursor-pointer hover:bg-slate-50" onClick={() => handleReviewVendor(vendor)}>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="font-medium">{vendor.legal_name}</p>
|
||
<p className="text-xs text-slate-500">{vendor.vendor_number}</p>
|
||
</div>
|
||
<Button size="sm" variant="outline">Review</Button>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowRejectModal(false)}>
|
||
Cancel
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Review & Approve Modal */}
|
||
<Dialog open={showReviewModal} onOpenChange={setShowReviewModal}>
|
||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-2xl">Review Vendor Application</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{reviewingVendor && (
|
||
<div className="space-y-6">
|
||
{/* Vendor Info */}
|
||
<Card className="bg-slate-50">
|
||
<CardContent className="p-4">
|
||
<h3 className="font-semibold text-lg mb-3">{reviewingVendor.legal_name || reviewingVendor.name}</h3>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-slate-500">Vendor Number:</span>
|
||
<span className="ml-2 font-medium">{reviewingVendor.vendor_number || reviewingVendor.vendorNumber}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-slate-500">Business Type:</span>
|
||
<span className="ml-2 font-medium">{reviewingVendor.business_type || 'N/A'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-slate-500">Tax ID:</span>
|
||
<span className="ml-2 font-medium">{reviewingVendor.tax_id || 'N/A'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-slate-500">Contact:</span>
|
||
<span className="ml-2 font-medium">{reviewingVendor.primary_contact_name || 'N/A'}</span>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Documents */}
|
||
<div>
|
||
<h4 className="font-semibold mb-2">Documents</h4>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{reviewingVendor.w9_document ? (
|
||
<Card>
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center gap-2">
|
||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||
<span className="text-sm font-medium">W-9 Form</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<Card>
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center gap-2 text-slate-500">
|
||
<XCircle className="w-4 h-4 text-red-500" />
|
||
<span className="text-sm font-medium">W-9 Form (Missing)</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
{reviewingVendor.coi_document ? (
|
||
<Card>
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center gap-2">
|
||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||
<span className="text-sm font-medium">COI</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<Card>
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center gap-2 text-slate-500">
|
||
<XCircle className="w-4 h-4 text-red-500" />
|
||
<span className="text-sm font-medium">COI (Missing)</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Service Coverage */}
|
||
<div>
|
||
<h4 className="font-semibold mb-2">Service Coverage</h4>
|
||
{reviewingVendor.coverage_regions && reviewingVendor.coverage_regions.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{reviewingVendor.coverage_regions.map((city, i) => (
|
||
<Badge key={i} variant="outline">{city}</Badge>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-slate-500">No coverage regions specified.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Rate Proposals */}
|
||
<div>
|
||
<h4 className="font-semibold mb-2">Rate Proposals ({allVendorRates.filter(r => r.vendor_id === reviewingVendor.id).length})</h4>
|
||
<div className="space-y-2 max-h-60 overflow-y-auto pr-2">
|
||
{allVendorRates.filter(r => r.vendor_id === reviewingVendor.id).length > 0 ? (
|
||
allVendorRates.filter(r => r.vendor_id === reviewingVendor.id).map((rate, i) => (
|
||
<Card key={i}>
|
||
<CardContent className="p-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<span className="font-medium">{rate.role_name}</span>
|
||
<span className="text-xs text-slate-500 ml-2">{rate.category}</span>
|
||
</div>
|
||
<div className="flex items-center gap-4 text-sm">
|
||
<span className="text-slate-600">Pay: <strong className="text-green-600">${rate.employee_wage}</strong></span>
|
||
<span className="text-slate-600">Markup: <strong className="text-blue-600">{rate.markup_percentage}%</strong></span>
|
||
<span className="text-slate-600">Bill: <strong className="text-[#0A39DF]">${rate.client_rate}</strong></span>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))
|
||
) : (
|
||
<p className="text-sm text-slate-500">No rate proposals submitted yet.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rejection Reason (optional) */}
|
||
<div>
|
||
<Label htmlFor="rejection_reason">Rejection Reason (Optional)</Label>
|
||
<p className="text-xs text-slate-500 mb-2">Provide feedback to help the vendor improve their application</p>
|
||
<Textarea
|
||
id="rejection_reason"
|
||
value={rejectionReason}
|
||
onChange={(e) => setRejectionReason(e.target.value)}
|
||
placeholder="e.g., Missing insurance documents, incomplete rate card, coverage area doesn't match our needs..."
|
||
className="mt-2"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
setShowReviewModal(false);
|
||
setReviewingVendor(null);
|
||
setRejectionReason("");
|
||
}}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={() => {
|
||
const reason = rejectionReason.trim() || "No specific reason provided";
|
||
rejectVendorMutation.mutate({
|
||
vendorId: reviewingVendor.id,
|
||
reason: reason
|
||
});
|
||
}}
|
||
disabled={rejectVendorMutation.isPending || !reviewingVendor}
|
||
>
|
||
{rejectVendorMutation.isPending ? "Rejecting..." : (
|
||
<>
|
||
<XCircle className="w-4 h-4 mr-2" />
|
||
Reject
|
||
</>
|
||
)}
|
||
</Button>
|
||
<Button
|
||
onClick={() => approveVendorMutation.mutate(reviewingVendor.id)}
|
||
disabled={approveVendorMutation.isPending || !reviewingVendor}
|
||
className="bg-green-600 hover:bg-green-700"
|
||
>
|
||
{approveVendorMutation.isPending ? "Approving..." : (
|
||
<>
|
||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||
Approve & Activate
|
||
</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|