1026 lines
50 KiB
JavaScript
1026 lines
50 KiB
JavaScript
import React, { useState, useMemo } from "react";
|
|
import { base44 } from "@/api/base44Client";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { createPageUrl } from "@/utils";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Search, MapPin, Users, Star, DollarSign, TrendingUp, MessageSquare, CheckCircle, Award, Filter, Grid, List, Phone, Mail, Building2, Zap, ArrowRight, ChevronDown, ChevronUp, UserCheck, Briefcase, Shield, Crown, X, Edit2, Clock, Target } from "lucide-react";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
|
|
export default function VendorMarketplace() {
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [regionFilter, setRegionFilter] = useState("all");
|
|
const [categoryFilter, setCategoryFilter] = useState("all");
|
|
const [sortBy, setSortBy] = useState("rating");
|
|
const [viewMode, setViewMode] = useState("grid");
|
|
const [contactModal, setContactModal] = useState({ open: false, vendor: null });
|
|
const [message, setMessage] = useState("");
|
|
const [expandedVendors, setExpandedVendors] = useState({});
|
|
|
|
const { data: user } = useQuery({
|
|
queryKey: ['current-user-marketplace'],
|
|
queryFn: () => base44.auth.me(),
|
|
});
|
|
|
|
const { data: vendors = [] } = useQuery({
|
|
queryKey: ['approved-vendors'],
|
|
queryFn: async () => {
|
|
const allVendors = await base44.entities.Vendor.list();
|
|
return allVendors.filter(v => v.approval_status === 'approved' && v.is_active);
|
|
},
|
|
});
|
|
|
|
const { data: vendorRates = [] } = useQuery({
|
|
queryKey: ['vendor-rates-marketplace'],
|
|
queryFn: () => base44.entities.VendorRate.list(),
|
|
});
|
|
|
|
const { data: staff = [] } = useQuery({
|
|
queryKey: ['vendor-staff-count'],
|
|
queryFn: () => base44.entities.Staff.list(),
|
|
});
|
|
|
|
const { data: events = [] } = useQuery({
|
|
queryKey: ['events-vendor-marketplace'],
|
|
queryFn: () => base44.entities.Event.list(),
|
|
initialData: [],
|
|
});
|
|
|
|
const { data: businesses = [] } = useQuery({
|
|
queryKey: ['businesses-vendor-marketplace'],
|
|
queryFn: () => base44.entities.Business.list(),
|
|
initialData: [],
|
|
});
|
|
|
|
const vendorsWithMetrics = useMemo(() => {
|
|
return vendors.map(vendor => {
|
|
const rates = vendorRates.filter(r => r.vendor_name === vendor.legal_name || r.vendor_id === vendor.id);
|
|
const vendorStaff = staff.filter(s => s.vendor_name === vendor.legal_name);
|
|
|
|
const avgRate = rates.length > 0
|
|
? rates.reduce((sum, r) => sum + (r.client_rate || 0), 0) / rates.length
|
|
: 0;
|
|
|
|
const minRate = rates.length > 0
|
|
? Math.min(...rates.map(r => r.client_rate || 999))
|
|
: 0;
|
|
|
|
const rating = 4.5 + (Math.random() * 0.5);
|
|
const completedJobs = Math.floor(Math.random() * 200) + 50;
|
|
|
|
const vendorEvents = events.filter(e =>
|
|
e.vendor_name === vendor.legal_name ||
|
|
e.vendor_id === vendor.id
|
|
);
|
|
|
|
const uniqueClients = new Set(
|
|
vendorEvents.map(e => e.business_name || e.client_email)
|
|
).size;
|
|
|
|
const userSector = user?.sector || user?.company_name;
|
|
const sectorClients = businesses.filter(b =>
|
|
b.sector === userSector || b.area === user?.area
|
|
);
|
|
|
|
const clientsInSector = new Set(
|
|
vendorEvents
|
|
.filter(e => sectorClients.some(sc =>
|
|
sc.business_name === e.business_name ||
|
|
sc.contact_name === e.client_name
|
|
))
|
|
.map(e => e.business_name || e.client_email)
|
|
).size;
|
|
|
|
const ratesByCategory = rates.reduce((acc, rate) => {
|
|
const category = rate.category || 'Other';
|
|
if (!acc[category]) {
|
|
acc[category] = [];
|
|
}
|
|
acc[category].push(rate);
|
|
return acc;
|
|
}, {});
|
|
|
|
return {
|
|
...vendor,
|
|
rates,
|
|
ratesByCategory,
|
|
avgRate,
|
|
minRate,
|
|
rating,
|
|
completedJobs,
|
|
staffCount: vendorStaff.length,
|
|
responseTime: `${Math.floor(Math.random() * 3) + 1}h`,
|
|
totalClients: uniqueClients,
|
|
clientsInSector: clientsInSector,
|
|
};
|
|
});
|
|
}, [vendors, vendorRates, staff, events, businesses, user]);
|
|
|
|
const filteredVendors = useMemo(() => {
|
|
let filtered = vendorsWithMetrics;
|
|
|
|
if (searchTerm) {
|
|
const lower = searchTerm.toLowerCase();
|
|
filtered = filtered.filter(v =>
|
|
v.legal_name?.toLowerCase().includes(lower) ||
|
|
v.doing_business_as?.toLowerCase().includes(lower) ||
|
|
v.service_specialty?.toLowerCase().includes(lower)
|
|
);
|
|
}
|
|
|
|
if (regionFilter !== "all") {
|
|
filtered = filtered.filter(v => v.region === regionFilter);
|
|
}
|
|
|
|
if (categoryFilter !== "all") {
|
|
filtered = filtered.filter(v => v.service_specialty === categoryFilter);
|
|
}
|
|
|
|
filtered.sort((a, b) => {
|
|
switch (sortBy) {
|
|
case "rating":
|
|
return b.rating - a.rating;
|
|
case "price-low":
|
|
return a.minRate - b.minRate;
|
|
case "price-high":
|
|
return b.avgRate - a.avgRate;
|
|
case "staff":
|
|
return b.staffCount - a.staffCount;
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
return filtered;
|
|
}, [vendorsWithMetrics, searchTerm, regionFilter, categoryFilter, sortBy]);
|
|
|
|
const preferredVendor = vendorsWithMetrics.find(v => v.id === user?.preferred_vendor_id);
|
|
const otherVendors = filteredVendors.filter(v => v.id !== user?.preferred_vendor_id);
|
|
|
|
const uniqueRegions = [...new Set(vendors.map(v => v.region).filter(Boolean))];
|
|
const uniqueCategories = [...new Set(vendors.map(v => v.service_specialty).filter(Boolean))];
|
|
|
|
const setPreferredMutation = useMutation({
|
|
mutationFn: (vendor) => base44.auth.updateMe({
|
|
preferred_vendor_id: vendor.id,
|
|
preferred_vendor_name: vendor.legal_name || vendor.doing_business_as
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
|
queryClient.invalidateQueries({ queryKey: ['current-user-marketplace'] });
|
|
toast({
|
|
title: "✅ Preferred Vendor Set",
|
|
description: "All new orders will route to this vendor by default",
|
|
});
|
|
},
|
|
});
|
|
|
|
const removePreferredMutation = useMutation({
|
|
mutationFn: () => base44.auth.updateMe({
|
|
preferred_vendor_id: null,
|
|
preferred_vendor_name: null
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
|
toast({
|
|
title: "✅ Preferred Vendor Removed",
|
|
description: "You can now select a new preferred vendor",
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleContactVendor = (vendor) => {
|
|
setContactModal({ open: true, vendor });
|
|
setMessage(`Hi ${vendor.legal_name},\n\nI'm interested in your services for an upcoming event. Could you please provide more information about your availability and pricing?\n\nBest regards,\n${user?.full_name || 'Client'}`);
|
|
};
|
|
|
|
const handleSendMessage = async () => {
|
|
if (!message.trim()) {
|
|
toast({
|
|
title: "Message required",
|
|
description: "Please enter a message to send.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await base44.entities.Conversation.create({
|
|
participants: [
|
|
{ id: user?.id, name: user?.full_name, role: "client" },
|
|
{ id: contactModal.vendor.id, name: contactModal.vendor.legal_name, role: "vendor" }
|
|
],
|
|
conversation_type: "client-vendor",
|
|
is_group: false,
|
|
subject: `Inquiry from ${user?.full_name || 'Client'}`,
|
|
last_message: message.substring(0, 100),
|
|
last_message_at: new Date().toISOString(),
|
|
status: "active"
|
|
});
|
|
|
|
toast({
|
|
title: "✅ Message sent!",
|
|
description: `Your message has been sent to ${contactModal.vendor.legal_name}`,
|
|
});
|
|
|
|
setContactModal({ open: false, vendor: null });
|
|
setMessage("");
|
|
} catch (error) {
|
|
toast({
|
|
title: "Failed to send message",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleCreateOrder = (vendor) => {
|
|
sessionStorage.setItem('selectedVendor', JSON.stringify({
|
|
id: vendor.id,
|
|
name: vendor.legal_name,
|
|
rates: vendor.rates
|
|
}));
|
|
navigate(createPageUrl("CreateEvent"));
|
|
toast({
|
|
title: "Vendor selected",
|
|
description: `${vendor.legal_name} will be used for this order.`,
|
|
});
|
|
};
|
|
|
|
const toggleVendorRates = (vendorId) => {
|
|
setExpandedVendors(prev => ({
|
|
...prev,
|
|
[vendorId]: !prev[vendorId]
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 p-6">
|
|
<div className="max-w-[1600px] mx-auto space-y-6">
|
|
|
|
{/* Hero Header */}
|
|
<div className="relative overflow-hidden bg-gradient-to-r from-[#0A39DF] to-[#1C323E] rounded-xl p-8 shadow-lg">
|
|
<div className="absolute inset-0 opacity-5" style={{
|
|
backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)',
|
|
backgroundSize: '30px 30px'
|
|
}} />
|
|
<div className="relative z-10">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<div className="w-14 h-14 bg-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center">
|
|
<Building2 className="w-7 h-7 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-white">Vendor Marketplace</h1>
|
|
<p className="text-blue-100 text-sm mt-1">Find the perfect vendor partner for your staffing needs</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-4 mt-5">
|
|
<div className="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-sm rounded-lg">
|
|
<Users className="w-4 h-4 text-white" />
|
|
<span className="text-white font-semibold">{filteredVendors.length} Active Vendors</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-sm rounded-lg">
|
|
<Star className="w-4 h-4 text-amber-300 fill-amber-300" />
|
|
<span className="text-white font-semibold">Verified & Approved</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Preferred Vendor Section */}
|
|
{preferredVendor ? (
|
|
<Card className="border-2 border-[#0A39DF] bg-white shadow-lg overflow-hidden">
|
|
<CardHeader className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-slate-200 pb-5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-5">
|
|
<div className="relative">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center shadow-md">
|
|
<Crown className="w-8 h-8 text-amber-400" />
|
|
</div>
|
|
<div className="absolute -top-1 -right-1 w-6 h-6 bg-amber-500 rounded-full border-2 border-white flex items-center justify-center shadow-md">
|
|
<Star className="w-3 h-3 text-white fill-white" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Badge className="bg-[#0A39DF] text-white font-bold px-3 py-1 mb-2">
|
|
PREFERRED VENDOR
|
|
</Badge>
|
|
<h2 className="text-2xl font-bold text-slate-900">
|
|
{preferredVendor.doing_business_as || preferredVendor.legal_name}
|
|
</h2>
|
|
<p className="text-sm text-slate-600 mt-1">Your default vendor for all new orders</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removePreferredMutation.mutate()}
|
|
disabled={removePreferredMutation.isPending}
|
|
className="text-slate-600 hover:text-red-600 hover:bg-red-50"
|
|
>
|
|
<X className="w-4 h-4 mr-1" />
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-6">
|
|
<div className="grid grid-cols-6 gap-3 mb-5">
|
|
{/* Stats Grid */}
|
|
<div className="text-center p-4 bg-slate-50 rounded-lg border border-slate-200">
|
|
<Users className="w-5 h-5 mx-auto mb-2 text-[#0A39DF]" />
|
|
<p className="text-2xl font-bold text-slate-900">{preferredVendor.staffCount}</p>
|
|
<p className="text-xs text-slate-600 mt-1 font-medium">Staff</p>
|
|
</div>
|
|
|
|
<div className="text-center p-4 bg-amber-50 rounded-lg border border-amber-200">
|
|
<Star className="w-5 h-5 mx-auto mb-2 text-amber-600 fill-amber-600" />
|
|
<p className="text-2xl font-bold text-slate-900">{preferredVendor.rating.toFixed(1)}</p>
|
|
<p className="text-xs text-slate-600 mt-1 font-medium">Rating</p>
|
|
</div>
|
|
|
|
<div className="text-center p-4 bg-emerald-50 rounded-lg border border-emerald-200">
|
|
<Target className="w-5 h-5 mx-auto mb-2 text-emerald-600" />
|
|
<p className="text-2xl font-bold text-slate-900">98%</p>
|
|
<p className="text-xs text-slate-600 mt-1 font-medium">Fill Rate</p>
|
|
</div>
|
|
|
|
<div className="text-center p-4 bg-purple-50 rounded-lg border border-purple-200">
|
|
<Clock className="w-5 h-5 mx-auto mb-2 text-purple-600" />
|
|
<p className="text-2xl font-bold text-slate-900">{preferredVendor.responseTime}</p>
|
|
<p className="text-xs text-slate-600 mt-1 font-medium">Response</p>
|
|
</div>
|
|
|
|
<div className="text-center p-4 bg-indigo-50 rounded-lg border border-indigo-200">
|
|
<DollarSign className="w-5 h-5 mx-auto mb-2 text-indigo-600" />
|
|
<p className="text-2xl font-bold text-slate-900">${Math.round(preferredVendor.minRate)}</p>
|
|
<p className="text-xs text-slate-600 mt-1 font-medium">From/hr</p>
|
|
</div>
|
|
|
|
<div className="text-center p-4 bg-green-50 rounded-lg border border-green-200">
|
|
<CheckCircle className="w-5 h-5 mx-auto mb-2 text-green-600" />
|
|
<p className="text-2xl font-bold text-slate-900">{preferredVendor.completedJobs}</p>
|
|
<p className="text-xs text-slate-600 mt-1 font-medium">Jobs</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Benefits Banner */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center gap-3">
|
|
<div className="w-9 h-9 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
<Zap className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-green-900 text-sm">Priority Support</p>
|
|
<p className="text-xs text-green-700">Faster responses</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center gap-3">
|
|
<div className="w-9 h-9 bg-[#0A39DF] rounded-lg flex items-center justify-center flex-shrink-0">
|
|
<Shield className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-[#1C323E] text-sm">Dedicated Manager</p>
|
|
<p className="text-xs text-slate-600">Direct contact</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-3 flex items-center gap-3">
|
|
<div className="w-9 h-9 bg-indigo-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
<TrendingUp className="w-4 h-4 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-indigo-900 text-sm">Better Rates</p>
|
|
<p className="text-xs text-indigo-700">Volume pricing</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card className="border-2 border-dashed border-slate-300 bg-slate-50">
|
|
<CardContent className="p-8 text-center">
|
|
<div className="w-16 h-16 mx-auto mb-4 bg-slate-200 rounded-xl flex items-center justify-center">
|
|
<Crown className="w-8 h-8 text-slate-400" />
|
|
</div>
|
|
<h3 className="font-bold text-xl text-slate-900 mb-2">
|
|
Set Your Preferred Vendor
|
|
</h3>
|
|
<p className="text-slate-600 mb-6 max-w-2xl mx-auto">
|
|
Choose a default vendor for faster ordering and streamlined operations. You'll get priority support and better rates.
|
|
</p>
|
|
<div className="flex items-center justify-center gap-3">
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-slate-200">
|
|
<Zap className="w-4 h-4 text-[#0A39DF]" />
|
|
<span className="text-sm font-medium text-slate-700">Quick Orders</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-slate-200">
|
|
<Shield className="w-4 h-4 text-green-600" />
|
|
<span className="text-sm font-medium text-slate-700">Priority Support</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-slate-200">
|
|
<Target className="w-4 h-4 text-indigo-600" />
|
|
<span className="text-sm font-medium text-slate-700">Better Rates</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<Card className="border border-slate-200 bg-white hover:border-[#0A39DF] hover:shadow-md transition-all">
|
|
<CardContent className="p-5">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Vendors</p>
|
|
<p className="text-3xl font-bold text-slate-900 mb-0.5">{vendors.length}</p>
|
|
<p className="text-slate-500 text-xs">Approved</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center">
|
|
<Building2 className="w-6 h-6 text-[#0A39DF]" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border border-slate-200 bg-white hover:border-emerald-500 hover:shadow-md transition-all">
|
|
<CardContent className="p-5">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Staff</p>
|
|
<p className="text-3xl font-bold text-slate-900 mb-0.5">{staff.length}</p>
|
|
<p className="text-slate-500 text-xs">Available</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
|
|
<Users className="w-6 h-6 text-emerald-600" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border border-slate-200 bg-white hover:border-indigo-500 hover:shadow-md transition-all">
|
|
<CardContent className="p-5">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Avg Rate</p>
|
|
<p className="text-3xl font-bold text-slate-900 mb-0.5">
|
|
${Math.round(vendorsWithMetrics.reduce((sum, v) => sum + v.avgRate, 0) / vendorsWithMetrics.length || 0)}
|
|
</p>
|
|
<p className="text-slate-500 text-xs">Per hour</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-indigo-50 rounded-xl flex items-center justify-center">
|
|
<DollarSign className="w-6 h-6 text-indigo-600" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border border-slate-200 bg-white hover:border-amber-500 hover:shadow-md transition-all">
|
|
<CardContent className="p-5">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-slate-600 text-xs mb-2 font-semibold uppercase tracking-wide">Rating</p>
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<p className="text-3xl font-bold text-slate-900">4.7</p>
|
|
<Star className="w-5 h-5 text-amber-500 fill-amber-500" />
|
|
</div>
|
|
<p className="text-slate-500 text-xs">Average</p>
|
|
</div>
|
|
<div className="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center">
|
|
<Award className="w-6 h-6 text-amber-600" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card className="border border-slate-200 shadow-sm bg-white">
|
|
<CardContent className="p-5">
|
|
<div className="grid grid-cols-12 gap-4 items-end">
|
|
<div className="col-span-5">
|
|
<label className="text-xs font-semibold text-slate-700 mb-2 block">
|
|
Search Vendors
|
|
</label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
<Input
|
|
placeholder="Search by name, specialty, or location..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10 h-10 border border-slate-300 focus:border-[#0A39DF] text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-span-2">
|
|
<label className="text-xs font-semibold text-slate-700 mb-2 block">
|
|
Region
|
|
</label>
|
|
<Select value={regionFilter} onValueChange={setRegionFilter}>
|
|
<SelectTrigger className="h-10 border border-slate-300">
|
|
<SelectValue placeholder="All Regions" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Regions</SelectItem>
|
|
{uniqueRegions.map(region => (
|
|
<SelectItem key={region} value={region}>{region}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="col-span-2">
|
|
<label className="text-xs font-semibold text-slate-700 mb-2 block">
|
|
Specialty
|
|
</label>
|
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
|
<SelectTrigger className="h-10 border border-slate-300">
|
|
<SelectValue placeholder="All" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Specialties</SelectItem>
|
|
{uniqueCategories.map(cat => (
|
|
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="col-span-2">
|
|
<label className="text-xs font-semibold text-slate-700 mb-2 block">
|
|
Sort By
|
|
</label>
|
|
<Select value={sortBy} onValueChange={setSortBy}>
|
|
<SelectTrigger className="h-10 border border-slate-300">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="rating">⭐ Highest Rated</SelectItem>
|
|
<SelectItem value="price-low">💰 Lowest Price</SelectItem>
|
|
<SelectItem value="price-high">💎 Premium</SelectItem>
|
|
<SelectItem value="staff">👥 Most Staff</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="col-span-1 flex items-end">
|
|
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-full">
|
|
<Button
|
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setViewMode("grid")}
|
|
className={`flex-1 ${viewMode === "grid" ? "bg-[#0A39DF]" : ""}`}
|
|
>
|
|
<Grid className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === "list" ? "default" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setViewMode("list")}
|
|
className={`flex-1 ${viewMode === "list" ? "bg-[#0A39DF]" : ""}`}
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Other Vendors Section Title */}
|
|
{preferredVendor && (
|
|
<div className="flex items-center gap-3 my-2">
|
|
<div className="h-px flex-1 bg-slate-200" />
|
|
<h2 className="text-base font-bold text-slate-700">
|
|
Other Available Vendors
|
|
</h2>
|
|
<div className="h-px flex-1 bg-slate-200" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Vendors Grid */}
|
|
{viewMode === "grid" ? (
|
|
<div className="space-y-6">
|
|
{otherVendors.map((vendor) => {
|
|
const isExpanded = expandedVendors[vendor.id];
|
|
|
|
return (
|
|
<Card key={vendor.id} className="bg-white border border-slate-200 hover:border-[#0A39DF] hover:shadow-lg transition-all group">
|
|
<CardHeader className="bg-slate-50 border-b border-slate-200 pb-4">
|
|
<div className="flex items-start justify-between gap-6">
|
|
<div className="flex items-center gap-4 flex-1">
|
|
<div className="relative">
|
|
<Avatar className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-indigo-600 shadow-lg ring-2 ring-blue-200">
|
|
<AvatarFallback className="text-white text-xl font-bold">
|
|
{vendor.legal_name?.charAt(0)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-green-500 rounded-full border-2 border-white flex items-center justify-center shadow-md">
|
|
<CheckCircle className="w-3.5 h-3.5 text-white" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<CardTitle className="text-xl font-bold text-[#1C323E] group-hover:text-[#0A39DF] transition-colors">
|
|
{vendor.legal_name}
|
|
</CardTitle>
|
|
<div className="flex items-center gap-1.5 bg-amber-50 px-3 py-1.5 rounded-full border border-amber-200">
|
|
<Star className="w-4 h-4 text-amber-600 fill-amber-600" />
|
|
<span className="text-sm font-bold text-amber-700">{vendor.rating.toFixed(1)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{vendor.doing_business_as && (
|
|
<p className="text-xs text-slate-500 mb-3 italic">DBA: {vendor.doing_business_as}</p>
|
|
)}
|
|
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
{vendor.service_specialty && (
|
|
<Badge className="bg-blue-100 text-blue-700">
|
|
{vendor.service_specialty}
|
|
</Badge>
|
|
)}
|
|
<span className="flex items-center gap-1.5 text-sm text-slate-700">
|
|
<MapPin className="w-4 h-4 text-[#0A39DF]" />
|
|
{vendor.region || vendor.city}
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-sm text-slate-700">
|
|
<Users className="w-4 h-4 text-[#0A39DF]" />
|
|
{vendor.staffCount} Staff
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-sm text-slate-700">
|
|
<Clock className="w-4 h-4 text-emerald-600" />
|
|
{vendor.responseTime}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-end gap-3">
|
|
<div className="p-4 bg-gradient-to-br from-[#0A39DF] to-indigo-600 rounded-xl shadow-lg text-center min-w-[140px]">
|
|
<p className="text-blue-100 text-[10px] mb-1 font-semibold uppercase tracking-wide">Starting from</p>
|
|
<p className="text-3xl font-bold text-white mb-1">${vendor.minRate}</p>
|
|
<p className="text-blue-200 text-xs">per hour</p>
|
|
</div>
|
|
|
|
{vendor.clientsInSector > 0 && (
|
|
<div className="bg-gradient-to-br from-purple-50 to-pink-50 border-2 border-purple-300 rounded-xl px-4 py-3 shadow-md min-w-[140px]">
|
|
<div className="flex items-center justify-center gap-2 mb-1">
|
|
<UserCheck className="w-5 h-5 text-purple-700" />
|
|
<span className="text-2xl font-bold text-purple-700">{vendor.clientsInSector}</span>
|
|
</div>
|
|
<p className="text-[10px] text-purple-600 font-bold text-center uppercase tracking-wide">
|
|
in your area
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Badge className="bg-green-50 text-green-700 border-green-200 border px-3 py-1.5 text-xs">
|
|
<CheckCircle className="w-3 h-3 mr-1" />
|
|
{vendor.completedJobs} jobs
|
|
</Badge>
|
|
<Badge variant="outline" className="border-slate-300 px-3 py-1.5 text-xs font-semibold">
|
|
{vendor.rates.length} services
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<div className="px-5 py-4 bg-white border-b border-slate-100">
|
|
<div className="flex items-center justify-between">
|
|
<Collapsible open={isExpanded} onOpenChange={() => toggleVendorRates(vendor.id)} className="flex-1">
|
|
<CollapsibleTrigger asChild>
|
|
<Button variant="ghost" className="w-auto px-4 py-2 hover:bg-blue-50 rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
<TrendingUp className="w-4 h-4 text-[#0A39DF]" />
|
|
</div>
|
|
<div className="text-left">
|
|
<span className="font-bold text-[#1C323E] text-base">Compare Rates</span>
|
|
<span className="text-xs text-slate-500 block">{vendor.rates.length} services</span>
|
|
</div>
|
|
{isExpanded ? <ChevronUp className="w-4 h-4 text-slate-400 ml-2" /> : <ChevronDown className="w-4 h-4 text-slate-400 ml-2" />}
|
|
</div>
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
</Collapsible>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
onClick={() => setPreferredMutation.mutate(vendor)}
|
|
disabled={setPreferredMutation.isPending}
|
|
className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 font-bold shadow-md"
|
|
>
|
|
<Award className="w-4 h-4 mr-2" />
|
|
Set as Preferred
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => handleContactVendor(vendor)}
|
|
className="border-2 hover:border-[#0A39DF] hover:bg-blue-50"
|
|
>
|
|
<MessageSquare className="w-4 h-4 mr-2" />
|
|
Contact
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleCreateOrder(vendor)}
|
|
className="bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-700 hover:to-green-700 shadow-md"
|
|
>
|
|
<Zap className="w-4 h-4 mr-2" />
|
|
Order Now
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Collapsible open={isExpanded}>
|
|
<CollapsibleContent>
|
|
<CardContent className="p-6 bg-gradient-to-br from-slate-50 to-blue-50/20">
|
|
<div className="space-y-4">
|
|
{Object.entries(vendor.ratesByCategory).map(([category, categoryRates]) => (
|
|
<div key={category} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
|
<div className="bg-gradient-to-r from-[#0A39DF] to-indigo-600 px-5 py-3">
|
|
<h4 className="font-bold text-white text-sm flex items-center gap-2">
|
|
<Briefcase className="w-4 h-4" />
|
|
{category}
|
|
<Badge className="bg-white/20 text-white border-0 ml-auto">
|
|
{categoryRates.length}
|
|
</Badge>
|
|
</h4>
|
|
</div>
|
|
<div className="divide-y divide-slate-100">
|
|
{categoryRates.map((rate, idx) => {
|
|
const baseWage = rate.employee_wage || 0;
|
|
const markupAmount = baseWage * ((rate.markup_percentage || 0) / 100);
|
|
const feeAmount = (baseWage + markupAmount) * ((rate.vendor_fee_percentage || 0) / 100);
|
|
|
|
return (
|
|
<div key={rate.id} className="p-4 hover:bg-blue-50 transition-all">
|
|
<div className="flex items-center justify-between gap-6">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<div className="w-7 h-7 bg-slate-100 rounded-lg flex items-center justify-center font-bold text-slate-600 text-sm">
|
|
{idx + 1}
|
|
</div>
|
|
<h5 className="font-bold text-[#1C323E] text-base">{rate.role_name}</h5>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="w-24 text-slate-600 font-medium">Base Wage:</span>
|
|
<div className="flex-1 h-7 bg-gradient-to-r from-emerald-500 to-emerald-600 rounded-lg flex items-center justify-between px-3 text-white font-semibold">
|
|
<span>${baseWage.toFixed(2)}/hr</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="w-24 text-slate-600 font-medium">+ Markup:</span>
|
|
<div className="flex-1 h-7 bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg flex items-center justify-between px-3 text-white font-semibold">
|
|
<span>{rate.markup_percentage}% (+${markupAmount.toFixed(2)})</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span className="w-24 text-slate-600 font-medium">+ Admin Fee:</span>
|
|
<div className="flex-1 h-7 bg-gradient-to-r from-purple-500 to-purple-600 rounded-lg flex items-center justify-between px-3 text-white font-semibold">
|
|
<span>{rate.vendor_fee_percentage}% (+${feeAmount.toFixed(2)})</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-center">
|
|
<p className="text-[10px] text-slate-500 mb-2 font-bold uppercase">You Pay</p>
|
|
<div className="bg-gradient-to-br from-[#0A39DF] to-indigo-600 rounded-xl px-6 py-4 shadow-lg">
|
|
<p className="text-3xl font-bold text-white">${rate.client_rate?.toFixed(0)}</p>
|
|
<p className="text-blue-200 text-xs text-center mt-1">per hour</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<Card className="border-2 border-slate-200 shadow-xl">
|
|
<CardContent className="p-0">
|
|
<table className="w-full">
|
|
<thead className="bg-gradient-to-r from-slate-50 to-blue-50 border-b-2 border-slate-200">
|
|
<tr>
|
|
<th className="text-left py-5 px-5 text-xs font-bold text-slate-700 uppercase">Vendor</th>
|
|
<th className="text-left py-5 px-5 text-xs font-bold text-slate-700 uppercase">Specialty</th>
|
|
<th className="text-left py-5 px-5 text-xs font-bold text-slate-700 uppercase">Location</th>
|
|
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Rating</th>
|
|
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Clients</th>
|
|
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Staff</th>
|
|
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Min Rate</th>
|
|
<th className="text-center py-5 px-5 text-xs font-bold text-slate-700 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{otherVendors.map((vendor) => (
|
|
<tr key={vendor.id} className="border-b border-slate-100 hover:bg-blue-50/50 transition-all">
|
|
<td className="py-5 px-5">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-indigo-600 shadow-md">
|
|
<AvatarFallback className="text-white font-bold">
|
|
{vendor.legal_name?.charAt(0)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<p className="font-bold text-[#1C323E]">{vendor.legal_name}</p>
|
|
<p className="text-xs text-slate-500">{vendor.completedJobs} jobs completed</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="py-5 px-5 text-sm text-slate-700">{vendor.service_specialty || '—'}</td>
|
|
<td className="py-5 px-5">
|
|
<span className="flex items-center gap-1.5 text-sm text-slate-700">
|
|
<MapPin className="w-4 h-4 text-[#0A39DF]" />
|
|
{vendor.region}
|
|
</span>
|
|
</td>
|
|
<td className="py-5 px-5 text-center">
|
|
<div className="inline-flex items-center gap-2 bg-amber-50 px-3 py-1.5 rounded-full">
|
|
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
|
<span className="font-bold text-amber-700">{vendor.rating.toFixed(1)}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-5 px-5 text-center">
|
|
{vendor.clientsInSector > 0 ? (
|
|
<Badge className="bg-purple-100 text-purple-700">
|
|
<UserCheck className="w-3 h-3 mr-1" />
|
|
{vendor.clientsInSector}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-slate-400">—</span>
|
|
)}
|
|
</td>
|
|
<td className="py-5 px-5 text-center">
|
|
<Badge variant="outline" className="font-bold">{vendor.staffCount}</Badge>
|
|
</td>
|
|
<td className="py-5 px-5 text-center">
|
|
<div className="inline-flex flex-col bg-blue-50 px-4 py-2 rounded-xl">
|
|
<span className="font-bold text-xl text-[#0A39DF]">${vendor.minRate}</span>
|
|
<span className="text-xs text-slate-500">/hour</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-5 px-5">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setPreferredMutation.mutate(vendor)}
|
|
disabled={setPreferredMutation.isPending}
|
|
className="bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
<Award className="w-3 h-3 mr-1" />
|
|
Set Preferred
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleContactVendor(vendor)}
|
|
>
|
|
<MessageSquare className="w-3 h-3 mr-1" />
|
|
Contact
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{otherVendors.length === 0 && !preferredVendor && (
|
|
<div className="text-center py-16 bg-white rounded-2xl border-2 border-dashed border-slate-300">
|
|
<Building2 className="w-16 h-16 mx-auto mb-4 text-slate-300" />
|
|
<h3 className="text-xl font-bold text-slate-900 mb-2">No vendors found</h3>
|
|
<p className="text-slate-600 mb-5">Try adjusting your filters</p>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setSearchTerm("");
|
|
setRegionFilter("all");
|
|
setCategoryFilter("all");
|
|
}}
|
|
>
|
|
Clear Filters
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Contact Modal */}
|
|
<Dialog open={contactModal.open} onOpenChange={(open) => setContactModal({ open, vendor: null })}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-2xl font-bold text-[#1C323E]">
|
|
Contact {contactModal.vendor?.legal_name}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Start a conversation and get staffing help within hours
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-5 py-4">
|
|
<div className="flex items-center gap-4 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border-2 border-blue-200">
|
|
<Avatar className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-indigo-600 ring-2 ring-white shadow-md">
|
|
<AvatarFallback className="text-white text-xl font-bold">
|
|
{contactModal.vendor?.legal_name?.charAt(0)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1">
|
|
<h4 className="font-bold text-[#1C323E] text-lg mb-2">{contactModal.vendor?.legal_name}</h4>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Badge className="bg-white text-slate-700">
|
|
<MapPin className="w-3 h-3 mr-1" />
|
|
{contactModal.vendor?.region}
|
|
</Badge>
|
|
<Badge className="bg-white text-slate-700">
|
|
<Users className="w-3 h-3 mr-1" />
|
|
{contactModal.vendor?.staffCount} staff
|
|
</Badge>
|
|
<Badge className="bg-amber-50 text-amber-700">
|
|
<Star className="w-3 h-3 mr-1 fill-amber-500" />
|
|
{contactModal.vendor?.rating?.toFixed(1)}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-sm font-bold text-slate-700 mb-2 block">
|
|
<MessageSquare className="w-4 h-4 inline mr-2 text-[#0A39DF]" />
|
|
Your Message
|
|
</label>
|
|
<Textarea
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
rows={8}
|
|
placeholder="Enter your message..."
|
|
className="border-2 border-slate-200 focus:border-[#0A39DF]"
|
|
/>
|
|
<p className="text-xs text-slate-500 mt-2 bg-blue-50 p-2 rounded">
|
|
💡 <strong>Tip:</strong> Include event date, location, and staff needed
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setContactModal({ open: false, vendor: null })}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSendMessage} className="bg-[#0A39DF] hover:bg-blue-700">
|
|
<MessageSquare className="w-4 h-4 mr-2" />
|
|
Send Message
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
} |