other modifications days ago

This commit is contained in:
José Salazar
2025-12-26 15:14:51 -05:00
parent ec496fa1ba
commit 5136b1d6b5
56 changed files with 16364 additions and 3094 deletions

View File

@@ -0,0 +1,401 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
DollarSign, TrendingUp, TrendingDown, AlertTriangle, CheckCircle,
Target, Lightbulb, ArrowRight, PieChart, BarChart3, Wallet,
Building2, Users, Package, Calendar, Zap, Brain, Shield
} from "lucide-react";
const ROLE_BUDGET_CONFIG = {
procurement: {
title: "Procurement Budget Control",
color: "blue",
metrics: ["vendor_spend", "rate_compliance", "savings_achieved"],
focus: "Vendor rate optimization & consolidation"
},
operator: {
title: "Operator Budget Overview",
color: "emerald",
metrics: ["sector_allocation", "labor_costs", "efficiency"],
focus: "Cross-sector resource optimization"
},
sector: {
title: "Sector Budget Allocation",
color: "purple",
metrics: ["site_costs", "overtime", "headcount"],
focus: "Site-level cost management"
},
client: {
title: "Your Staffing Budget",
color: "green",
metrics: ["order_costs", "vendor_rates", "savings"],
focus: "Cost-effective staffing solutions"
},
vendor: {
title: "Revenue & Margin Tracker",
color: "amber",
metrics: ["revenue", "margins", "utilization"],
focus: "Maximize revenue & worker utilization"
},
admin: {
title: "Platform Budget Intelligence",
color: "slate",
metrics: ["total_gmv", "platform_fees", "growth"],
focus: "Platform-wide financial health"
}
};
export default function BudgetUtilizationTracker({
userRole = 'admin',
events = [],
invoices = [],
budgetData = null
}) {
const [selectedPeriod, setSelectedPeriod] = useState("month");
const config = ROLE_BUDGET_CONFIG[userRole] || ROLE_BUDGET_CONFIG.admin;
// Calculate budget metrics
const totalSpent = invoices.reduce((sum, inv) => sum + (inv.amount || 0), 0) || 15000;
const totalBudget = budgetData?.total_budget || totalSpent * 1.2;
const utilizationRate = ((totalSpent / totalBudget) * 100).toFixed(1);
const remainingBudget = totalBudget - totalSpent;
// Savings calculation
const potentialSavings = totalSpent * 0.12; // 12% potential savings
const achievedSavings = totalSpent * 0.05; // 5% already achieved
// Trend data
const trend = utilizationRate < 80 ? "under" : utilizationRate < 95 ? "on_track" : "over";
// Smart recommendations based on role and budget status
const getSmartRecommendations = () => {
const recommendations = [];
if (userRole === 'procurement') {
if (utilizationRate > 90) {
recommendations.push({
priority: "high",
action: "Renegotiate top 3 vendor contracts",
impact: `Save $${(totalSpent * 0.08).toLocaleString()}`,
icon: DollarSign
});
}
recommendations.push({
priority: "medium",
action: "Consolidate 5 underperforming vendors",
impact: "18% rate reduction",
icon: Package
});
recommendations.push({
priority: "low",
action: "Lock in Q1 rates before price increases",
impact: "Protect against 5% inflation",
icon: Shield
});
} else if (userRole === 'operator') {
recommendations.push({
priority: "high",
action: "Reallocate 12 workers from Sector A to B",
impact: "+15% fill rate improvement",
icon: Users
});
recommendations.push({
priority: "medium",
action: "Reduce overtime in kitchen roles by 20%",
impact: `Save $${(totalSpent * 0.04).toLocaleString()}/month`,
icon: TrendingDown
});
} else if (userRole === 'sector') {
recommendations.push({
priority: "high",
action: "Review 3 sites with >110% budget usage",
impact: "Prevent $8,200 overage",
icon: AlertTriangle
});
recommendations.push({
priority: "medium",
action: "Shift Tuesday/Wednesday staffing levels",
impact: "15% efficiency gain",
icon: Calendar
});
} else if (userRole === 'client') {
recommendations.push({
priority: "high",
action: "Switch 2 orders to Preferred Vendor rates",
impact: `Save $${(totalSpent * 0.06).toLocaleString()}`,
icon: DollarSign
});
recommendations.push({
priority: "medium",
action: "Lock recurring staff for top 3 positions",
impact: "15% rate lock guarantee",
icon: Shield
});
} else if (userRole === 'vendor') {
recommendations.push({
priority: "high",
action: "Fill 12 idle workers with pending orders",
impact: `+$${(potentialSavings * 0.8).toLocaleString()} revenue`,
icon: Users
});
recommendations.push({
priority: "medium",
action: "Upsell premium rates to 3 new clients",
impact: "+8% margin improvement",
icon: TrendingUp
});
} else {
recommendations.push({
priority: "high",
action: "Enable 2 more automation workflows",
impact: "+$12K/month platform savings",
icon: Zap
});
recommendations.push({
priority: "medium",
action: "Approve 5 pending vendor applications",
impact: "Unlock $45K GMV potential",
icon: Building2
});
}
return recommendations;
};
const recommendations = getSmartRecommendations();
// Budget breakdown by category
const budgetBreakdown = [
{ name: "Labor Costs", amount: totalSpent * 0.65, percentage: 65, color: "bg-blue-500" },
{ name: "Vendor Fees", amount: totalSpent * 0.12, percentage: 12, color: "bg-purple-500" },
{ name: "Overtime", amount: totalSpent * 0.15, percentage: 15, color: "bg-amber-500" },
{ name: "Other", amount: totalSpent * 0.08, percentage: 8, color: "bg-slate-400" }
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 bg-${config.color}-100 rounded-xl flex items-center justify-center`}>
<Wallet className={`w-6 h-6 text-${config.color}-600`} />
</div>
<div>
<h2 className="text-xl font-bold text-slate-900">{config.title}</h2>
<p className="text-sm text-slate-500">{config.focus}</p>
</div>
</div>
<div className="flex gap-2">
{["week", "month", "quarter", "year"].map(period => (
<Button
key={period}
size="sm"
variant={selectedPeriod === period ? "default" : "outline"}
onClick={() => setSelectedPeriod(period)}
className={selectedPeriod === period ? "bg-[#0A39DF]" : ""}
>
{period.charAt(0).toUpperCase() + period.slice(1)}
</Button>
))}
</div>
</div>
{/* Main Budget Card */}
<Card className={`border-2 ${trend === 'under' ? 'border-green-200 bg-green-50/30' : trend === 'on_track' ? 'border-blue-200 bg-blue-50/30' : 'border-red-200 bg-red-50/30'}`}>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Total Spent */}
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Total Spent</p>
<p className="text-3xl font-bold text-slate-900">${totalSpent.toLocaleString()}</p>
<p className="text-sm text-slate-500">of ${totalBudget.toLocaleString()} budget</p>
</div>
{/* Utilization Rate */}
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Utilization</p>
<div className="flex items-center gap-2">
<p className={`text-3xl font-bold ${trend === 'under' ? 'text-green-600' : trend === 'on_track' ? 'text-blue-600' : 'text-red-600'}`}>
{utilizationRate}%
</p>
{trend === 'under' && <TrendingDown className="w-5 h-5 text-green-500" />}
{trend === 'on_track' && <Target className="w-5 h-5 text-blue-500" />}
{trend === 'over' && <TrendingUp className="w-5 h-5 text-red-500" />}
</div>
<Progress
value={parseFloat(utilizationRate)}
className={`h-2 mt-2 ${trend === 'over' ? '[&>div]:bg-red-500' : trend === 'on_track' ? '[&>div]:bg-blue-500' : '[&>div]:bg-green-500'}`}
/>
</div>
{/* Remaining Budget */}
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Remaining</p>
<p className={`text-3xl font-bold ${remainingBudget > 0 ? 'text-green-600' : 'text-red-600'}`}>
${Math.abs(remainingBudget).toLocaleString()}
</p>
<p className="text-sm text-slate-500">
{remainingBudget > 0 ? 'Available to spend' : 'Over budget'}
</p>
</div>
{/* Savings Opportunity */}
<div className="bg-purple-50 rounded-xl p-4 border-2 border-purple-200">
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold mb-1">
💡 Savings Potential
</p>
<p className="text-2xl font-bold text-purple-700">${potentialSavings.toLocaleString()}</p>
<div className="flex items-center gap-2 mt-1">
<Badge className="bg-green-100 text-green-700 text-[10px]">
${achievedSavings.toLocaleString()} achieved
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* AI-Powered Recommendations */}
<Card className="bg-gradient-to-r from-[#1C323E] to-[#0A39DF] text-white overflow-hidden">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Brain className="w-5 h-5" />
<span className="font-bold">AI Budget Advisor</span>
<Badge className="bg-white/20 text-white border-0 text-[10px]">Smart Recommendations</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{recommendations.map((rec, idx) => {
const Icon = rec.icon;
return (
<div
key={idx}
className={`p-4 rounded-xl ${rec.priority === 'high' ? 'bg-white/20' : 'bg-white/10'} backdrop-blur-sm`}
>
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4" />
<Badge className={`text-[10px] ${rec.priority === 'high' ? 'bg-red-500' : rec.priority === 'medium' ? 'bg-amber-500' : 'bg-green-500'}`}>
{rec.priority}
</Badge>
</div>
<p className="text-sm font-medium mb-1">{rec.action}</p>
<p className="text-xs text-white/70">{rec.impact}</p>
<Button
size="sm"
variant="secondary"
className="mt-2 h-7 text-xs bg-white text-[#0A39DF] hover:bg-white/90"
onClick={() => window.location.href = rec.priority === 'high' ? '/VendorManagement' : '/Reports'}
>
Take Action <ArrowRight className="w-3 h-3 ml-1" />
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Budget Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChart className="w-5 h-5 text-[#0A39DF]" />
Spend Breakdown
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{budgetBreakdown.map((item, idx) => (
<div key={idx}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">{item.name}</span>
<span className="text-sm text-slate-600">${item.amount.toLocaleString()} ({item.percentage}%)</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2">
<div
className={`${item.color} h-2 rounded-full transition-all`}
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[#0A39DF]" />
Budget Health Score
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4">
<div className={`w-24 h-24 mx-auto rounded-full flex items-center justify-center text-3xl font-bold ${trend === 'under' ? 'bg-green-100 text-green-700' : trend === 'on_track' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'}`}>
{trend === 'under' ? 'A+' : trend === 'on_track' ? 'B+' : 'C'}
</div>
<p className="mt-3 font-semibold text-slate-900">
{trend === 'under' ? 'Excellent Budget Management' : trend === 'on_track' ? 'Good - Monitor Closely' : 'Action Required'}
</p>
<p className="text-sm text-slate-500 mt-1">
{trend === 'under'
? 'You have room for strategic investments'
: trend === 'on_track'
? 'Stay on track with current spending pace'
: 'Immediate cost reduction recommended'
}
</p>
</div>
<div className="grid grid-cols-3 gap-3 mt-4">
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-lg font-bold text-green-600">12%</p>
<p className="text-[10px] text-slate-500">vs Last Period</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-lg font-bold text-blue-600">94%</p>
<p className="text-[10px] text-slate-500">Forecast Accuracy</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-lg font-bold text-purple-600">$8.2K</p>
<p className="text-[10px] text-slate-500">Saved This Month</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Quick Decision Panel */}
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Lightbulb className="w-5 h-5 text-purple-600 mt-0.5" />
<div className="flex-1">
<p className="font-semibold text-purple-900">Quick Decision for {config.title.split(' ')[0]}</p>
<p className="text-sm text-purple-700 mt-1">
{userRole === 'procurement' && "Lock in 3 vendor contracts at current rates before Q1 price increases — potential 8% savings on $125K annual spend."}
{userRole === 'operator' && "Reallocate 15% of Sector B's overtime budget to temporary staffing — same output, 22% cost reduction."}
{userRole === 'sector' && "Approve shift consolidation for Tuesdays — reduces 4 overlapping positions, saves $1,800/week."}
{userRole === 'client' && "Switch to annual contract with Preferred Vendor — locks in 15% discount, saves $6,400/year."}
{userRole === 'vendor' && "Accept 3 pending orders at standard rates — fills idle capacity, adds $12K revenue this month."}
{userRole === 'admin' && "Enable automated invoice reconciliation — reduces processing time 60%, saves $8K/month in admin costs."}
</p>
</div>
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => window.location.href = '/SavingsEngine'}
>
Execute <ArrowRight className="w-3 h-3 ml-1" />
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -23,6 +23,12 @@ import {
Sparkles
} from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
// Helper to fix DnD issues
const getItemStyle = (isDragging, draggableStyle) => ({
userSelect: "none",
...draggableStyle,
});
import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
@@ -243,12 +249,12 @@ export default function DashboardCustomizer({
</div>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="visible">
<Droppable droppableId="visible" direction="vertical">
{(provided, snapshot) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className={`space-y-2 min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
{...provided.droppableProps}
className={`min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
snapshot.isDraggingOver
? 'border-blue-400 bg-blue-50'
: 'border-slate-200 bg-slate-50'
@@ -267,9 +273,13 @@ export default function DashboardCustomizer({
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`bg-white border-2 rounded-lg p-4 transition-all ${
style={getItemStyle(
snapshot.isDragging,
provided.draggableProps.style
)}
className={`bg-white border-2 rounded-lg p-4 mb-2 ${
snapshot.isDragging
? 'border-blue-400 shadow-2xl scale-105 rotate-2'
? 'border-blue-400 shadow-2xl'
: 'border-slate-200 hover:border-blue-300 hover:shadow-md'
}`}
>

View File

@@ -142,7 +142,64 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
initialData: [],
});
const availableRoles = [...new Set(allRates.map(r => r.role_name))].sort();
// Get available roles - filter by selected vendor for clients, or by client for vendors
const availableRoles = React.useMemo(() => {
// For CLIENT users: filter roles by selected vendor
if (isClient && formData.vendor_id) {
const selectedVendor = vendors.find(v => v.id === formData.vendor_id);
const vendorName = selectedVendor?.legal_name || selectedVendor?.doing_business_as;
// Get roles from this vendor's rates
const vendorRoles = allRates.filter(rate =>
(rate.vendor_id === formData.vendor_id || rate.vendor_name === vendorName) &&
rate.is_active !== false
).map(r => r.role_name);
// Deduplicate and sort
const uniqueRoles = [...new Set(vendorRoles)].filter(Boolean);
if (uniqueRoles.length > 0) {
return uniqueRoles.sort();
}
}
// For VENDOR users: filter roles by selected client
if (isVendor && formData.business_id && formData.business_name) {
const extractCompanyName = (name) => {
if (!name) return '';
return name.split(/\s*[-]\s*/)[0].trim();
};
const mainCompanyName = extractCompanyName(formData.business_name);
const selectedBusiness = businesses.find(b => b.id === formData.business_id);
// Get roles that have client-specific rates for this client
const clientSpecificRoles = allRates.filter(rate =>
rate.rate_book_type === 'client_specific' &&
(rate.client_name === formData.business_name ||
rate.client_name === mainCompanyName ||
extractCompanyName(rate.client_name) === mainCompanyName)
).map(r => r.role_name);
// Also get roles from client's rate_card if set
let rateCardRoles = [];
if (selectedBusiness?.rate_card) {
rateCardRoles = allRates.filter(rate =>
rate.rate_book_name === selectedBusiness.rate_card
).map(r => r.role_name);
}
// Combine and deduplicate
const combinedRoles = [...new Set([...clientSpecificRoles, ...rateCardRoles])].filter(Boolean);
if (combinedRoles.length > 0) {
return combinedRoles.sort();
}
}
// Default: show all roles
return [...new Set(allRates.map(r => r.role_name))].filter(Boolean).sort();
}, [allRates, isVendor, isClient, formData.vendor_id, formData.business_id, formData.business_name, businesses, vendors]);
useEffect(() => {
if (isClient && currentUserData && !formData.vendor_id) {
@@ -300,19 +357,82 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
const handleBusinessChange = (businessId) => {
const selectedBusiness = businesses.find(b => b.id === businessId);
if (selectedBusiness) {
setFormData(prev => ({
...prev,
business_id: businessId,
business_name: selectedBusiness.business_name || "",
hub: selectedBusiness.hub_building || prev.hub,
shifts: prev.shifts.map(shift => ({
// Update form with business info and recalculate rates for all roles
setFormData(prev => {
const updatedShifts = prev.shifts.map(shift => ({
...shift,
location_address: selectedBusiness.address || shift.location_address
}))
}));
location_address: selectedBusiness.address || shift.location_address,
roles: shift.roles.map(role => {
if (role.role) {
const rate = getRateForRoleAndClient(role.role, businessId, selectedBusiness.business_name);
return {
...role,
rate_per_hour: rate,
total_value: rate * (role.hours || 0) * (parseInt(role.count) || 1)
};
}
return role;
})
}));
return {
...prev,
business_id: businessId,
business_name: selectedBusiness.business_name || "",
hub: selectedBusiness.hub_building || prev.hub,
shifts: updatedShifts
};
});
setTimeout(() => updateGrandTotal(), 10);
}
};
// Get rate for role based on selected client
const getRateForRoleAndClient = (roleName, businessId, businessName) => {
if (!businessId || !businessName) return 0;
// Extract main company name (e.g., "Chime" from "Chime - HQ")
const extractCompanyName = (name) => {
if (!name) return '';
return name.split(/\s*[-]\s*/)[0].trim();
};
const mainCompanyName = extractCompanyName(businessName);
const selectedBusiness = businesses.find(b => b.id === businessId);
// Priority 1: Client-specific rate matching this client
const clientSpecificRate = allRates.find(rate =>
rate.role_name?.toLowerCase() === roleName.toLowerCase() &&
rate.rate_book_type === 'client_specific' &&
(rate.client_name === businessName ||
rate.client_name === mainCompanyName ||
extractCompanyName(rate.client_name) === mainCompanyName)
);
if (clientSpecificRate) {
return clientSpecificRate.client_rate || 0;
}
// Priority 2: Match client's rate_card
if (selectedBusiness?.rate_card) {
const rateCardMatch = allRates.find(rate =>
rate.role_name?.toLowerCase() === roleName.toLowerCase() &&
rate.rate_book_name === selectedBusiness.rate_card
);
if (rateCardMatch) {
return rateCardMatch.client_rate || 0;
}
}
// Priority 3: Default rate
const defaultRate = allRates.find(rate =>
rate.role_name?.toLowerCase() === roleName.toLowerCase() &&
rate.rate_book_type === 'default'
);
return defaultRate?.client_rate || 0;
};
// Auto-populate shift addresses when hub changes
useEffect(() => {
if (formData.hub && formData.shifts.length > 0) {
@@ -378,20 +498,44 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
const targetVendorId = vendorId || formData.vendor_id;
if (targetVendorId) {
const rate = allRates.find(r =>
r.role_name === roleName &&
// Priority 1: Find rate matching vendor_id and role_name exactly
const vendorRate = allRates.find(r =>
r.role_name?.toLowerCase() === roleName?.toLowerCase() &&
r.vendor_id === targetVendorId &&
r.is_active !== false
);
if (rate) {
console.log('Found rate for', roleName, 'from vendor', targetVendorId, ':', rate.client_rate);
return parseFloat(rate.client_rate || 0);
if (vendorRate) {
return parseFloat(vendorRate.client_rate || 0);
}
// Priority 2: Find rate matching vendor_name (some rates may not have vendor_id set)
const selectedVendor = vendors.find(v => v.id === targetVendorId);
if (selectedVendor) {
const vendorName = selectedVendor.legal_name || selectedVendor.doing_business_as;
const vendorNameRate = allRates.find(r =>
r.role_name?.toLowerCase() === roleName?.toLowerCase() &&
r.vendor_name === vendorName &&
r.is_active !== false
);
if (vendorNameRate) {
return parseFloat(vendorNameRate.client_rate || 0);
}
}
}
console.log('No rate found for', roleName, 'from vendor', targetVendorId);
const fallbackRate = allRates.find(r => r.role_name === roleName && r.is_active !== false);
return fallbackRate ? parseFloat(fallbackRate.client_rate || 0) : 0;
// Fallback: Find any rate for this role (default rates)
const defaultRate = allRates.find(r =>
r.role_name?.toLowerCase() === roleName?.toLowerCase() &&
r.rate_book_type === 'default' &&
r.is_active !== false
);
if (defaultRate) {
return parseFloat(defaultRate.client_rate || 0);
}
// Last fallback: any rate for this role
const anyRate = allRates.find(r => r.role_name?.toLowerCase() === roleName?.toLowerCase() && r.is_active !== false);
return anyRate ? parseFloat(anyRate.client_rate || 0) : 0;
};
const handleRoleChange = (shiftIndex, roleIndex, field, value) => {
@@ -401,8 +545,14 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
role[field] = value;
if (field === 'role') {
const rate = getRateForRole(value, prev.vendor_id);
console.log('Setting rate for role', value, ':', rate, 'vendor:', prev.vendor_id);
// For vendors, use client-based rates; for clients, use vendor-based rates
let rate = 0;
if (isVendor && prev.business_id) {
rate = getRateForRoleAndClient(value, prev.business_id, prev.business_name);
} else {
rate = getRateForRole(value, prev.vendor_id);
}
console.log('Setting rate for role', value, ':', rate);
role.rate_per_hour = rate;
role.vendor_id = prev.vendor_id;
role.vendor_name = prev.vendor_name;
@@ -628,6 +778,32 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
<CardContent className="p-4 space-y-4">
<Label className="text-sm font-semibold">Event Details</Label>
{isVendor && (
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200">
<Label className="text-xs font-semibold mb-2 flex items-center gap-2">
<Building2 className="w-4 h-4 text-amber-600" />
Select Client *
</Label>
<Select value={formData.business_id || ""} onValueChange={handleBusinessChange}>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="Choose client for this order" />
</SelectTrigger>
<SelectContent>
{businesses.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.business_name}
</SelectItem>
))}
</SelectContent>
</Select>
{formData.business_id && (
<p className="text-xs text-amber-600 mt-2">
Client selected: {formData.business_name}
</p>
)}
</div>
)}
{isClient && (
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
<Label className="text-xs font-semibold mb-2 flex items-center gap-2">
@@ -854,23 +1030,7 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
/>
</div>
{isVendor && (
<div>
<Label className="text-sm mb-1.5 block">Client</Label>
<Select value={formData.business_id || ""} onValueChange={handleBusinessChange}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{businesses.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.business_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-2 p-2.5 bg-green-50 rounded-lg">
<Checkbox

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,342 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Calendar, Copy, FileText, Search, Clock, Users, MapPin, Zap, Save, Star, Trash2 } from "lucide-react";
import { format, parseISO } from "date-fns";
export default function InvoiceQuickActions({
events = [],
invoices = [],
templates = [],
onImportFromEvent,
onDuplicateInvoice,
onUseTemplate,
onSaveTemplate,
onDeleteTemplate
}) {
const [eventDialogOpen, setEventDialogOpen] = useState(false);
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
const [saveTemplateDialogOpen, setSaveTemplateDialogOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [templateName, setTemplateName] = useState("");
// Filter completed events that can be invoiced
const completedEvents = events.filter(e =>
e.status === "Completed" || e.status === "Active" || e.status === "Confirmed"
);
// Filter events by search
const filteredEvents = completedEvents.filter(e =>
e.event_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.hub?.toLowerCase().includes(searchTerm.toLowerCase())
);
// Filter invoices for duplication
const filteredInvoices = invoices.filter(inv =>
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.event_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
// Filter templates
const filteredTemplates = templates.filter(t =>
t.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.client_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleImportEvent = (event) => {
onImportFromEvent(event);
setEventDialogOpen(false);
setSearchTerm("");
};
const handleDuplicate = (invoice) => {
onDuplicateInvoice(invoice);
setDuplicateDialogOpen(false);
setSearchTerm("");
};
const handleUseTemplate = (template) => {
onUseTemplate(template);
setTemplateDialogOpen(false);
setSearchTerm("");
};
const handleSaveTemplate = () => {
if (templateName.trim()) {
onSaveTemplate(templateName.trim());
setSaveTemplateDialogOpen(false);
setTemplateName("");
}
};
return (
<div className="mb-6 p-4 bg-gradient-to-r from-amber-50 to-orange-100 rounded-xl border-2 border-amber-200">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="font-bold text-amber-900">Quick Actions</h3>
<p className="text-xs text-amber-700">Save time with these shortcuts</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{/* Import from Event */}
<Dialog open={eventDialogOpen} onOpenChange={setEventDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
<Calendar className="w-4 h-4 mr-2" />
Import from Event
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-600" />
Import Staff Data from Event
</DialogTitle>
</DialogHeader>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search events by name, client, or hub..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
{filteredEvents.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<Calendar className="w-12 h-12 mx-auto mb-2 text-slate-300" />
<p>No completed events found</p>
</div>
) : (
filteredEvents.map(event => (
<button
key={event.id}
onClick={() => handleImportEvent(event)}
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-blue-400 hover:bg-blue-50 transition-all text-left"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-semibold text-slate-900">{event.event_name}</h4>
<div className="flex items-center gap-3 mt-1 text-sm text-slate-600">
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{event.assigned_staff?.length || 0} staff
</span>
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{event.hub || event.event_location || "—"}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{event.date ? format(parseISO(event.date), 'MMM d, yyyy') : "—"}
</span>
</div>
<p className="text-xs text-slate-500 mt-1">{event.business_name}</p>
</div>
<Badge className={`${event.status === 'Completed' ? 'bg-emerald-100 text-emerald-700' : 'bg-blue-100 text-blue-700'}`}>
{event.status}
</Badge>
</div>
</button>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Duplicate Invoice */}
<Dialog open={duplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
<Copy className="w-4 h-4 mr-2" />
Duplicate Invoice
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="w-5 h-5 text-purple-600" />
Duplicate an Existing Invoice
</DialogTitle>
</DialogHeader>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search by invoice #, client, or event..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
{filteredInvoices.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-2 text-slate-300" />
<p>No invoices found</p>
</div>
) : (
filteredInvoices.slice(0, 20).map(invoice => (
<button
key={invoice.id}
onClick={() => handleDuplicate(invoice)}
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-purple-400 hover:bg-purple-50 transition-all text-left"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-bold text-slate-900">{invoice.invoice_number}</h4>
<Badge variant="outline" className="text-xs">${invoice.amount?.toLocaleString() || 0}</Badge>
</div>
<p className="text-sm text-slate-700 mt-1">{invoice.business_name}</p>
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
<span>{invoice.event_name || "No event"}</span>
<span></span>
<span>{invoice.roles?.[0]?.staff_entries?.length || 0} staff entries</span>
<span></span>
<span>{invoice.issue_date ? format(parseISO(invoice.issue_date), 'MMM d, yyyy') : "—"}</span>
</div>
</div>
<Badge className={`${invoice.status === 'Paid' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'}`}>
{invoice.status}
</Badge>
</div>
</button>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Use Template */}
<Dialog open={templateDialogOpen} onOpenChange={setTemplateDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
<FileText className="w-4 h-4 mr-2" />
Use Template
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-emerald-600" />
Invoice Templates
</DialogTitle>
</DialogHeader>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search templates..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
{filteredTemplates.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<Star className="w-12 h-12 mx-auto mb-2 text-slate-300" />
<p>No templates saved yet</p>
<p className="text-xs mt-1">Create an invoice and save it as a template</p>
</div>
) : (
filteredTemplates.map(template => (
<div
key={template.id}
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-emerald-400 hover:bg-emerald-50 transition-all"
>
<div className="flex items-start justify-between">
<button
onClick={() => handleUseTemplate(template)}
className="flex-1 text-left"
>
<div className="flex items-center gap-2">
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
<h4 className="font-semibold text-slate-900">{template.name}</h4>
</div>
<p className="text-sm text-slate-600 mt-1">{template.client_name}</p>
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
<span>{template.staff_count || 0} staff entries</span>
<span></span>
<span>{template.charges_count || 0} charges</span>
</div>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteTemplate(template.id)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Save as Template */}
<Dialog open={saveTemplateDialogOpen} onOpenChange={setSaveTemplateDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
<Save className="w-4 h-4 mr-2" />
Save as Template
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Save className="w-5 h-5 text-blue-600" />
Save Current Invoice as Template
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm font-medium text-slate-700">Template Name</label>
<Input
placeholder="e.g., Google Weekly Catering, Standard Event Setup..."
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-slate-500 mt-1">
This will save client info, positions, rates, and charges as a reusable template
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setSaveTemplateDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSaveTemplate}
disabled={!templateName.trim()}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="w-4 h-4 mr-2" />
Save Template
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@@ -1,12 +1,13 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, TrendingUp, Users, Star } from "lucide-react";
import { Download, TrendingUp, Users, Star, Heart, AlertTriangle, ArrowUp } from "lucide-react";
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import ReportInsightsBanner from "./ReportInsightsBanner";
export default function ClientTrendsReport({ events, invoices }) {
export default function ClientTrendsReport({ events, invoices, userRole = 'admin' }) {
const { toast } = useToast();
// Bookings by month
@@ -75,65 +76,101 @@ export default function ClientTrendsReport({ events, invoices }) {
toast({ title: "✅ Report Exported", description: "Client trends report downloaded as CSV" });
};
// Churn risk
const lowEngagementClients = topClients.filter(c => c.bookings < 3);
return (
<div className="space-y-6">
{/* AI Insights Banner */}
<ReportInsightsBanner userRole={userRole} reportType="clients" />
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Client Satisfaction & Booking Trends</h2>
<p className="text-sm text-slate-500">Track client engagement and satisfaction metrics</p>
<h2 className="text-xl font-bold text-slate-900">Client Intelligence</h2>
<p className="text-sm text-slate-500">Satisfaction, retention & growth opportunities</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-1" />
Export
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
{/* Retention Alert */}
{lowEngagementClients.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<div>
<p className="text-sm text-slate-500">Total Clients</p>
<p className="text-2xl font-bold text-slate-900">{totalClients}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600" />
<span className="text-sm font-medium text-red-800">{lowEngagementClients.length} clients at churn risk</span>
<p className="text-xs text-red-600">Less than 3 bookings this period</p>
</div>
</div>
<Button size="sm" variant="outline" className="border-red-300 text-red-700 hover:bg-red-100">
View List
</Button>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Heart className="w-5 h-5 text-green-600" />
<div>
<span className="text-sm font-medium text-green-800">{topClients.filter(c => c.bookings >= 5).length} loyal clients</span>
<p className="text-xs text-green-600">5+ bookings eligible for rewards</p>
</div>
</div>
<Button size="sm" variant="outline" className="border-green-300 text-green-700 hover:bg-green-100">
Send Thank You
</Button>
</div>
</div>
)}
{/* Decision Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="border-l-4 border-l-blue-500">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Total Clients</p>
<p className="text-2xl font-bold text-slate-900">{totalClients}</p>
<p className="text-xs text-slate-500 mt-1">Active this period</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Satisfaction</p>
<p className="text-2xl font-bold text-slate-900">{avgSatisfaction}/5</p>
<div className="flex gap-0.5 mt-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-4 h-4 ${i < Math.floor(avgSatisfaction) ? 'fill-amber-400 text-amber-400' : 'text-slate-300'}`} />
))}
</div>
</div>
<div className="w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
<Star className="w-6 h-6 text-amber-600" />
<Card className={`border-l-4 ${avgSatisfaction >= 4.5 ? 'border-l-green-500' : avgSatisfaction >= 4 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Satisfaction</p>
<div className="flex items-center gap-2">
<p className="text-2xl font-bold text-slate-900">{avgSatisfaction}</p>
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-3 h-3 ${i < Math.floor(avgSatisfaction) ? 'fill-amber-400 text-amber-400' : 'text-slate-300'}`} />
))}
</div>
</div>
<p className={`text-xs mt-1 ${avgSatisfaction >= 4.5 ? 'text-green-600' : 'text-amber-600'}`}>
{avgSatisfaction >= 4.5 ? '✓ Excellent' : 'Room to improve'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Repeat Rate</p>
<p className="text-2xl font-bold text-slate-900">{repeatRate}%</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
<Card className={`border-l-4 ${parseFloat(repeatRate) >= 40 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Repeat Rate</p>
<p className="text-2xl font-bold text-slate-900">{repeatRate}%</p>
<p className="text-xs text-slate-500 mt-1">${(topClients.reduce((s, c) => s + c.revenue, 0) * 0.4).toLocaleString()} from repeats</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
<p className="text-sm font-medium text-purple-900 mt-1">
{lowEngagementClients.length > 2
? `Re-engage ${lowEngagementClients.length} low-activity clients with special offers`
: parseFloat(repeatRate) < 40
? 'Launch loyalty program to boost repeat bookings by 25%'
: 'Top 3 clients ready for annual contract — lock in 12-month deals'
}
</p>
</CardContent>
</Card>
</div>

View File

@@ -1,14 +1,15 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, Zap, Clock, TrendingUp, CheckCircle } from "lucide-react";
import { Download, Zap, Clock, TrendingUp, CheckCircle, Target, ArrowUp, ArrowDown } from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from "recharts";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import ReportInsightsBanner from "./ReportInsightsBanner";
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444'];
export default function OperationalEfficiencyReport({ events, staff }) {
export default function OperationalEfficiencyReport({ events, staff, userRole = 'admin' }) {
const { toast } = useToast();
// Automation impact metrics
@@ -78,72 +79,82 @@ export default function OperationalEfficiencyReport({ events, staff }) {
return (
<div className="space-y-6">
{/* AI Insights Banner */}
<ReportInsightsBanner userRole={userRole} reportType="efficiency" />
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Operational Efficiency & Automation Impact</h2>
<p className="text-sm text-slate-500">Track process improvements and automation effectiveness</p>
<h2 className="text-xl font-bold text-slate-900">Operational Efficiency</h2>
<p className="text-sm text-slate-500">Automation impact & process optimization</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-1" />
Export
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Automation Rate</p>
<p className="text-2xl font-bold text-slate-900">{automationRate}%</p>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
<Zap className="w-6 h-6 text-purple-600" />
</div>
</div>
{/* ROI Highlight */}
<div className="bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-purple-800">Automation ROI This Month</p>
<p className="text-2xl font-bold text-purple-900">$24,500 saved</p>
<p className="text-xs text-purple-600">Based on 85% automation rate × manual processing costs</p>
</div>
<div className="flex items-center gap-1 bg-green-100 px-3 py-1 rounded-full">
<ArrowUp className="w-4 h-4 text-green-600" />
<span className="text-sm font-semibold text-green-700">+18% vs last month</span>
</div>
</div>
</div>
{/* Decision Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<Card className={`border-l-4 ${parseFloat(automationRate) >= 80 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Automation</p>
<p className="text-2xl font-bold text-slate-900">{automationRate}%</p>
<p className={`text-xs mt-1 ${parseFloat(automationRate) >= 80 ? 'text-green-600' : 'text-amber-600'}`}>
{parseFloat(automationRate) >= 80 ? '✓ Optimal' : `${(80 - parseFloat(automationRate)).toFixed(0)}% to target`}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Time to Fill</p>
<p className="text-2xl font-bold text-slate-900">{avgTimeToFill}h</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Clock className="w-6 h-6 text-blue-600" />
</div>
</div>
<Card className={`border-l-4 ${avgTimeToFill <= 2 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Time to Fill</p>
<p className="text-2xl font-bold text-slate-900">{avgTimeToFill}h</p>
<p className="text-xs text-slate-500 mt-1">Industry avg: 4.5h</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Response Time</p>
<p className="text-2xl font-bold text-slate-900">{avgResponseTime}h</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
<Card className={`border-l-4 ${avgResponseTime <= 1.5 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Response Time</p>
<p className="text-2xl font-bold text-slate-900">{avgResponseTime}h</p>
<p className="text-xs text-green-600 mt-1">SLA: 2h </p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Completed</p>
<p className="text-2xl font-bold text-slate-900">{events.filter(e => e.status === 'Completed').length}</p>
</div>
<div className="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-emerald-600" />
</div>
</div>
<Card className="border-l-4 border-l-emerald-500">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Completed</p>
<p className="text-2xl font-bold text-slate-900">{events.filter(e => e.status === 'Completed').length}</p>
<p className="text-xs text-slate-500 mt-1">of {totalEvents} total</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
<p className="text-sm font-medium text-purple-900 mt-1">
{parseFloat(automationRate) < 80
? 'Enable auto-assignment for recurring orders (+15% automation)'
: avgTimeToFill > 2
? 'Expand preferred worker pool to reduce fill time'
: 'System performing well — document SOP for new sites'
}
</p>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,325 @@
import React, { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/components/ui/use-toast";
import {
Download, FileText, Table, FileSpreadsheet,
Printer, Share2, Link, CheckCircle, Loader2
} from "lucide-react";
import ReportPDFPreview from "./ReportPDFPreview";
const EXPORT_FORMATS = [
{ id: 'pdf', label: 'PDF Document', icon: FileText, description: 'Best for sharing and printing', color: 'red' },
{ id: 'excel', label: 'Excel Spreadsheet', icon: FileSpreadsheet, description: 'Editable with formulas', color: 'green' },
{ id: 'csv', label: 'CSV File', icon: Table, description: 'Universal data format', color: 'blue' },
{ id: 'json', label: 'JSON (API)', icon: Link, description: 'For system integration', color: 'purple' },
];
export default function ReportExporter({
open,
onClose,
reportName,
reportData,
onExport
}) {
const { toast } = useToast();
const [selectedFormat, setSelectedFormat] = useState('pdf');
const [isExporting, setIsExporting] = useState(false);
const [exportOptions, setExportOptions] = useState({
includeCharts: true,
includeSummary: true,
includeDetails: true,
includeFooter: true,
});
const handleExport = async () => {
setIsExporting(true);
try {
// Simulate export process
await new Promise(resolve => setTimeout(resolve, 1500));
// Generate filename
const timestamp = new Date().toISOString().split('T')[0];
const filename = `${reportName?.replace(/\s+/g, '-').toLowerCase() || 'report'}-${timestamp}`;
// Handle different export formats
let blob;
let extension;
switch (selectedFormat) {
case 'json':
blob = new Blob([JSON.stringify(reportData || {}, null, 2)], { type: 'application/json' });
extension = 'json';
break;
case 'csv':
// Simple CSV conversion
const csvContent = convertToCSV(reportData);
blob = new Blob([csvContent], { type: 'text/csv' });
extension = 'csv';
break;
case 'excel':
case 'pdf':
default:
// For demo, just export as JSON
blob = new Blob([JSON.stringify(reportData || {}, null, 2)], { type: 'application/json' });
extension = 'json';
toast({
title: "Note",
description: `${selectedFormat.toUpperCase()} export would be generated server-side. Downloaded as JSON for demo.`,
});
}
// Trigger download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}.${extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "✅ Export Complete",
description: `${reportName} has been downloaded`,
});
onExport?.({ format: selectedFormat, filename });
onClose();
} catch (error) {
toast({
title: "Export Failed",
description: error.message,
variant: "destructive",
});
} finally {
setIsExporting(false);
}
};
const convertToCSV = (data) => {
if (!data || typeof data !== 'object') return '';
// Handle array of objects
if (Array.isArray(data) && data.length > 0) {
const headers = Object.keys(data[0]);
const rows = data.map(row =>
headers.map(h => JSON.stringify(row[h] ?? '')).join(',')
);
return [headers.join(','), ...rows].join('\n');
}
// Handle single object
return Object.entries(data)
.map(([key, value]) => `${key},${JSON.stringify(value)}`)
.join('\n');
};
const handlePrint = () => {
const printContent = document.getElementById('report-preview-content');
if (printContent) {
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<html>
<head>
<title>${reportName || 'Report'}</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 20px; }
* { box-sizing: border-box; }
</style>
</head>
<body>${printContent.innerHTML}</body>
</html>
`);
printWindow.document.close();
printWindow.print();
} else {
window.print();
}
toast({ title: "Print dialog opened" });
};
const handleShare = async () => {
const shareUrl = window.location.href;
if (navigator.share) {
try {
await navigator.share({
title: reportName,
text: `Check out this ${reportName} report`,
url: shareUrl,
});
} catch (err) {
// User cancelled or error
}
} else {
await navigator.clipboard.writeText(shareUrl);
toast({ title: "Link Copied", description: "Report link copied to clipboard" });
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5 text-[#0A39DF]" />
Export Report
</DialogTitle>
</DialogHeader>
<Tabs value={selectedFormat} onValueChange={setSelectedFormat} className="mt-4">
<TabsList className="grid grid-cols-4 w-full">
{EXPORT_FORMATS.map(f => {
const Icon = f.icon;
return (
<TabsTrigger key={f.id} value={f.id} className="text-xs">
<Icon className="w-3 h-3 mr-1" />
{f.label.split(' ')[0]}
</TabsTrigger>
);
})}
</TabsList>
{/* PDF Preview */}
<TabsContent value="pdf" className="mt-4">
<div id="report-preview-content" className="max-h-[400px] overflow-y-auto border rounded-lg p-2 bg-slate-100">
<ReportPDFPreview
reportName={reportName}
reportData={reportData}
options={exportOptions}
/>
</div>
</TabsContent>
{/* Excel Preview */}
<TabsContent value="excel" className="mt-4">
<div className="border rounded-lg p-4 bg-green-50">
<div className="flex items-center gap-2 mb-3">
<FileSpreadsheet className="w-5 h-5 text-green-600" />
<span className="font-medium text-green-800">Excel Spreadsheet</span>
</div>
<div className="bg-white border rounded text-xs">
<div className="grid grid-cols-4 gap-px bg-slate-200">
<div className="bg-green-100 p-2 font-semibold">Category</div>
<div className="bg-green-100 p-2 font-semibold">Count</div>
<div className="bg-green-100 p-2 font-semibold">Amount</div>
<div className="bg-green-100 p-2 font-semibold">%</div>
{[
['Kitchen Staff', '45', '$42,500', '34%'],
['Event Servers', '38', '$35,200', '28%'],
['Bartenders', '28', '$28,800', '23%'],
].flat().map((cell, i) => (
<div key={i} className="bg-white p-2">{cell}</div>
))}
</div>
</div>
<p className="text-xs text-green-700 mt-2">Includes formulas, pivot tables, and multiple sheets</p>
</div>
</TabsContent>
{/* CSV Preview */}
<TabsContent value="csv" className="mt-4">
<div className="border rounded-lg p-4 bg-blue-50">
<div className="flex items-center gap-2 mb-3">
<Table className="w-5 h-5 text-blue-600" />
<span className="font-medium text-blue-800">CSV Data</span>
</div>
<pre className="bg-slate-900 text-green-400 p-3 rounded text-[10px] overflow-x-auto">
{`category,count,amount,percentage
"Kitchen Staff",45,42500,34
"Event Servers",38,35200,28
"Bartenders",28,28800,23
"Support Staff",22,18500,15`}
</pre>
<p className="text-xs text-blue-700 mt-2">Universal format for any spreadsheet or database</p>
</div>
</TabsContent>
{/* JSON Preview */}
<TabsContent value="json" className="mt-4">
<div className="border rounded-lg p-4 bg-purple-50">
<div className="flex items-center gap-2 mb-3">
<Link className="w-5 h-5 text-purple-600" />
<span className="font-medium text-purple-800">JSON (API)</span>
</div>
<pre className="bg-slate-900 text-amber-400 p-3 rounded text-[10px] overflow-x-auto max-h-32">
{`{
"report": "${reportName}",
"generated": "${new Date().toISOString()}",
"data": {
"totalSpend": 125000,
"fillRate": 94.2,
"categories": [...]
}
}`}
</pre>
<p className="text-xs text-purple-700 mt-2">For API push and system integration</p>
</div>
</TabsContent>
</Tabs>
{/* Export Options */}
<div className="flex items-center gap-4 mt-4 pt-4 border-t flex-wrap">
<div className="flex items-center gap-3 text-sm">
{[
{ key: 'includeSummary', label: 'Summary' },
{ key: 'includeCharts', label: 'Charts' },
{ key: 'includeDetails', label: 'Details' },
].map(opt => (
<label key={opt.key} className="flex items-center gap-1 cursor-pointer">
<Checkbox
checked={exportOptions[opt.key]}
onCheckedChange={(c) => setExportOptions({ ...exportOptions, [opt.key]: c })}
/>
<span className="text-xs text-slate-600">{opt.label}</span>
</label>
))}
</div>
<div className="ml-auto flex gap-2">
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="w-3 h-3 mr-1" />
Print
</Button>
<Button variant="outline" size="sm" onClick={handleShare}>
<Share2 className="w-3 h-3 mr-1" />
Share
</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={handleExport}
className="bg-[#0A39DF]"
disabled={isExporting}
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Export {selectedFormat.toUpperCase()}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,178 @@
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Clock, Brain, TrendingUp, Zap, Lightbulb, Target, AlertTriangle,
ArrowRight, CheckCircle, DollarSign, Users, Shield, Calendar
} from "lucide-react";
const ROLE_CONFIGS = {
procurement: {
color: 'from-blue-600 to-indigo-700',
title: 'Procurement Intelligence',
question: 'What should I negotiate this week?',
metrics: [
{ icon: Clock, label: 'Analysis Time', value: '12 hrs saved', trend: '+15%' },
{ icon: Brain, label: 'Decision Speed', value: '3x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Forecast Accuracy', value: '94%', trend: '+2.1%' },
],
actions: [
{ label: 'Renegotiate 3 vendor contracts expiring in 30 days', priority: 'high', impact: '$12,400 potential savings' },
{ label: 'Consolidate 5 underperforming vendors into Tier 1', priority: 'medium', impact: '18% rate reduction' },
],
prediction: 'Next quarter spend projected at $425K — 8% below budget if consolidation executed',
},
operator: {
color: 'from-emerald-600 to-teal-700',
title: 'Operations Command Center',
question: 'Where do I need to reallocate resources?',
metrics: [
{ icon: Clock, label: 'Report Time', value: '8 hrs saved', trend: '+22%' },
{ icon: Brain, label: 'Reallocation Speed', value: '5x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Demand Forecast', value: '91%', trend: '+3.5%' },
],
actions: [
{ label: 'Sector B understaffed by 12% — shift 8 workers from Sector A', priority: 'high', impact: 'Fill rate +15%' },
{ label: 'OT exposure at 22% in kitchen roles — redistribute shifts', priority: 'medium', impact: '$8,200 OT savings' },
],
prediction: 'Peak demand expected Dec 15-22 — pre-approve 45 additional workers now',
},
sector: {
color: 'from-purple-600 to-violet-700',
title: 'Sector Performance Hub',
question: 'Which sites need immediate attention?',
metrics: [
{ icon: Clock, label: 'Site Analysis', value: '6 hrs saved', trend: '+18%' },
{ icon: Brain, label: 'Shift Decisions', value: '4x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Pattern Accuracy', value: '89%', trend: '+4.2%' },
],
actions: [
{ label: 'Site #3 no-show rate spiked 8% — review worker pool', priority: 'high', impact: 'Reliability +12%' },
{ label: '3 certifications expiring this week — schedule renewals', priority: 'high', impact: 'Compliance 100%' },
],
prediction: 'Tuesday/Wednesday historically 15% understaffed — auto-schedule buffer',
},
client: {
color: 'from-green-600 to-emerald-700',
title: 'Your Staffing Intelligence',
question: 'How can I reduce costs without sacrificing quality?',
metrics: [
{ icon: Clock, label: 'Order Tracking', value: '4 hrs saved', trend: '+25%' },
{ icon: Brain, label: 'Vendor Choice', value: '2x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Cost Accuracy', value: '96%', trend: '+1.8%' },
],
actions: [
{ label: 'Switch 2 orders to Preferred Vendor — same quality, lower rate', priority: 'high', impact: '$2,100 savings' },
{ label: 'Lock in recurring staff for your top 3 positions', priority: 'medium', impact: '15% rate lock' },
],
prediction: 'Your Q1 staffing cost projected at $48K — $6K below last year',
},
vendor: {
color: 'from-amber-600 to-orange-700',
title: 'Vendor Growth Dashboard',
question: 'How do I maximize revenue and retention?',
metrics: [
{ icon: Clock, label: 'Scheduling Time', value: '10 hrs saved', trend: '+30%' },
{ icon: Brain, label: 'Assignment Match', value: '6x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Revenue Forecast', value: '92%', trend: '+2.5%' },
],
actions: [
{ label: '12 workers idle this week — propose to 3 pending orders', priority: 'high', impact: '$4,800 revenue' },
{ label: '2 clients at churn risk — schedule check-in calls', priority: 'high', impact: '$18K annual value' },
],
prediction: 'December revenue projected at $125K — 22% above November',
},
admin: {
color: 'from-slate-700 to-slate-900',
title: 'Platform Command Center',
question: 'What needs my attention across the entire system?',
metrics: [
{ icon: Clock, label: 'Oversight Time', value: '15 hrs saved', trend: '+35%' },
{ icon: Brain, label: 'System Decisions', value: '8x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Platform Accuracy', value: '97%', trend: '+1.2%' },
],
actions: [
{ label: '3 vendors pending compliance review — approve/reject today', priority: 'high', impact: 'Onboard $45K revenue' },
{ label: 'System automation at 85% — enable 2 more workflows', priority: 'medium', impact: '+$12K/month savings' },
],
prediction: 'Platform GMV projected at $2.4M this quarter — 18% growth',
},
};
export default function ReportInsightsBanner({ userRole = 'admin', reportType, data = {} }) {
const config = ROLE_CONFIGS[userRole] || ROLE_CONFIGS.admin;
return (
<div className={`bg-gradient-to-r ${config.color} rounded-xl text-white overflow-hidden`}>
{/* Header */}
<div className="p-4 border-b border-white/10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Lightbulb className="w-5 h-5" />
<span className="font-bold">{config.title}</span>
<Badge className="bg-white/20 text-white border-0 text-[10px]">AI-Powered</Badge>
</div>
<span className="text-sm opacity-80 italic">"{config.question}"</span>
</div>
</div>
{/* Metrics Row */}
<div className="grid grid-cols-3 divide-x divide-white/10">
{config.metrics.map((metric, idx) => {
const Icon = metric.icon;
return (
<div key={idx} className="p-4 text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<Icon className="w-4 h-4 opacity-80" />
<span className="text-xs opacity-80">{metric.label}</span>
</div>
<p className="text-2xl font-bold">{metric.value}</p>
<p className="text-[11px] text-green-300">{metric.trend}</p>
</div>
);
})}
</div>
{/* Actions */}
<div className="p-4 bg-black/20">
<p className="text-xs font-semibold uppercase tracking-wider mb-2 opacity-80">
Recommended Actions
</p>
<div className="space-y-2">
{config.actions.map((action, idx) => (
<div key={idx} className="flex items-center justify-between bg-white/10 rounded-lg px-3 py-2">
<div className="flex items-center gap-2">
{action.priority === 'high' ? (
<AlertTriangle className="w-4 h-4 text-amber-300" />
) : (
<CheckCircle className="w-4 h-4 text-green-300" />
)}
<span className="text-sm">{action.label}</span>
</div>
<Badge className="bg-white/20 text-white border-0 text-[10px]">
{action.impact}
</Badge>
</div>
))}
</div>
</div>
{/* Prediction */}
<div className="p-3 bg-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="w-4 h-4" />
<span className="text-sm font-medium">Prediction:</span>
<span className="text-sm opacity-90">{config.prediction}</span>
</div>
<Button
size="sm"
variant="secondary"
className="h-7 text-xs bg-white text-slate-900 hover:bg-white/90"
onClick={() => window.location.href = '/SavingsEngine'}
>
Take Action <ArrowRight className="w-3 h-3 ml-1" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { format } from "date-fns";
import { FileText, Building2, Calendar, User, TrendingUp, DollarSign, Users, CheckCircle } from "lucide-react";
export default function ReportPDFPreview({ reportName, reportData, options = {} }) {
const now = new Date();
const {
includeSummary = true,
includeCharts = true,
includeDetails = true,
includeFooter = true,
} = options;
// Sample metrics for preview
const metrics = {
totalSpend: reportData?.invoices?.reduce((s, i) => s + (i.amount || 0), 0) || 125000,
eventCount: reportData?.events?.length || 48,
staffCount: reportData?.staff?.length || 156,
vendorCount: reportData?.vendors?.length || 12,
fillRate: 94.2,
avgRate: 42.50,
};
return (
<div className="bg-white border-2 border-slate-200 rounded-lg shadow-lg max-w-2xl mx-auto" style={{ aspectRatio: '8.5/11' }}>
{/* PDF Page Container */}
<div className="p-6 h-full flex flex-col text-sm">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#1C323E] rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">K</span>
</div>
<div>
<h1 className="text-lg font-bold text-[#1C323E]">KROW</h1>
<p className="text-[10px] text-slate-500">Workforce Control Tower</p>
</div>
</div>
<div className="text-right text-[10px] text-slate-500">
<p>Generated: {format(now, 'MMM d, yyyy h:mm a')}</p>
<p>Report ID: RPT-{Math.random().toString(36).substr(2, 8).toUpperCase()}</p>
</div>
</div>
{/* Report Title */}
<div className="bg-gradient-to-r from-[#1C323E] to-[#0A39DF] text-white p-4 rounded-lg mb-4">
<div className="flex items-center gap-2 mb-1">
<FileText className="w-5 h-5" />
<h2 className="text-base font-bold">{reportName || 'Workforce Report'}</h2>
</div>
<p className="text-white/80 text-xs">
Period: {format(new Date(now.getFullYear(), now.getMonth(), 1), 'MMM d')} - {format(now, 'MMM d, yyyy')}
</p>
</div>
{/* Executive Summary */}
{includeSummary && (
<div className="mb-4">
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2 flex items-center gap-1">
<TrendingUp className="w-3 h-3" /> Executive Summary
</h3>
<div className="grid grid-cols-3 gap-2">
<div className="bg-blue-50 p-2 rounded border border-blue-100">
<p className="text-[10px] text-blue-600 font-medium">Total Spend</p>
<p className="text-sm font-bold text-blue-700">${metrics.totalSpend.toLocaleString()}</p>
</div>
<div className="bg-emerald-50 p-2 rounded border border-emerald-100">
<p className="text-[10px] text-emerald-600 font-medium">Fill Rate</p>
<p className="text-sm font-bold text-emerald-700">{metrics.fillRate}%</p>
</div>
<div className="bg-purple-50 p-2 rounded border border-purple-100">
<p className="text-[10px] text-purple-600 font-medium">Avg Rate</p>
<p className="text-sm font-bold text-purple-700">${metrics.avgRate}/hr</p>
</div>
</div>
</div>
)}
{/* Chart Placeholder */}
{includeCharts && (
<div className="mb-4">
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2 flex items-center gap-1">
<DollarSign className="w-3 h-3" /> Spend Breakdown
</h3>
<div className="bg-slate-50 border border-slate-200 rounded p-3 h-24 flex items-center justify-center">
<div className="flex items-end gap-1 h-16">
{[65, 45, 80, 55, 70, 40, 85].map((h, i) => (
<div
key={i}
className="w-6 bg-gradient-to-t from-[#0A39DF] to-blue-400 rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
<div className="ml-4 text-[10px] text-slate-500">
<p>Weekly trend</p>
<p className="text-emerald-600"> 12% vs prior</p>
</div>
</div>
</div>
)}
{/* Data Table */}
{includeDetails && (
<div className="mb-4 flex-1">
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2 flex items-center gap-1">
<Users className="w-3 h-3" /> Detailed Breakdown
</h3>
<div className="border border-slate-200 rounded overflow-hidden">
<table className="w-full text-[10px]">
<thead className="bg-slate-100">
<tr>
<th className="text-left p-1.5 font-semibold">Category</th>
<th className="text-right p-1.5 font-semibold">Count</th>
<th className="text-right p-1.5 font-semibold">Amount</th>
<th className="text-right p-1.5 font-semibold">%</th>
</tr>
</thead>
<tbody>
{[
{ cat: 'Kitchen Staff', count: 45, amount: 42500, pct: 34 },
{ cat: 'Event Servers', count: 38, amount: 35200, pct: 28 },
{ cat: 'Bartenders', count: 28, amount: 28800, pct: 23 },
{ cat: 'Support Staff', count: 22, amount: 18500, pct: 15 },
].map((row, i) => (
<tr key={i} className={i % 2 ? 'bg-slate-50' : ''}>
<td className="p-1.5">{row.cat}</td>
<td className="p-1.5 text-right">{row.count}</td>
<td className="p-1.5 text-right">${row.amount.toLocaleString()}</td>
<td className="p-1.5 text-right">{row.pct}%</td>
</tr>
))}
</tbody>
<tfoot className="bg-slate-100 font-semibold">
<tr>
<td className="p-1.5">Total</td>
<td className="p-1.5 text-right">133</td>
<td className="p-1.5 text-right">$125,000</td>
<td className="p-1.5 text-right">100%</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{/* Key Insights */}
<div className="mb-4">
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2">Key Insights</h3>
<div className="space-y-1">
{[
'Fill rate improved 3.2% month-over-month',
'Kitchen staff utilization at 94% capacity',
'Top vendor: Premier Staffing (42% of orders)',
].map((insight, i) => (
<div key={i} className="flex items-center gap-1.5 text-[10px] text-slate-600">
<CheckCircle className="w-3 h-3 text-emerald-500 flex-shrink-0" />
{insight}
</div>
))}
</div>
</div>
{/* Footer */}
{includeFooter && (
<div className="mt-auto pt-3 border-t border-slate-200">
<div className="flex items-center justify-between text-[9px] text-slate-400">
<div className="flex items-center gap-2">
<Building2 className="w-3 h-3" />
<span>KROW Workforce Control Tower</span>
</div>
<span>Confidential - Internal Use Only</span>
<span>Page 1 of 1</span>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import React, { useState } from "react";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
FileText, DollarSign, Users, TrendingUp, Shield, Clock,
Building2, Package, AlertTriangle, Star, Search, Filter,
Download, Eye, Zap, BarChart3, PieChart, MapPin, Calendar
} from "lucide-react";
const REPORT_TEMPLATES = [
// Procurement Reports
{ id: 'vendor-spend-analysis', name: 'Vendor Spend Analysis', category: 'Procurement', icon: DollarSign, color: 'blue', description: 'Detailed breakdown of spend by vendor, tier, and region', roles: ['procurement', 'admin'] },
{ id: 'vendor-performance-scorecard', name: 'Vendor Performance Scorecard', category: 'Procurement', icon: Star, color: 'amber', description: 'Fill rates, reliability scores, and SLA compliance by vendor', roles: ['procurement', 'admin'] },
{ id: 'rate-compliance', name: 'Rate Compliance Report', category: 'Procurement', icon: Shield, color: 'green', description: 'Contracted vs spot rate usage and savings opportunities', roles: ['procurement', 'admin'] },
{ id: 'vendor-consolidation', name: 'Vendor Consolidation Analysis', category: 'Procurement', icon: Package, color: 'purple', description: 'Opportunities to consolidate vendors for better rates', roles: ['procurement', 'admin'] },
// Operator Reports
{ id: 'enterprise-labor-summary', name: 'Enterprise Labor Summary', category: 'Operator', icon: Building2, color: 'indigo', description: 'Cross-sector labor costs, utilization, and trends', roles: ['operator', 'admin'] },
{ id: 'sector-comparison', name: 'Sector Comparison Report', category: 'Operator', icon: BarChart3, color: 'blue', description: 'Performance benchmarks across all sectors', roles: ['operator', 'admin'] },
{ id: 'overtime-exposure', name: 'Overtime Exposure Report', category: 'Operator', icon: Clock, color: 'red', description: 'OT hours by sector, site, and worker with cost impact', roles: ['operator', 'admin', 'sector'] },
{ id: 'fill-rate-analysis', name: 'Fill Rate Analysis', category: 'Operator', icon: TrendingUp, color: 'emerald', description: 'Order fulfillment rates and gap analysis', roles: ['operator', 'admin', 'sector'] },
// Sector Reports
{ id: 'site-labor-allocation', name: 'Site Labor Allocation', category: 'Sector', icon: MapPin, color: 'purple', description: 'Cost allocation by site, department, and cost center', roles: ['sector', 'operator', 'admin'] },
{ id: 'attendance-patterns', name: 'Attendance Patterns Report', category: 'Sector', icon: Calendar, color: 'blue', description: 'No-shows, late arrivals, and attendance trends', roles: ['sector', 'operator', 'admin'] },
{ id: 'worker-reliability', name: 'Worker Reliability Index', category: 'Sector', icon: Users, color: 'green', description: 'Individual worker performance and reliability scores', roles: ['sector', 'vendor', 'admin'] },
{ id: 'compliance-risk', name: 'Compliance Risk Report', category: 'Sector', icon: AlertTriangle, color: 'amber', description: 'Certification expirations, background check status', roles: ['sector', 'admin'] },
// Vendor Reports
{ id: 'client-revenue', name: 'Client Revenue Report', category: 'Vendor', icon: DollarSign, color: 'emerald', description: 'Revenue by client, event type, and time period', roles: ['vendor', 'admin'] },
{ id: 'workforce-utilization', name: 'Workforce Utilization', category: 'Vendor', icon: Users, color: 'blue', description: 'Staff hours, availability, and utilization rates', roles: ['vendor', 'admin'] },
{ id: 'margin-analysis', name: 'Margin Analysis Report', category: 'Vendor', icon: TrendingUp, color: 'purple', description: 'Profit margins by client, role, and event type', roles: ['vendor', 'admin'] },
{ id: 'staff-performance', name: 'Staff Performance Report', category: 'Vendor', icon: Star, color: 'amber', description: 'Ratings, feedback, and performance trends', roles: ['vendor', 'admin'] },
// Finance & Compliance
{ id: 'invoice-aging', name: 'Invoice Aging Report', category: 'Finance', icon: FileText, color: 'slate', description: 'Outstanding invoices by age and status', roles: ['admin', 'procurement', 'vendor'] },
{ id: 'payroll-summary', name: 'Payroll Summary Report', category: 'Finance', icon: DollarSign, color: 'green', description: 'Total labor costs, taxes, and deductions', roles: ['admin', 'vendor'] },
{ id: 'audit-trail', name: 'Audit Trail Report', category: 'Compliance', icon: Shield, color: 'indigo', description: 'All system changes and user activities', roles: ['admin'] },
{ id: 'certification-status', name: 'Certification Status Report', category: 'Compliance', icon: AlertTriangle, color: 'red', description: 'Expiring and missing certifications', roles: ['admin', 'sector', 'vendor'] },
// Client Reports
{ id: 'event-cost-summary', name: 'Event Cost Summary', category: 'Client', icon: Calendar, color: 'blue', description: 'Detailed costs for all events with breakdown', roles: ['client', 'admin'] },
{ id: 'savings-report', name: 'Savings Achieved Report', category: 'Client', icon: Zap, color: 'emerald', description: 'Cost savings from preferred vendor usage', roles: ['client', 'admin'] },
{ id: 'staff-feedback', name: 'Staff Feedback Report', category: 'Client', icon: Star, color: 'amber', description: 'Ratings and feedback on assigned staff', roles: ['client', 'admin'] },
];
const CATEGORY_COLORS = {
'Procurement': 'bg-blue-100 text-blue-700 border-blue-200',
'Operator': 'bg-indigo-100 text-indigo-700 border-indigo-200',
'Sector': 'bg-purple-100 text-purple-700 border-purple-200',
'Vendor': 'bg-amber-100 text-amber-700 border-amber-200',
'Finance': 'bg-emerald-100 text-emerald-700 border-emerald-200',
'Compliance': 'bg-red-100 text-red-700 border-red-200',
'Client': 'bg-green-100 text-green-700 border-green-200',
};
const ICON_COLORS = {
blue: 'bg-blue-500',
amber: 'bg-amber-500',
green: 'bg-green-500',
purple: 'bg-purple-500',
indigo: 'bg-indigo-500',
red: 'bg-red-500',
emerald: 'bg-emerald-500',
slate: 'bg-slate-500',
};
export default function ReportTemplateLibrary({ userRole, onSelectTemplate, onPreview }) {
const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const filteredTemplates = REPORT_TEMPLATES.filter(template => {
const matchesRole = template.roles.includes(userRole) || userRole === 'admin';
const matchesSearch = template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = categoryFilter === "all" || template.category === categoryFilter;
return matchesRole && matchesSearch && matchesCategory;
});
const categories = [...new Set(REPORT_TEMPLATES.map(t => t.category))];
return (
<div className="space-y-5">
{/* Search & Filters - Clean Row */}
<div className="flex items-center gap-3 flex-wrap bg-white p-3 rounded-xl border">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search reports..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9 bg-slate-50 border-slate-200"
/>
</div>
<div className="flex gap-1.5 flex-wrap">
<Button
size="sm"
variant={categoryFilter === "all" ? "default" : "ghost"}
onClick={() => setCategoryFilter("all")}
className={`h-8 px-3 ${categoryFilter === "all" ? "bg-[#0A39DF]" : "text-slate-600"}`}
>
All
</Button>
{categories.map(cat => (
<Button
key={cat}
size="sm"
variant={categoryFilter === cat ? "default" : "ghost"}
onClick={() => setCategoryFilter(cat)}
className={`h-8 px-3 ${categoryFilter === cat ? "bg-[#0A39DF]" : "text-slate-600"}`}
>
{cat}
</Button>
))}
</div>
<Badge variant="outline" className="ml-auto text-slate-500 font-normal">
{filteredTemplates.length} reports
</Badge>
</div>
{/* Template Grid - Improved Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredTemplates.map(template => {
const Icon = template.icon;
return (
<div
key={template.id}
className="bg-white rounded-xl border border-slate-200 hover:border-blue-400 hover:shadow-lg transition-all cursor-pointer group overflow-hidden"
onClick={() => onSelectTemplate?.(template)}
>
<div className="p-4">
<div className="flex items-start gap-3 mb-3">
<div className={`w-10 h-10 ${ICON_COLORS[template.color]} rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm`}>
<Icon className="w-5 h-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm text-slate-900 group-hover:text-blue-700 leading-tight">
{template.name}
</h4>
<Badge className={`${CATEGORY_COLORS[template.category]} text-[10px] px-1.5 py-0 mt-1`}>
{template.category}
</Badge>
</div>
</div>
<p className="text-xs text-slate-500 line-clamp-2 mb-3 min-h-[32px]">{template.description}</p>
</div>
<div className="px-4 pb-4">
<Button
size="sm"
className="w-full h-9 bg-[#0A39DF] hover:bg-[#0831b8] font-medium"
onClick={(e) => { e.stopPropagation(); onSelectTemplate?.(template); }}
>
<Download className="w-4 h-4 mr-1.5" />
Generate Report
</Button>
</div>
</div>
);
})}
</div>
{filteredTemplates.length === 0 && (
<div className="text-center py-16 bg-white rounded-xl border">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="text-slate-500 font-medium">No reports match your search</p>
<p className="text-sm text-slate-400 mt-1">Try adjusting your filters</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,313 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
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 { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import {
Clock, Calendar, Mail, Plus, Trash2, Edit2, Play, Pause,
Coffee, Sun, Moon, FileText, Download, Bell, Send
} from "lucide-react";
const SCHEDULE_OPTIONS = [
{ value: 'daily', label: 'Daily', icon: Sun, description: 'Every morning at 7 AM' },
{ value: 'weekly', label: 'Weekly', icon: Calendar, description: 'Every Monday morning' },
{ value: 'biweekly', label: 'Bi-Weekly', icon: Calendar, description: 'Every other Monday' },
{ value: 'monthly', label: 'Monthly', icon: Calendar, description: 'First of each month' },
{ value: 'quarterly', label: 'Quarterly', icon: Calendar, description: 'Start of each quarter' },
];
const TIME_OPTIONS = [
{ value: '06:00', label: '6:00 AM - Early Bird' },
{ value: '07:00', label: '7:00 AM - First Coffee ☕' },
{ value: '08:00', label: '8:00 AM - Start of Day' },
{ value: '09:00', label: '9:00 AM - Morning' },
{ value: '12:00', label: '12:00 PM - Midday' },
{ value: '17:00', label: '5:00 PM - End of Day' },
];
export default function ScheduledReports({ userRole, scheduledReports = [], onUpdate }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingReport, setEditingReport] = useState(null);
const [newSchedule, setNewSchedule] = useState({
report_name: '',
report_type: '',
frequency: 'weekly',
time: '07:00',
recipients: '',
format: 'pdf',
is_active: true,
});
const createScheduleMutation = useMutation({
mutationFn: async (scheduleData) => {
// In real app, this would create a scheduled job
// For now, we'll save it to user preferences
await base44.auth.updateMe({
scheduled_reports: [...(scheduledReports || []), {
...scheduleData,
id: Date.now().toString(),
created_at: new Date().toISOString()
}]
});
},
onSuccess: () => {
toast({ title: "✅ Schedule Created", description: "Your report will be delivered automatically" });
setShowCreateModal(false);
setNewSchedule({ report_name: '', report_type: '', frequency: 'weekly', time: '07:00', recipients: '', format: 'pdf', is_active: true });
onUpdate?.();
},
});
const toggleScheduleMutation = useMutation({
mutationFn: async ({ id, is_active }) => {
const updated = scheduledReports.map(r =>
r.id === id ? { ...r, is_active } : r
);
await base44.auth.updateMe({ scheduled_reports: updated });
},
onSuccess: () => {
toast({ title: "Schedule Updated" });
onUpdate?.();
},
});
const deleteScheduleMutation = useMutation({
mutationFn: async (id) => {
const updated = scheduledReports.filter(r => r.id !== id);
await base44.auth.updateMe({ scheduled_reports: updated });
},
onSuccess: () => {
toast({ title: "Schedule Deleted" });
onUpdate?.();
},
});
const handleCreate = () => {
if (!newSchedule.report_name || !newSchedule.recipients) {
toast({ title: "Missing Fields", description: "Please fill in all required fields", variant: "destructive" });
return;
}
createScheduleMutation.mutate(newSchedule);
};
const getFrequencyLabel = (freq) => {
const option = SCHEDULE_OPTIONS.find(o => o.value === freq);
return option?.label || freq;
};
const getNextDelivery = (schedule) => {
const now = new Date();
const [hours, minutes] = schedule.time.split(':').map(Number);
const next = new Date(now);
next.setHours(hours, minutes, 0, 0);
if (next <= now) {
switch (schedule.frequency) {
case 'daily':
next.setDate(next.getDate() + 1);
break;
case 'weekly':
next.setDate(next.getDate() + (7 - now.getDay() + 1) % 7 || 7);
break;
default:
next.setDate(next.getDate() + 1);
}
}
return next.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
};
return (
<div className="space-y-6">
{/* Compact Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Coffee className="w-5 h-5 text-[#0A39DF]" />
<span className="font-semibold text-slate-900">Scheduled Reports</span>
<span className="text-sm text-slate-500"> delivered like your morning coffee</span>
</div>
<Button onClick={() => setShowCreateModal(true)} size="sm" className="bg-[#0A39DF]">
<Plus className="w-4 h-4 mr-1" />
Schedule
</Button>
</div>
{/* Compact Scheduled Reports List */}
{scheduledReports?.length > 0 ? (
<div className="space-y-2">
{scheduledReports.map(schedule => (
<div
key={schedule.id}
className={`flex items-center gap-4 p-3 rounded-lg border ${schedule.is_active ? 'bg-white border-slate-200' : 'bg-slate-50 border-slate-100 opacity-60'}`}
>
<Switch
checked={schedule.is_active}
onCheckedChange={(checked) => toggleScheduleMutation.mutate({ id: schedule.id, is_active: checked })}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900 truncate">{schedule.report_name}</span>
<Badge className="bg-purple-100 text-purple-700 text-[10px]">{getFrequencyLabel(schedule.frequency)}</Badge>
<Badge variant="outline" className="text-[10px]">{schedule.format?.toUpperCase()}</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 mt-1">
<span><Clock className="w-3 h-3 inline mr-1" />{schedule.time}</span>
<span className="truncate"><Mail className="w-3 h-3 inline mr-1" />{schedule.recipients}</span>
{schedule.is_active && <span className="text-emerald-600">Next: {getNextDelivery(schedule)}</span>}
</div>
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => { setEditingReport(schedule); setShowCreateModal(true); }}>
<Edit2 className="w-3 h-3" />
</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-blue-600">
<Send className="w-3 h-3" />
</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-red-600" onClick={() => deleteScheduleMutation.mutate(schedule.id)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-slate-500">
<Clock className="w-8 h-8 mx-auto mb-2 text-slate-300" />
<p className="text-sm">No scheduled reports yet</p>
<Button onClick={() => setShowCreateModal(true)} size="sm" variant="link" className="text-[#0A39DF]">
Create your first schedule
</Button>
</div>
)}
{/* Create/Edit Modal */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-[#0A39DF]" />
{editingReport ? 'Edit Schedule' : 'Create Report Schedule'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Report Name *</label>
<Input
value={newSchedule.report_name}
onChange={(e) => setNewSchedule({ ...newSchedule, report_name: e.target.value })}
placeholder="e.g., Weekly Labor Summary"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Frequency</label>
<Select
value={newSchedule.frequency}
onValueChange={(v) => setNewSchedule({ ...newSchedule, frequency: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
<div className="flex items-center gap-2">
<opt.icon className="w-4 h-4" />
{opt.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Delivery Time</label>
<Select
value={newSchedule.time}
onValueChange={(v) => setNewSchedule({ ...newSchedule, time: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Recipients *</label>
<Input
value={newSchedule.recipients}
onChange={(e) => setNewSchedule({ ...newSchedule, recipients: e.target.value })}
placeholder="email@company.com, another@company.com"
/>
<p className="text-xs text-slate-500 mt-1">Separate multiple emails with commas</p>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Format</label>
<Select
value={newSchedule.format}
onValueChange={(v) => setNewSchedule({ ...newSchedule, format: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pdf">📄 PDF Document</SelectItem>
<SelectItem value="excel">📊 Excel Spreadsheet</SelectItem>
<SelectItem value="csv">📋 CSV File</SelectItem>
</SelectContent>
</Select>
</div>
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
<p className="text-xs text-blue-800">
<Coffee className="w-3 h-3 inline mr-1" />
<strong>Pro Tip:</strong> 7 AM delivery ensures your report is ready with your morning coffee
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateModal(false)}>Cancel</Button>
<Button
onClick={handleCreate}
className="bg-[#0A39DF]"
disabled={createScheduleMutation.isPending}
>
{createScheduleMutation.isPending ? 'Creating...' : 'Create Schedule'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,14 +1,15 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, Users, TrendingUp, Clock } from "lucide-react";
import { Download, Users, TrendingUp, Clock, AlertTriangle, Award } from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useToast } from "@/components/ui/use-toast";
import ReportInsightsBanner from "./ReportInsightsBanner";
export default function StaffPerformanceReport({ staff, events }) {
export default function StaffPerformanceReport({ staff, events, userRole = 'admin' }) {
const { toast } = useToast();
// Calculate staff metrics
@@ -90,60 +91,79 @@ export default function StaffPerformanceReport({ staff, events }) {
toast({ title: "✅ Report Exported", description: "Performance report downloaded as CSV" });
};
// At-risk workers
const atRiskWorkers = staffMetrics.filter(s => s.reliability < 70 || s.noShows > 2);
return (
<div className="space-y-6">
{/* AI Insights Banner */}
<ReportInsightsBanner userRole={userRole} reportType="performance" />
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Staff Performance Metrics</h2>
<p className="text-sm text-slate-500">Reliability, fill rates, and performance tracking</p>
<h2 className="text-xl font-bold text-slate-900">Workforce Performance</h2>
<p className="text-sm text-slate-500">Reliability, fill rates & actionable insights</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-1" />
Export
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Reliability</p>
<p className="text-2xl font-bold text-slate-900">{avgReliability.toFixed(1)}%</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
{/* Quick Actions */}
{atRiskWorkers.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-600" />
<span className="text-sm font-medium text-amber-800">
{atRiskWorkers.length} workers need attention (reliability &lt;70% or 2+ no-shows)
</span>
</div>
<Button size="sm" variant="outline" className="border-amber-300 text-amber-700 hover:bg-amber-100">
View At-Risk Workers
</Button>
</div>
)}
{/* Decision Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className={`border-l-4 ${avgReliability >= 85 ? 'border-l-green-500' : avgReliability >= 70 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Avg Reliability</p>
<p className="text-2xl font-bold text-slate-900">{avgReliability.toFixed(1)}%</p>
<p className={`text-xs mt-1 ${avgReliability >= 85 ? 'text-green-600' : 'text-amber-600'}`}>
{avgReliability >= 85 ? '✓ Excellent' : avgReliability >= 70 ? '⚠ Needs attention' : '✗ Critical'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Fill Rate</p>
<p className="text-2xl font-bold text-slate-900">{avgFillRate.toFixed(1)}%</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600" />
</div>
</div>
<Card className={`border-l-4 ${avgFillRate >= 90 ? 'border-l-green-500' : avgFillRate >= 75 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Avg Fill Rate</p>
<p className="text-2xl font-bold text-slate-900">{avgFillRate.toFixed(1)}%</p>
<p className="text-xs text-slate-500 mt-1">{avgFillRate >= 90 ? 'Above target' : `${(90 - avgFillRate).toFixed(1)}% below target`}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Cancellations</p>
<p className="text-2xl font-bold text-slate-900">{totalCancellations}</p>
</div>
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<Clock className="w-6 h-6 text-red-600" />
</div>
</div>
<Card className={`border-l-4 ${totalCancellations < 5 ? 'border-l-green-500' : totalCancellations < 15 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Cancellations</p>
<p className="text-2xl font-bold text-slate-900">{totalCancellations}</p>
<p className="text-xs text-red-600 mt-1">~${(totalCancellations * 150).toLocaleString()} impact</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
<p className="text-sm font-medium text-purple-900 mt-1">
{atRiskWorkers.length > 0
? `Review ${atRiskWorkers.length} at-risk workers before next scheduling cycle`
: avgFillRate < 90
? 'Expand worker pool by 15% to improve fill rate'
: 'Performance healthy — consider bonuses for top 10%'
}
</p>
</CardContent>
</Card>
</div>

View File

@@ -1,15 +1,16 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, DollarSign, TrendingUp, AlertCircle } from "lucide-react";
import { Download, DollarSign, TrendingUp, AlertCircle, Lightbulb, Clock, Brain, Target } from "lucide-react";
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import ReportInsightsBanner from "./ReportInsightsBanner";
const COLORS = ['#0A39DF', '#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'];
export default function StaffingCostReport({ events, invoices }) {
export default function StaffingCostReport({ events, invoices, userRole = 'admin' }) {
const [dateRange, setDateRange] = useState("30");
const { toast } = useToast();
@@ -92,10 +93,13 @@ export default function StaffingCostReport({ events, invoices }) {
return (
<div className="space-y-6">
{/* AI Insights Banner */}
<ReportInsightsBanner userRole={userRole} reportType="costs" />
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Staffing Costs & Budget Adherence</h2>
<p className="text-sm text-slate-500">Track spending and budget compliance</p>
<h2 className="text-xl font-bold text-slate-900">Labor Spend Analysis</h2>
<p className="text-sm text-slate-500">Track spending, budget compliance & cost optimization</p>
</div>
<div className="flex gap-2">
<Select value={dateRange} onValueChange={setDateRange}>
@@ -109,57 +113,52 @@ export default function StaffingCostReport({ events, invoices }) {
<SelectItem value="365">Last year</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-1" />
Export
</Button>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Spent</p>
<p className="text-2xl font-bold text-slate-900">${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<DollarSign className="w-6 h-6 text-blue-600" />
</div>
</div>
{/* Decision Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="border-l-4 border-l-blue-500">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Total Spent</p>
<p className="text-2xl font-bold text-slate-900">${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
<p className="text-xs text-slate-500 mt-1">This period</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Budget</p>
<p className="text-2xl font-bold text-slate-900">${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
<Card className="border-l-4 border-l-green-500">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Budget</p>
<p className="text-2xl font-bold text-slate-900">${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
<p className="text-xs text-green-600 mt-1">${(totalBudget - totalSpent).toLocaleString()} remaining</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Budget Adherence</p>
<p className="text-2xl font-bold text-slate-900">{adherence}%</p>
<Badge className={adherence < 90 ? "bg-green-500" : adherence < 100 ? "bg-amber-500" : "bg-red-500"}>
{adherence < 90 ? "Under Budget" : adherence < 100 ? "On Track" : "Over Budget"}
</Badge>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-purple-600" />
</div>
</div>
<Card className={`border-l-4 ${adherence < 90 ? 'border-l-green-500' : adherence < 100 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Budget Used</p>
<p className="text-2xl font-bold text-slate-900">{adherence}%</p>
<Badge className={`mt-1 ${adherence < 90 ? "bg-green-100 text-green-700" : adherence < 100 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}`}>
{adherence < 90 ? "✓ Under Budget" : adherence < 100 ? "⚠ On Track" : "✗ Over Budget"}
</Badge>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
<p className="text-sm font-medium text-purple-900 mt-1">
{adherence < 90
? "You have budget room — consider pre-booking Q1 staff at locked rates"
: adherence < 100
? "Monitor closely — reduce OT by 10% to stay on track"
: "Action needed — cut 15% discretionary spend immediately"
}
</p>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,422 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Target, ArrowRight, CheckCircle, AlertTriangle,
TrendingUp, Users, DollarSign, Zap, Star, Shield, Layers
} from "lucide-react";
import ConversionModal from "./ConversionModal";
export default function ContractConversionMap({ assignments, vendors, workforce, metrics, userRole }) {
const [selectedOpportunity, setSelectedOpportunity] = useState(null);
// Role-specific opportunities with fallback spend values
const baseSpend = metrics.totalSpend || 250000; // Default $250k if no data
const getProcurementOpportunities = () => [
{
id: 1,
category: "Vendor Tier Consolidation",
description: "Consolidate Tier 3 vendors into Tier 1 preferred network for better rates",
currentSpend: baseSpend * 0.25,
currentRate: metrics.avgNonContractedRate || 52,
targetRate: metrics.avgContractedRate || 42,
potentialSavings: (baseSpend * 0.25) * 0.19,
savingsPercent: 19,
priority: "high",
count: Math.max(Math.floor(vendors.length * 0.4), 5),
countLabel: "vendors",
recommendation: "Consolidate to Preferred Network",
benefits: ["19% rate reduction", "Stronger SLAs", "Simplified vendor management"],
tierFrom: "Standard Vendors",
tierTo: "Preferred Network",
},
{
id: 2,
category: "Multi-Vendor Spend Consolidation",
description: "Reduce vendor fragmentation by consolidating spend with top 3 partners",
currentSpend: baseSpend * 0.20,
currentRate: 48,
targetRate: 40,
potentialSavings: (baseSpend * 0.20) * 0.17,
savingsPercent: 17,
priority: "high",
count: 5,
countLabel: "vendors to consolidate",
recommendation: "Volume-based rate negotiation",
benefits: ["Volume discounts", "Single invoice processing", "Better compliance"],
tierFrom: "5+ Vendors",
tierTo: "2 Partners",
},
{
id: 3,
category: "Rate Card Optimization",
description: "Renegotiate rates with underperforming vendors or replace with preferred",
currentSpend: baseSpend * 0.15,
currentRate: 65,
targetRate: 55,
potentialSavings: (baseSpend * 0.15) * 0.15,
savingsPercent: 15,
priority: "medium",
count: Math.max(Math.floor(vendors.length * 0.2), 3),
countLabel: "vendors above market",
recommendation: "Rate renegotiation or replacement",
benefits: ["Market-aligned rates", "Performance guarantees", "Contract leverage"],
tierFrom: "Above Market",
tierTo: "Competitive Rates",
},
{
id: 4,
category: "Regional Vendor Optimization",
description: "Add regional preferred vendors to reduce travel costs and improve response",
currentSpend: baseSpend * 0.12,
currentRate: 50,
targetRate: 44,
potentialSavings: (baseSpend * 0.12) * 0.12,
savingsPercent: 12,
priority: "medium",
count: 3,
countLabel: "regions underserved",
recommendation: "Onboard regional vendors",
benefits: ["Local expertise", "Reduced travel fees", "Faster fulfillment"],
tierFrom: "National Only",
tierTo: "Regional Network",
},
];
const getClientOpportunities = () => [
{
id: 1,
category: "Staff Rate Optimization",
description: "Request staff through preferred vendors for lower hourly rates",
currentSpend: baseSpend * 0.25,
currentRate: metrics.avgNonContractedRate || 52,
targetRate: metrics.avgContractedRate || 42,
potentialSavings: (baseSpend * 0.25) * 0.19,
savingsPercent: 19,
priority: "high",
count: Math.max(Math.floor(workforce.length * 0.3), 5),
countLabel: "staff positions",
recommendation: "Use Preferred Vendor Network",
benefits: ["Lower hourly rates", "Same quality staff", "Better reliability"],
tierFrom: "Standard Rates",
tierTo: "Preferred Rates",
},
{
id: 2,
category: "Recurring Staff Savings",
description: "Convert recurring positions to contracted rates for consistent savings",
currentSpend: baseSpend * 0.20,
currentRate: 48,
targetRate: 40,
potentialSavings: (baseSpend * 0.20) * 0.17,
savingsPercent: 17,
priority: "high",
count: Math.max(Math.floor(workforce.length * 0.25), 4),
countLabel: "recurring positions",
recommendation: "Lock in contracted rates",
benefits: ["Predictable costs", "Priority staffing", "Dedicated workforce"],
tierFrom: "Spot Rates",
tierTo: "Contracted",
},
{
id: 3,
category: "Skill-Matched Staffing",
description: "Match staff skills to reduce overtime and improve productivity",
currentSpend: baseSpend * 0.15,
currentRate: 65,
targetRate: 55,
potentialSavings: (baseSpend * 0.15) * 0.15,
savingsPercent: 15,
priority: "medium",
count: Math.max(Math.floor(workforce.length * 0.2), 3),
countLabel: "positions",
recommendation: "Request skill-verified staff",
benefits: ["Less overtime", "Higher productivity", "Fewer replacements"],
tierFrom: "General Staff",
tierTo: "Skill-Matched",
},
{
id: 4,
category: "Favorite Staff Pool",
description: "Build a pool of preferred workers for consistent quality and rates",
currentSpend: baseSpend * 0.12,
currentRate: 50,
targetRate: 44,
potentialSavings: (baseSpend * 0.12) * 0.12,
savingsPercent: 12,
priority: "medium",
count: Math.max(Math.floor(workforce.length * 0.15), 3),
countLabel: "workers to add",
recommendation: "Create favorite staff list",
benefits: ["Familiar with your operations", "Faster onboarding", "Priority booking"],
tierFrom: "Random Assignment",
tierTo: "Preferred Workers",
},
];
const opportunities = userRole === "client" ? getClientOpportunities() : getProcurementOpportunities();
const priorityColors = {
high: { bg: "bg-white", border: "border-slate-200", text: "text-slate-900", badge: "bg-red-100 text-red-700" },
medium: { bg: "bg-white", border: "border-slate-200", text: "text-slate-900", badge: "bg-amber-100 text-amber-700" },
low: { bg: "bg-white", border: "border-slate-200", text: "text-slate-900", badge: "bg-slate-100 text-slate-700" },
};
const totalPotentialSavings = opportunities.reduce((sum, o) => sum + o.potentialSavings, 0);
const totalCount = opportunities.reduce((sum, o) => sum + o.count, 0);
const countLabel = userRole === "client" ? "staff positions" : "vendors";
return (
<div className="space-y-6">
{/* Summary Banner */}
<Card className="bg-[#1C323E] text-white border-0">
<CardContent className="p-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<div className="flex items-center gap-2 mb-2">
<Layers className="w-6 h-6" />
<h3 className="text-xl font-bold">Rate & Vendor Optimization</h3>
</div>
<p className="text-slate-300">
{userRole === "client"
? `Optimize ${totalCount} staff positions for better rates`
: `Consolidate ${totalCount} ${countLabel} into preferred network`
}
</p>
<p className="text-sm text-slate-400 mt-1">
{userRole === "client"
? "Lower rates • Same quality staff • Better reliability"
: "Lower rates • Stronger partnerships • Simplified management"
}
</p>
</div>
<div className="text-right bg-white/10 rounded-xl p-4">
<p className="text-sm text-slate-300">Total Optimization Potential</p>
<p className="text-4xl font-bold">${totalPotentialSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
<p className="text-sm text-slate-300">annual savings</p>
</div>
</div>
</CardContent>
</Card>
{/* How Rate Optimization Works */}
<Card className="border border-slate-200">
<CardContent className="p-5">
<h4 className="font-bold text-slate-900 mb-4 flex items-center gap-2">
<Zap className="w-5 h-5 text-[#0A39DF]" />
How Rate Optimization Works
</h4>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-slate-50 rounded-lg">
<div className="w-10 h-10 bg-[#0A39DF] text-white rounded-full flex items-center justify-center mx-auto mb-2">
<span className="text-lg font-bold">1</span>
</div>
<p className="text-sm font-medium text-slate-900">Identify Workers</p>
<p className="text-xs text-slate-500 mt-1">Workers with higher-tier vendors</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<div className="w-10 h-10 bg-[#0A39DF] text-white rounded-full flex items-center justify-center mx-auto mb-2">
<span className="text-lg font-bold">2</span>
</div>
<p className="text-sm font-medium text-slate-900">Compare Rates</p>
<p className="text-xs text-slate-500 mt-1">Match to preferred vendor rates</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<div className="w-10 h-10 bg-[#0A39DF] text-white rounded-full flex items-center justify-center mx-auto mb-2">
<span className="text-lg font-bold">3</span>
</div>
<p className="text-sm font-medium text-slate-900">Reassign</p>
<p className="text-xs text-slate-500 mt-1">Move to preferred vendors</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<div className="w-10 h-10 bg-emerald-600 text-white rounded-full flex items-center justify-center mx-auto mb-2">
<span className="text-lg font-bold">4</span>
</div>
<p className="text-sm font-medium text-slate-900">Save</p>
<p className="text-xs text-slate-500 mt-1">Lower rates, same quality</p>
</div>
</div>
</CardContent>
</Card>
{/* Rate Tier Visualization */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="border-amber-300 bg-amber-50">
<CardContent className="p-5 text-center">
<div className="w-12 h-12 bg-amber-500 rounded-lg flex items-center justify-center mx-auto mb-3">
<DollarSign className="w-6 h-6 text-white" />
</div>
<h4 className="font-semibold text-slate-900">Standard Tier</h4>
<p className="text-3xl font-bold text-amber-600 mt-2">
${(metrics.avgNonContractedRate || 52).toFixed(0)}/hr
</p>
<p className="text-sm text-slate-500 mt-1">Higher rates Less volume</p>
</CardContent>
</Card>
<Card className="border-[#0A39DF] border-2 bg-blue-50">
<CardContent className="p-5 text-center relative">
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-[#0A39DF] text-white">
OPTIMIZE
</Badge>
<div className="w-12 h-12 bg-[#0A39DF] rounded-lg flex items-center justify-center mx-auto mb-3 mt-1">
<ArrowRight className="w-6 h-6 text-white" />
</div>
<h4 className="font-semibold text-slate-900">Ready to Optimize</h4>
<p className="text-3xl font-bold text-[#0A39DF] mt-2">
{totalCount} {countLabel}
</p>
<p className="text-sm text-slate-500 mt-1">Eligible for optimization</p>
</CardContent>
</Card>
<Card className="border-emerald-300 bg-emerald-50">
<CardContent className="p-5 text-center">
<div className="w-12 h-12 bg-emerald-600 rounded-lg flex items-center justify-center mx-auto mb-3">
<Shield className="w-6 h-6 text-white" />
</div>
<h4 className="font-semibold text-slate-900">Preferred Tier</h4>
<p className="text-3xl font-bold text-emerald-600 mt-2">
${(metrics.avgContractedRate || 42).toFixed(0)}/hr
</p>
<p className="text-sm text-slate-500 mt-1">Better rates Volume discounts</p>
</CardContent>
</Card>
</div>
{/* Optimization Opportunity Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{opportunities.map((opportunity) => {
const colors = priorityColors[opportunity.priority];
return (
<Card key={opportunity.id} className={`${colors.bg} ${colors.border} border-2 hover:shadow-lg transition-all`}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-2">
<Badge className={colors.badge}>
{opportunity.priority === "high" ? "HIGH IMPACT" : "MEDIUM IMPACT"}
</Badge>
<Badge variant="outline" className="bg-white">
{opportunity.tierFrom} {opportunity.tierTo}
</Badge>
</div>
<CardTitle className={`text-lg ${colors.text}`}>{opportunity.category}</CardTitle>
<p className="text-sm text-slate-500 mt-1">{opportunity.description}</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Rate Comparison */}
<div className="bg-white rounded-lg p-3 border">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-700">Rate Comparison</span>
<Badge className="bg-green-100 text-green-700">
Save {opportunity.savingsPercent}%
</Badge>
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<p className="text-xs text-slate-500">Current</p>
<p className="text-lg font-bold text-amber-600">${opportunity.currentRate}/hr</p>
</div>
<ArrowRight className="w-5 h-5 text-purple-500" />
<div className="flex-1">
<p className="text-xs text-slate-500">Optimized</p>
<p className="text-lg font-bold text-emerald-600">${opportunity.targetRate}/hr</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-white/70 rounded-lg p-2">
<p className="text-slate-500 text-xs">Current Spend</p>
<p className="font-bold text-slate-900">${opportunity.currentSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
<div className="bg-white/70 rounded-lg p-2">
<p className="text-slate-500 text-xs">Annual Savings</p>
<p className="font-bold text-green-600">${opportunity.potentialSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-slate-400" />
<span className="text-slate-600">{opportunity.count} {opportunity.countLabel} eligible</span>
</div>
<div className="flex flex-wrap gap-2">
{opportunity.benefits.map((benefit, idx) => (
<Badge key={idx} variant="outline" className="text-xs bg-white">
<CheckCircle className="w-3 h-3 mr-1 text-green-500" />
{benefit}
</Badge>
))}
</div>
<Button className="w-full bg-[#0A39DF] hover:bg-[#0831b8]" onClick={() => setSelectedOpportunity(opportunity)}>
<Zap className="w-4 h-4 mr-2" />
Optimize Now
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Vendor Recommendations */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Star className="w-5 h-5 text-amber-500" />
Recommended Vendors for Conversion
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{vendors.slice(0, 3).map((vendor, idx) => (
<div key={vendor.id || idx} className="p-4 bg-slate-50 rounded-xl border border-slate-200">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-[#0A39DF] rounded-lg flex items-center justify-center">
<Shield className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-semibold text-slate-900">{vendor.legal_name || `Preferred Vendor ${idx + 1}`}</p>
<p className="text-xs text-slate-500">{vendor.region || "National"}</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Avg Rate</span>
<span className="font-medium">${(metrics.avgContractedRate - idx * 2).toFixed(2)}/hr</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Reliability</span>
<span className="font-medium text-green-600">{95 - idx}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Workforce</span>
<span className="font-medium">{vendor.workforce_count || 150 - idx * 20}</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<ConversionModal
open={!!selectedOpportunity}
onClose={() => setSelectedOpportunity(null)}
opportunity={selectedOpportunity}
vendors={vendors}
userRole={userRole}
/>
</div>
);
}

View File

@@ -0,0 +1,690 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import {
Target, Users, DollarSign, CheckCircle, ArrowRight,
Building2, Clock, Sparkles, AlertTriangle, Send, Shield,
FileText, MapPin, Phone, Mail, Star, XCircle, UserPlus,
Briefcase, CheckSquare, AlertCircle
} from "lucide-react";
export default function ConversionModal({ open, onClose, opportunity, vendors = [], userRole }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const [conversionType, setConversionType] = useState(""); // "existing" or "new"
const [selectedVendor, setSelectedVendor] = useState("");
const [selectedWorkers, setSelectedWorkers] = useState([]);
const [notes, setNotes] = useState("");
const [urgency, setUrgency] = useState("normal");
// New vendor referral fields
const [newVendor, setNewVendor] = useState({
legal_name: "",
contact_name: "",
email: "",
phone: "",
region: "",
services: "",
reason: "",
estimated_rate: "",
});
// For Clients: Mock workers for the opportunity
const affectedWorkers = Array.from({ length: opportunity?.count || 5 }, (_, i) => ({
id: `worker-${i + 1}`,
name: `Worker ${i + 1}`,
role: ["Server", "Bartender", "Cook", "Host", "Busser"][i % 5],
currentRate: 22 + Math.random() * 10,
assignments: Math.floor(Math.random() * 20) + 5,
}));
// For Procurement: Mock vendors to consolidate
const affectedVendors = Array.from({ length: opportunity?.count || 5 }, (_, i) => ({
id: `vendor-aff-${i + 1}`,
name: ["Bay Area Events", "Quick Staff Inc.", "Metro Workforce", "City Staffing Co.", "Premier Temps"][i % 5],
tier: i < 2 ? "Tier 1" : i < 4 ? "Tier 2" : "Tier 3",
currentSpend: 15000 + Math.random() * 20000,
rate: 45 + Math.random() * 15,
reliability: 75 + Math.random() * 20,
}));
const isProcurement = userRole === "procurement" || userRole === "admin" || userRole === "operator";
const createTaskMutation = useMutation({
mutationFn: (taskData) => base44.entities.Task.create(taskData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
const createVendorInviteMutation = useMutation({
mutationFn: (inviteData) => base44.entities.VendorInvite.create(inviteData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vendor-invites'] });
},
});
const handleClose = () => {
setStep(1);
setConversionType("");
setSelectedVendor("");
setSelectedWorkers([]);
setNotes("");
setUrgency("normal");
setNewVendor({
legal_name: "",
contact_name: "",
email: "",
phone: "",
region: "",
services: "",
reason: "",
estimated_rate: "",
});
onClose();
};
const handleSubmit = async () => {
if (conversionType === "new") {
// Create Vendor Referral Task for Procurement
const referralTask = {
task_name: `Vendor Referral: ${newVendor.legal_name}`,
team_id: "procurement",
description: `**New Vendor Referral Request**\n\n**Vendor Details:**\n- Company: ${newVendor.legal_name}\n- Contact: ${newVendor.contact_name}\n- Email: ${newVendor.email}\n- Phone: ${newVendor.phone}\n- Region: ${newVendor.region}\n\n**Services Needed:** ${newVendor.services}\n\n**Estimated Rate:** ${newVendor.estimated_rate}\n\n**Reason for Request:** ${newVendor.reason}\n\n**Conversion Opportunity:** ${opportunity?.category}\n**Potential Savings:** $${opportunity?.potentialSavings?.toLocaleString()}\n**Workers to Convert:** ${selectedWorkers.length}\n\n**Urgency:** ${urgency}\n\n**Additional Notes:** ${notes}\n\n---\n**Action Required:**\n1. Verify vendor credentials\n2. Review insurance & compliance docs\n3. Evaluate rate alignment\n4. Approve/Reject for Preferred Network`,
status: "pending",
priority: urgency === "urgent" ? "high" : "normal",
due_date: new Date(Date.now() + (urgency === "urgent" ? 3 : 7) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
};
// Also create vendor invite record
const vendorInvite = {
vendor_name: newVendor.legal_name,
contact_name: newVendor.contact_name,
email: newVendor.email,
phone: newVendor.phone,
status: "pending_review",
referral_reason: newVendor.reason,
services_requested: newVendor.services,
region: newVendor.region,
notes: `Referred via Conversion Engine. Category: ${opportunity?.category}. Potential Savings: $${opportunity?.potentialSavings?.toLocaleString()}`,
};
await Promise.all([
createTaskMutation.mutateAsync(referralTask),
createVendorInviteMutation.mutateAsync(vendorInvite),
]);
toast({
title: "Vendor Referral Submitted",
description: `${newVendor.legal_name} has been sent to Procurement for review and approval.`,
});
} else {
// Existing vendor conversion
const taskData = {
task_name: isProcurement
? `Vendor Consolidation: ${opportunity?.category}`
: `Rate Optimization: ${opportunity?.category}`,
team_id: "procurement",
description: isProcurement
? `**Vendor Consolidation Request**\n\n**Category:** ${opportunity?.category}\n**Target Tier 1 Vendor:** ${vendors.find(v => v.id === selectedVendor)?.legal_name || selectedVendor}\n**Vendors to Consolidate:** ${selectedWorkers.length}\n**Potential Savings:** $${opportunity?.potentialSavings?.toLocaleString()}\n\n**Urgency:** ${urgency}\n\n**Notes:** ${notes}\n\n---\n**Action Required:**\n1. Review vendor spend analysis\n2. Negotiate volume discounts\n3. Migrate contracts to preferred vendor\n4. Track consolidated savings`
: `**Staff Rate Optimization**\n\n**Category:** ${opportunity?.category}\n**Target Vendor:** ${vendors.find(v => v.id === selectedVendor)?.legal_name || selectedVendor}\n**Positions Selected:** ${selectedWorkers.length}\n**Potential Savings:** $${opportunity?.potentialSavings?.toLocaleString()}\n\n**Urgency:** ${urgency}\n\n**Notes:** ${notes}\n\n---\n**Action Required:**\n1. Confirm rate book assignment\n2. Update position assignments\n3. Track savings in dashboard`,
status: "pending",
priority: urgency === "urgent" ? "high" : "normal",
due_date: new Date(Date.now() + (urgency === "urgent" ? 3 : 7) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
};
await createTaskMutation.mutateAsync(taskData);
toast({
title: isProcurement ? "Vendor Consolidation Submitted" : "Optimization Request Submitted",
description: isProcurement
? `${selectedWorkers.length} vendors will be consolidated to ${vendors.find(v => v.id === selectedVendor)?.legal_name || "preferred vendor"}.`
: `${selectedWorkers.length} positions will be optimized with ${vendors.find(v => v.id === selectedVendor)?.legal_name || "preferred vendor"}.`,
});
}
handleClose();
};
const toggleItem = (itemId) => {
setSelectedWorkers(prev =>
prev.includes(itemId)
? prev.filter(id => id !== itemId)
: [...prev, itemId]
);
};
const selectAllItems = () => {
const items = isProcurement ? affectedVendors : affectedWorkers;
if (selectedWorkers.length === items.length) {
setSelectedWorkers([]);
} else {
setSelectedWorkers(items.map(w => w.id));
}
};
if (!opportunity) return null;
const totalSteps = conversionType === "new" ? 4 : 3;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Target className="w-5 h-5 text-[#0A39DF]" />
{conversionType === "new" ? "New Vendor Referral" : "Start Conversion"}: {opportunity.category}
</DialogTitle>
</DialogHeader>
{/* Progress Steps */}
<div className="flex items-center gap-2 mb-6">
{Array.from({ length: totalSteps }, (_, i) => i + 1).map((s) => (
<React.Fragment key={s}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
step >= s ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'
}`}>
{step > s ? <CheckCircle className="w-4 h-4" /> : s}
</div>
{s < totalSteps && (
<div className={`flex-1 h-1 rounded ${step > s ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} />
)}
</React.Fragment>
))}
</div>
{/* Step 1: Choose Conversion Type */}
{step === 1 && (
<div className="space-y-4">
<div className="bg-gradient-to-r from-blue-50 to-purple-50 p-4 rounded-xl border border-blue-100">
<h4 className="font-semibold text-slate-900 mb-3">Conversion Opportunity Summary</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-500">Current Spend</p>
<p className="text-lg font-bold text-slate-900">
${opportunity.currentSpend?.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
</div>
<div>
<p className="text-xs text-slate-500">Potential Savings</p>
<p className="text-lg font-bold text-green-600">
${opportunity.potentialSavings?.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
</div>
<div>
<p className="text-xs text-slate-500">{isProcurement ? "Vendors Affected" : "Positions Affected"}</p>
<p className="text-lg font-bold text-slate-900">{opportunity.count} {opportunity.countLabel}</p>
</div>
<div>
<p className="text-xs text-slate-500">Savings Rate</p>
<p className="text-lg font-bold text-amber-600">{opportunity.savingsPercent}%</p>
</div>
</div>
</div>
<h4 className="font-semibold text-slate-900">How would you like to convert?</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card
className={`cursor-pointer transition-all hover:shadow-md ${
conversionType === "existing" ? 'ring-2 ring-[#0A39DF] bg-blue-50' : 'hover:bg-slate-50'
}`}
onClick={() => setConversionType("existing")}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
<Shield className="w-5 h-5 text-emerald-600" />
</div>
<div>
<h5 className="font-semibold text-slate-900">Preferred Vendor Network</h5>
<p className="text-sm text-slate-500 mt-1">
Convert to an already approved vendor in your network
</p>
<Badge className="mt-2 bg-emerald-100 text-emerald-700">
<CheckCircle className="w-3 h-3 mr-1" />
Pre-Approved
</Badge>
</div>
</div>
</CardContent>
</Card>
<Card
className={`cursor-pointer transition-all hover:shadow-md ${
conversionType === "new" ? 'ring-2 ring-[#0A39DF] bg-blue-50' : 'hover:bg-slate-50'
}`}
onClick={() => setConversionType("new")}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
<UserPlus className="w-5 h-5 text-amber-600" />
</div>
<div>
<h5 className="font-semibold text-slate-900">New Vendor Referral</h5>
<p className="text-sm text-slate-500 mt-1">
Refer a new vendor for procurement approval
</p>
<Badge className="mt-2 bg-amber-100 text-amber-700">
<AlertCircle className="w-3 h-3 mr-1" />
Requires Approval
</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
{conversionType === "new" && (
<div className="bg-amber-50 p-4 rounded-xl border border-amber-200">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5" />
<div>
<p className="font-semibold text-amber-900">Vendor Referral Workflow</p>
<p className="text-sm text-amber-700 mt-1">
This vendor will be flagged as "Not Yet Approved" and sent to Procurement for review.
They'll evaluate pricing, insurance, compliance, and SLA capability before approval.
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Step 2 for New Vendor: Vendor Details */}
{step === 2 && conversionType === "new" && (
<div className="space-y-4">
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200 flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
<p className="text-sm text-blue-800">
<strong>Vendor Referral Form</strong> — This will be sent to Procurement for review
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Company Name *</label>
<Input
value={newVendor.legal_name}
onChange={(e) => setNewVendor({ ...newVendor, legal_name: e.target.value })}
placeholder="e.g., Bay Area Staffing Co."
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Contact Name *</label>
<Input
value={newVendor.contact_name}
onChange={(e) => setNewVendor({ ...newVendor, contact_name: e.target.value })}
placeholder="e.g., John Smith"
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Email *</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
value={newVendor.email}
onChange={(e) => setNewVendor({ ...newVendor, email: e.target.value })}
placeholder="vendor@company.com"
className="pl-10"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Phone</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
value={newVendor.phone}
onChange={(e) => setNewVendor({ ...newVendor, phone: e.target.value })}
placeholder="(555) 123-4567"
className="pl-10"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Region / Location</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
value={newVendor.region}
onChange={(e) => setNewVendor({ ...newVendor, region: e.target.value })}
placeholder="e.g., Bay Area, Los Angeles"
className="pl-10"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Estimated Hourly Rate</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
value={newVendor.estimated_rate}
onChange={(e) => setNewVendor({ ...newVendor, estimated_rate: e.target.value })}
placeholder="e.g., $35-45/hr"
className="pl-10"
/>
</div>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Services Needed *</label>
<Textarea
value={newVendor.services}
onChange={(e) => setNewVendor({ ...newVendor, services: e.target.value })}
placeholder="e.g., Event catering staff, bartenders, servers for corporate events..."
className="h-20"
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Reason for Request *</label>
<Textarea
value={newVendor.reason}
onChange={(e) => setNewVendor({ ...newVendor, reason: e.target.value })}
placeholder="Why do you need this vendor? e.g., Local specialist, unique skills, better coverage in specific area..."
className="h-20"
/>
</div>
</div>
)}
{/* Step 2 for Existing / Step 3 for New: Select Items */}
{((step === 2 && conversionType === "existing") || (step === 3 && conversionType === "new")) && (
<div className="space-y-4">
{conversionType === "existing" && (
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
{isProcurement ? "Select Target Preferred Vendor" : "Select Preferred Vendor"}
</label>
<Select value={selectedVendor} onValueChange={setSelectedVendor}>
<SelectTrigger>
<SelectValue placeholder="Choose from approved vendors" />
</SelectTrigger>
<SelectContent>
{vendors.length > 0 ? vendors.filter(v => v.approval_status === "approved" || v.is_active).map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-500" />
{vendor.legal_name || vendor.doing_business_as}
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
</div>
</SelectItem>
)) : (
<>
<SelectItem value="vendor-1">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-500" />
Premier Staffing Co.
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
</div>
</SelectItem>
<SelectItem value="vendor-2">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-500" />
Elite Workforce Solutions
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
</div>
</SelectItem>
<SelectItem value="vendor-3">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-500" />
Compass Preferred Partners
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
</div>
</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
)}
{/* Procurement: Select Vendors to Consolidate */}
{isProcurement ? (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700">
Select Vendors to Consolidate ({selectedWorkers.length}/{affectedVendors.length})
</label>
<Button variant="ghost" size="sm" onClick={selectAllItems}>
{selectedWorkers.length === affectedVendors.length ? "Deselect All" : "Select All"}
</Button>
</div>
<div className="border rounded-lg divide-y max-h-48 overflow-y-auto">
{affectedVendors.map((vendor) => (
<div
key={vendor.id}
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-slate-50 ${
selectedWorkers.includes(vendor.id) ? 'bg-blue-50' : ''
}`}
onClick={() => toggleItem(vendor.id)}
>
<Checkbox checked={selectedWorkers.includes(vendor.id)} />
<div className="flex-1">
<p className="font-medium text-sm">{vendor.name}</p>
<Badge className={`text-[10px] ${
vendor.tier === "Tier 1" ? "bg-emerald-100 text-emerald-700" :
vendor.tier === "Tier 2" ? "bg-amber-100 text-amber-700" :
"bg-red-100 text-red-700"
}`}>{vendor.tier}</Badge>
</div>
<div className="text-right">
<p className="text-sm font-medium">${vendor.currentSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
<p className="text-xs text-slate-500">${vendor.rate.toFixed(0)}/hr avg</p>
</div>
</div>
))}
</div>
</div>
) : (
/* Client: Select Workers/Positions */
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700">
Select Positions to Optimize ({selectedWorkers.length}/{affectedWorkers.length})
</label>
<Button variant="ghost" size="sm" onClick={selectAllItems}>
{selectedWorkers.length === affectedWorkers.length ? "Deselect All" : "Select All"}
</Button>
</div>
<div className="border rounded-lg divide-y max-h-48 overflow-y-auto">
{affectedWorkers.map((worker) => (
<div
key={worker.id}
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-slate-50 ${
selectedWorkers.includes(worker.id) ? 'bg-blue-50' : ''
}`}
onClick={() => toggleItem(worker.id)}
>
<Checkbox checked={selectedWorkers.includes(worker.id)} />
<div className="flex-1">
<p className="font-medium text-sm">{worker.name}</p>
<p className="text-xs text-slate-500">{worker.role}</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">${worker.currentRate.toFixed(2)}/hr</p>
<p className="text-xs text-slate-500">{worker.assignments} shifts</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Final Step: Confirm & Submit */}
{((step === 3 && conversionType === "existing") || (step === 4 && conversionType === "new")) && (
<div className="space-y-4">
<div className={`p-4 rounded-xl border ${
conversionType === "new"
? 'bg-amber-50 border-amber-200'
: 'bg-emerald-50 border-emerald-200'
}`}>
<h4 className={`font-semibold mb-2 flex items-center gap-2 ${
conversionType === "new" ? 'text-amber-900' : 'text-emerald-900'
}`}>
{conversionType === "new" ? (
<>
<Send className="w-5 h-5" />
Vendor Referral Summary
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
Conversion Request Summary
</>
)}
</h4>
<div className="space-y-2 text-sm">
<p><strong>Category:</strong> {opportunity.category}</p>
<p><strong>{isProcurement ? "Vendors" : "Positions"} Selected:</strong> {selectedWorkers.length}</p>
{conversionType === "new" ? (
<>
<p><strong>New Vendor:</strong> {newVendor.legal_name}</p>
<p><strong>Contact:</strong> {newVendor.contact_name} ({newVendor.email})</p>
<p><strong>Status:</strong> <Badge className="bg-amber-100 text-amber-700">Pending Procurement Review</Badge></p>
</>
) : (
<p><strong>Target Vendor:</strong> {vendors.find(v => v.id === selectedVendor)?.legal_name || selectedVendor}</p>
)}
<p><strong>Estimated Savings:</strong> <span className="text-green-600 font-bold">${Math.round((opportunity.potentialSavings || 0) * (selectedWorkers.length / Math.max(opportunity.count || 1, 1))).toLocaleString()} ({opportunity.savingsPercent || 0}%)</span></p>
</div>
</div>
{conversionType === "new" && (
<div className="bg-blue-50 p-4 rounded-xl border border-blue-200">
<h5 className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
<Briefcase className="w-4 h-4" />
What Happens Next?
</h5>
<ol className="text-sm text-blue-800 space-y-1 list-decimal list-inside">
<li>Vendor referral sent to Procurement team</li>
<li>Procurement evaluates pricing, insurance & compliance</li>
<li>Vendor approved → added to Preferred Network</li>
<li>Spot labor automatically converts to contracted savings</li>
</ol>
</div>
)}
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
Urgency Level
</label>
<Select value={urgency} onValueChange={setUrgency}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-slate-400" />
Low — Within 2 weeks
</div>
</SelectItem>
<SelectItem value="normal">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-500" />
Normal — Within 1 week
</div>
</SelectItem>
<SelectItem value="urgent">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-500" />
Urgent — Within 3 days
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
Additional Notes (Optional)
</label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any special requirements or instructions..."
className="h-20"
/>
</div>
</div>
)}
<DialogFooter className="flex gap-2 mt-6">
{step > 1 && (
<Button variant="outline" onClick={() => setStep(step - 1)}>
Back
</Button>
)}
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
{step === 1 ? (
<Button
onClick={() => setStep(2)}
className="bg-[#0A39DF] hover:bg-[#0831b8]"
disabled={!conversionType}
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
) : step < totalSteps ? (
<Button
onClick={() => setStep(step + 1)}
className="bg-[#0A39DF] hover:bg-[#0831b8]"
disabled={
(step === 2 && conversionType === "new" && (!newVendor.legal_name || !newVendor.email || !newVendor.services || !newVendor.reason)) ||
((step === 2 && conversionType === "existing") || (step === 3 && conversionType === "new")) && selectedWorkers.length === 0
}
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
) : (
<Button
onClick={handleSubmit}
className={conversionType === "new" ? "bg-amber-600 hover:bg-amber-700" : "bg-emerald-600 hover:bg-emerald-700"}
disabled={createTaskMutation.isPending || createVendorInviteMutation.isPending}
>
<Send className="w-4 h-4 mr-2" />
{createTaskMutation.isPending || createVendorInviteMutation.isPending
? "Submitting..."
: conversionType === "new"
? "Submit Vendor Referral"
: "Submit Conversion Request"
}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,335 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
TrendingUp, TrendingDown, DollarSign, Users, Target,
CheckCircle, AlertTriangle, Clock, BarChart3, PieChart,
ArrowUpRight, Zap, Shield, Star, Activity, Calendar,
Package, Award, MapPin, Building2
} from "lucide-react";
import { PieChart as RechartsPie, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
export default function DynamicSavingsDashboard({ metrics, projections, timeRange, userRole }) {
const getTimeLabel = () => {
switch (timeRange) {
case "7days": return "7-Day";
case "30days": return "30-Day";
case "quarter": return "Quarterly";
case "year": return "Annual";
default: return "30-Day";
}
};
const getProjectedSavings = () => {
switch (timeRange) {
case "7days": return projections.sevenDays;
case "30days": return projections.thirtyDays;
case "quarter": return projections.quarter;
case "year": return projections.year;
default: return projections.thirtyDays;
}
};
// Role-specific content
const getRoleContent = () => {
switch (userRole) {
case "procurement":
return {
mainTitle: "Vendor Network Performance",
mainSubtitle: "Optimize your preferred vendor network",
chartTitle: "Vendor Tier Distribution",
chartData: [
{ name: "Preferred Vendors", value: 40, color: "#22c55e" },
{ name: "Approved Vendors", value: 35, color: "#0A39DF" },
{ name: "Standard Vendors", value: 25, color: "#f59e0b" },
],
kpis: [
{ label: "Avg Vendor Score", current: `${(metrics.avgReliability + 5).toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability - 5).toFixed(0)}%`, trend: "up", icon: Award },
{ label: "Contract Compliance", current: `${metrics.contractedRatio.toFixed(0)}%`, optimized: "90%", savings: `+${(90 - metrics.contractedRatio).toFixed(0)}%`, trend: "up", icon: Shield },
{ label: "Rate Variance", current: `$${metrics.avgNonContractedRate.toFixed(2)}`, optimized: `$${metrics.avgContractedRate.toFixed(2)}`, savings: `-$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}`, trend: "down", icon: DollarSign },
{ label: "SLA Adherence", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: CheckCircle },
],
actions: [
{ priority: "high", action: "Upgrade 3 high-performing Standard vendors to Approved tier", impact: "Better rates, guaranteed capacity", deadline: "This week" },
{ priority: "high", action: "Renegotiate rates with top 5 Preferred vendors", impact: `$${(getProjectedSavings() * 0.3).toLocaleString()} potential savings`, deadline: "This month" },
{ priority: "medium", action: "Review vendors below 85% SLA adherence", impact: "Improve network reliability", deadline: "Next 2 weeks" },
],
};
case "operator":
return {
mainTitle: "Enterprise Operational Efficiency",
mainSubtitle: "Cross-sector performance optimization",
chartTitle: "Spend by Sector",
chartData: [
{ name: "Food Service", value: 45, color: "#0A39DF" },
{ name: "Events", value: 30, color: "#8b5cf6" },
{ name: "Facilities", value: 15, color: "#06b6d4" },
{ name: "Other", value: 10, color: "#f59e0b" },
],
kpis: [
{ label: "Fill Rate", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Target },
{ label: "Labor Utilization", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Users },
{ label: "Cost Efficiency", current: `$${(metrics.totalSpend / Math.max(metrics.completedOrders, 1)).toFixed(0)}`, optimized: `$${((metrics.totalSpend / Math.max(metrics.completedOrders, 1)) * 0.85).toFixed(0)}`, savings: "-15%", trend: "down", icon: DollarSign },
{ label: "Overtime Rate", current: "12%", optimized: "5%", savings: "-7%", trend: "down", icon: Clock },
],
actions: [
{ priority: "high", action: "Balance workforce across underperforming sectors", impact: "Improve fill rate by 8%", deadline: "This week" },
{ priority: "high", action: "Implement predictive scheduling for peak periods", impact: "Reduce overtime by 40%", deadline: "Next 2 weeks" },
{ priority: "medium", action: "Cross-train staff for multi-sector flexibility", impact: "Increase utilization 15%", deadline: "This quarter" },
],
};
case "sector":
return {
mainTitle: "Location Performance",
mainSubtitle: "Your site's staffing efficiency",
chartTitle: "Position Coverage",
chartData: [
{ name: "Filled Positions", value: metrics.fillRate, color: "#22c55e" },
{ name: "Open Gaps", value: 100 - metrics.fillRate, color: "#ef4444" },
],
kpis: [
{ label: "Position Fill Rate", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "100%", savings: `+${(100 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Target },
{ label: "Staff Attendance", current: `${(100 - metrics.noShowRate).toFixed(0)}%`, optimized: "99%", savings: `+${(99 - (100 - metrics.noShowRate)).toFixed(0)}%`, trend: "up", icon: CheckCircle },
{ label: "Shift Coverage", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Calendar },
{ label: "Response Time", current: "4 hrs", optimized: "1 hr", savings: "-3 hrs", trend: "down", icon: Clock },
],
actions: [
{ priority: "high", action: "Fill 3 critical morning shift gaps", impact: "100% coverage for peak hours", deadline: "Tomorrow" },
{ priority: "high", action: "Request backup staff for weekend events", impact: "Prevent no-show impact", deadline: "This week" },
{ priority: "medium", action: "Train cross-functional backup team", impact: "Reduce gap response time", deadline: "This month" },
],
};
case "client":
return {
mainTitle: "Your Event Coverage & Savings",
mainSubtitle: "Maximize staffing quality while reducing costs",
chartTitle: "Event Fulfillment",
chartData: [
{ name: "Fully Staffed", value: metrics.fillRate, color: "#22c55e" },
{ name: "Partial Coverage", value: Math.max(0, 100 - metrics.fillRate - 5), color: "#f59e0b" },
{ name: "Gaps", value: Math.min(5, 100 - metrics.fillRate), color: "#ef4444" },
],
kpis: [
{ label: "Event Coverage", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "100%", savings: `+${(100 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Calendar },
{ label: "Staff Quality", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Star },
{ label: "Cost Savings", current: `$${metrics.avgNonContractedRate.toFixed(0)}/hr`, optimized: `$${metrics.avgContractedRate.toFixed(0)}/hr`, savings: `-$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(0)}/hr`, trend: "down", icon: DollarSign },
{ label: "On-Time Arrival", current: `${(100 - metrics.noShowRate).toFixed(0)}%`, optimized: "99%", savings: `+${(99 - (100 - metrics.noShowRate)).toFixed(0)}%`, trend: "up", icon: Clock },
],
actions: [
{ priority: "high", action: "Book preferred vendors for upcoming events", impact: `Save $${(getProjectedSavings() * 0.2).toLocaleString()} on rates`, deadline: "Before next event" },
{ priority: "medium", action: "Request top-rated staff from past events", impact: "Improve service quality 20%", deadline: "Next booking" },
{ priority: "medium", action: "Bundle multiple events for volume discount", impact: "Additional 10% savings", deadline: "This month" },
],
};
case "vendor":
return {
mainTitle: "Your Competitive Performance",
mainSubtitle: "Stand out in the vendor network",
chartTitle: "Your Performance vs. Network",
chartData: [
{ name: "Your Score", value: metrics.avgReliability, color: "#0A39DF" },
{ name: "Network Avg", value: 100 - metrics.avgReliability, color: "#e2e8f0" },
],
kpis: [
{ label: "Fill Rate", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "99%", savings: `+${(99 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Target },
{ label: "Team Reliability", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Users },
{ label: "Client Value", current: `$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}/hr`, optimized: `$${((metrics.avgNonContractedRate - metrics.avgContractedRate) * 1.2).toFixed(2)}/hr`, savings: "+20%", trend: "up", icon: Zap },
{ label: "Response Time", current: "2 hrs", optimized: "30 min", savings: "-1.5 hrs", trend: "down", icon: Clock },
],
actions: [
{ priority: "high", action: "Improve response time to order requests", impact: "Win 15% more orders", deadline: "Immediate" },
{ priority: "high", action: "Reduce no-show rate below 2%", impact: "Upgrade to Preferred tier", deadline: "This month" },
{ priority: "medium", action: "Expand certified workforce pool", impact: "Access premium assignments", deadline: "This quarter" },
],
};
default: // admin
return {
mainTitle: "Platform-Wide Savings",
mainSubtitle: "Transform labor spend into strategic advantage",
chartTitle: "Labor Spend Mix",
chartData: [
{ name: "Contracted Labor", value: metrics.contractedSpend, color: "#22c55e" },
{ name: "Non-Contracted", value: metrics.nonContractedSpend, color: "#ef4444" },
],
kpis: [
{ label: "Cost per Hour", current: `$${metrics.avgNonContractedRate.toFixed(2)}`, optimized: `$${metrics.avgContractedRate.toFixed(2)}`, savings: `-$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}`, trend: "down", icon: DollarSign },
{ label: "Fill Rate", current: `${metrics.fillRate.toFixed(1)}%`, optimized: "98%", savings: `+${(98 - metrics.fillRate).toFixed(1)}%`, trend: "up", icon: Target },
{ label: "No-Show Rate", current: `${metrics.noShowRate.toFixed(1)}%`, optimized: "1%", savings: `-${(metrics.noShowRate - 1).toFixed(1)}%`, trend: "down", icon: AlertTriangle },
{ label: "Reliability Score", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Shield },
],
actions: [
{ priority: "high", action: "Convert 15 high-volume gig workers to preferred vendor", impact: `$${(getProjectedSavings() * 0.25).toLocaleString()} savings`, deadline: "This week" },
{ priority: "high", action: "Negotiate volume discount with top 3 vendors", impact: `$${(getProjectedSavings() * 0.2).toLocaleString()} savings`, deadline: "Next 2 weeks" },
{ priority: "medium", action: "Reduce overtime through better scheduling", impact: `$${(getProjectedSavings() * 0.15).toLocaleString()} savings`, deadline: "This month" },
],
};
}
};
const content = getRoleContent();
return (
<div className="space-y-6">
{/* Main Summary */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white border-0">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-6">
<div>
<p className="text-blue-200 text-sm font-medium mb-1">{content.mainTitle}</p>
<p className="text-4xl font-bold">${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
<p className="text-blue-200 mt-2">{content.mainSubtitle}</p>
</div>
<div className="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center">
<TrendingUp className="w-8 h-8 text-white" />
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{content.kpis.slice(0, 4).map((kpi, idx) => (
<div key={idx} className="bg-white/10 rounded-lg p-3">
<p className="text-blue-200 text-xs">{kpi.label}</p>
<p className="text-xl font-bold">{kpi.current}</p>
<p className="text-xs text-green-300">{kpi.savings}</p>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<PieChart className="w-5 h-5 text-slate-400" />
{content.chartTitle}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<RechartsPie>
<Pie
data={content.chartData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={70}
paddingAngle={5}
dataKey="value"
>
{content.chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(value) => typeof value === 'number' && value > 100 ? `$${value.toLocaleString()}` : `${value}%`} />
</RechartsPie>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-3 mt-2">
{content.chartData.map((item, idx) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} />
<span className="text-slate-600">{item.name}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* KPI Comparison */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-500" />
Performance Optimization Metrics
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{content.kpis.map((kpi, idx) => {
const Icon = kpi.icon;
return (
<div key={idx} className="p-4 bg-slate-50 rounded-xl">
<div className="flex items-center gap-2 mb-3">
<Icon className="w-5 h-5 text-slate-400" />
<span className="font-medium text-slate-700">{kpi.label}</span>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-slate-500">Current</span>
<span className="font-semibold text-slate-900">{kpi.current}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-500">Target</span>
<span className="font-semibold text-green-600">{kpi.optimized}</span>
</div>
<div className="pt-2 border-t border-slate-200">
<div className="flex justify-between items-center">
<span className="text-sm text-slate-500">Impact</span>
<Badge className={kpi.trend === "up" ? "bg-green-100 text-green-700" : "bg-blue-100 text-blue-700"}>
{kpi.trend === "up" ? <TrendingUp className="w-3 h-3 mr-1" /> : <TrendingDown className="w-3 h-3 mr-1" />}
{kpi.savings}
</Badge>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Action Items */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="w-5 h-5 text-amber-500" />
Priority Actions for You
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{content.actions.map((item, idx) => (
<div
key={idx}
className={`p-4 rounded-xl border-2 flex items-center justify-between ${
item.priority === "high"
? "bg-red-50 border-red-200"
: "bg-amber-50 border-amber-200"
}`}
>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${item.priority === "high" ? "bg-red-500" : "bg-amber-500"}`} />
<div>
<p className="font-medium text-slate-900">{item.action}</p>
<div className="flex items-center gap-3 mt-1 text-sm text-slate-500">
<span className="flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
{item.impact}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{item.deadline}
</span>
</div>
</div>
</div>
<Badge className={item.priority === "high" ? "bg-red-100 text-red-700" : "bg-amber-100 text-amber-700"}>
{item.priority.toUpperCase()}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,272 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Users, DollarSign, Clock, TrendingUp, TrendingDown,
Building2, MapPin, Briefcase, AlertTriangle, CheckCircle
} from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
export default function LaborSpendAnalysis({ assignments, workforce, orders, metrics, userRole }) {
// Generate channel breakdown
const channelData = [
{ channel: "Preferred Vendors", spend: metrics.contractedSpend * 0.6, workers: Math.floor(workforce.length * 0.4), efficiency: 94 },
{ channel: "Approved Vendors", spend: metrics.contractedSpend * 0.4, workers: Math.floor(workforce.length * 0.25), efficiency: 88 },
{ channel: "Gig Platforms", spend: metrics.nonContractedSpend * 0.5, workers: Math.floor(workforce.length * 0.2), efficiency: 72 },
{ channel: "Agency Labor", spend: metrics.nonContractedSpend * 0.3, workers: Math.floor(workforce.length * 0.1), efficiency: 68 },
{ channel: "Internal Pool", spend: metrics.nonContractedSpend * 0.2, workers: Math.floor(workforce.length * 0.05), efficiency: 91 },
];
const utilizationData = [
{ category: "Culinary Staff", utilized: 85, available: 100, cost: 45000 },
{ category: "Event Staff", utilized: 78, available: 100, cost: 32000 },
{ category: "Bartenders", utilized: 92, available: 100, cost: 28000 },
{ category: "Security", utilized: 65, available: 100, cost: 18000 },
{ category: "Facilities", utilized: 71, available: 100, cost: 15000 },
];
const benchmarkData = [
{ metric: "Cost per Hour", yours: metrics.avgNonContractedRate, benchmark: 42.50, industry: 48.00 },
{ metric: "Fill Rate", yours: metrics.fillRate, benchmark: 95, industry: 88 },
{ metric: "No-Show Rate", yours: metrics.noShowRate, benchmark: 2, industry: 5 },
{ metric: "OT Percentage", yours: 12, benchmark: 8, industry: 15 },
];
return (
<div className="space-y-6">
{/* Channel Breakdown */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-blue-500" />
Labor Spend by Channel
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead>Channel</TableHead>
<TableHead className="text-right">Spend</TableHead>
<TableHead className="text-right">Workers</TableHead>
<TableHead className="text-right">Efficiency</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channelData.map((row, idx) => (
<TableRow key={idx}>
<TableCell className="font-medium">{row.channel}</TableCell>
<TableCell className="text-right font-semibold">
${row.spend.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</TableCell>
<TableCell className="text-right">{row.workers}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Progress value={row.efficiency} className="w-16 h-2" />
<span className="text-sm font-medium">{row.efficiency}%</span>
</div>
</TableCell>
<TableCell>
<Badge className={row.efficiency >= 85 ? "bg-green-100 text-green-700" : row.efficiency >= 70 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}>
{row.efficiency >= 85 ? "Optimal" : row.efficiency >= 70 ? "Review" : "Optimize"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Utilization Chart */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5 text-purple-500" />
Workforce Utilization by Category
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={utilizationData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis type="number" domain={[0, 100]} stroke="#64748b" fontSize={12} />
<YAxis type="category" dataKey="category" stroke="#64748b" fontSize={12} width={100} />
<Tooltip
formatter={(value) => [`${value}%`, 'Utilization']}
contentStyle={{ background: 'white', border: '1px solid #e2e8f0', borderRadius: '8px' }}
/>
<Bar dataKey="utilized" fill="#0A39DF" radius={[0, 4, 4, 0]} name="Utilized" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-green-500" />
Cost by Category
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{utilizationData.map((item, idx) => (
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#0A39DF] rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-slate-900">{item.category}</p>
<p className="text-sm text-slate-500">{item.utilized}% utilized</p>
</div>
</div>
<div className="text-right">
<p className="font-bold text-slate-900">${item.cost.toLocaleString()}</p>
<p className="text-xs text-slate-500">monthly spend</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Benchmarking */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-amber-500" />
Performance Benchmarking
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{benchmarkData.map((item, idx) => {
const isBetter = item.metric === "No-Show Rate" || item.metric === "OT Percentage"
? item.yours < item.benchmark
: item.yours > item.benchmark;
return (
<div key={idx} className="p-4 bg-slate-50 rounded-xl">
<p className="text-sm text-slate-500 mb-2">{item.metric}</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Your Rate</span>
<span className={`font-bold ${isBetter ? "text-green-600" : "text-red-600"}`}>
{item.metric.includes("Rate") || item.metric.includes("Percentage")
? `${item.yours.toFixed(1)}%`
: `$${item.yours.toFixed(2)}`}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Benchmark</span>
<span className="font-medium text-slate-900">
{item.metric.includes("Rate") || item.metric.includes("Percentage")
? `${item.benchmark}%`
: `$${item.benchmark.toFixed(2)}`}
</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-200">
<span className="text-sm text-slate-600">Industry Avg</span>
<span className="text-slate-500">
{item.metric.includes("Rate") || item.metric.includes("Percentage")
? `${item.industry}%`
: `$${item.industry.toFixed(2)}`}
</span>
</div>
</div>
<div className="mt-3">
<Badge className={isBetter ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"}>
{isBetter ? <CheckCircle className="w-3 h-3 mr-1" /> : <AlertTriangle className="w-3 h-3 mr-1" />}
{isBetter ? "Above Benchmark" : "Below Benchmark"}
</Badge>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Contracted vs Non-Contracted Comparison */}
<Card>
<CardHeader>
<CardTitle>Contracted vs. Non-Contracted Labor Analysis</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-6 bg-green-50 rounded-xl border-2 border-green-200">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-white" />
</div>
<div>
<h4 className="font-bold text-green-900">Contracted Labor</h4>
<p className="text-sm text-green-700">{metrics.contractedRatio.toFixed(1)}% of total spend</p>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-green-700">Total Spend</span>
<span className="font-bold text-green-900">${metrics.contractedSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
</div>
<div className="flex justify-between">
<span className="text-green-700">Avg Rate</span>
<span className="font-bold text-green-900">${metrics.avgContractedRate.toFixed(2)}/hr</span>
</div>
<div className="flex justify-between">
<span className="text-green-700">Reliability</span>
<span className="font-bold text-green-900">92%</span>
</div>
<div className="flex justify-between">
<span className="text-green-700">Fill Rate</span>
<span className="font-bold text-green-900">96%</span>
</div>
</div>
</div>
<div className="p-6 bg-red-50 rounded-xl border-2 border-red-200">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-red-500 rounded-xl flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-white" />
</div>
<div>
<h4 className="font-bold text-red-900">Non-Contracted Labor</h4>
<p className="text-sm text-red-700">{(100 - metrics.contractedRatio).toFixed(1)}% of total spend</p>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-red-700">Total Spend</span>
<span className="font-bold text-red-900">${metrics.nonContractedSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
</div>
<div className="flex justify-between">
<span className="text-red-700">Avg Rate</span>
<span className="font-bold text-red-900">${metrics.avgNonContractedRate.toFixed(2)}/hr</span>
</div>
<div className="flex justify-between">
<span className="text-red-700">Reliability</span>
<span className="font-bold text-red-900">71%</span>
</div>
<div className="flex justify-between">
<span className="text-red-700">Fill Rate</span>
<span className="font-bold text-red-900">78%</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,259 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
TrendingUp, DollarSign, Calendar, Target,
ArrowRight, CheckCircle, Sparkles, BarChart3,
Clock, Users, Zap, ArrowUpRight
} from "lucide-react";
import { LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Legend } from "recharts";
export default function PredictiveSavingsModel({ metrics, projections, assignments, rates, userRole }) {
// Generate forecast data
const forecastData = [
{ month: "Jan", current: 45000, optimized: 38000, savings: 7000 },
{ month: "Feb", current: 48000, optimized: 40000, savings: 8000 },
{ month: "Mar", current: 52000, optimized: 42000, savings: 10000 },
{ month: "Apr", current: 50000, optimized: 40000, savings: 10000 },
{ month: "May", current: 55000, optimized: 43000, savings: 12000 },
{ month: "Jun", current: 58000, optimized: 45000, savings: 13000 },
{ month: "Jul", current: 62000, optimized: 47000, savings: 15000 },
{ month: "Aug", current: 60000, optimized: 46000, savings: 14000 },
{ month: "Sep", current: 55000, optimized: 43000, savings: 12000 },
{ month: "Oct", current: 58000, optimized: 45000, savings: 13000 },
{ month: "Nov", current: 65000, optimized: 50000, savings: 15000 },
{ month: "Dec", current: 70000, optimized: 53000, savings: 17000 },
];
const savingsStrategies = [
{
id: 1,
strategy: "Shift to Higher-Performing Vendors",
impact: "High",
savingsPercent: 12,
timeToImplement: "2-4 weeks",
confidence: 92,
description: "Consolidate spend with top-tier vendors who offer better rates and reliability",
},
{
id: 2,
strategy: "Contracted Pricing Negotiation",
impact: "High",
savingsPercent: 15,
timeToImplement: "4-6 weeks",
confidence: 88,
description: "Lock in volume discounts with preferred supplier agreements",
},
{
id: 3,
strategy: "Internal Workforce Pool",
impact: "Medium",
savingsPercent: 8,
timeToImplement: "6-8 weeks",
confidence: 85,
description: "Build internal pool for recurring needs, reducing agency fees",
},
{
id: 4,
strategy: "Skill-Matched Talent",
impact: "Medium",
savingsPercent: 6,
timeToImplement: "2-3 weeks",
confidence: 90,
description: "Match worker skills to job requirements, reducing overtime and rework",
},
];
const scenarioData = [
{ scenario: "Conservative", savings: projections.year * 0.7 },
{ scenario: "Moderate", savings: projections.year },
{ scenario: "Aggressive", savings: projections.year * 1.4 },
];
return (
<div className="space-y-6">
{/* Predictive Header */}
<Card className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white border-0">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold">AI-Powered Savings Forecast</h3>
<p className="text-purple-200">Predictive analysis based on your workforce data</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
{[
{ label: "7-Day Forecast", value: projections.sevenDays },
{ label: "30-Day Forecast", value: projections.thirtyDays },
{ label: "Quarterly Forecast", value: projections.quarter },
{ label: "Annual Forecast", value: projections.year },
].map((item, idx) => (
<div key={idx} className="bg-white/10 rounded-lg p-4">
<p className="text-purple-200 text-sm">{item.label}</p>
<p className="text-2xl font-bold">${item.value.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* Forecast Chart */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-green-500" />
12-Month Savings Projection
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={forecastData}>
<defs>
<linearGradient id="colorCurrent" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#ef4444" stopOpacity={0}/>
</linearGradient>
<linearGradient id="colorOptimized" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#22c55e" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="month" stroke="#64748b" fontSize={12} />
<YAxis stroke="#64748b" fontSize={12} tickFormatter={(v) => `$${(v/1000).toFixed(0)}k`} />
<Tooltip
formatter={(value) => [`$${value.toLocaleString()}`, '']}
contentStyle={{ background: 'white', border: '1px solid #e2e8f0', borderRadius: '8px' }}
/>
<Legend />
<Area type="monotone" dataKey="current" stroke="#ef4444" fill="url(#colorCurrent)" name="Current Spend" />
<Area type="monotone" dataKey="optimized" stroke="#22c55e" fill="url(#colorOptimized)" name="Optimized Spend" />
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-blue-500" />
Monthly Savings Breakdown
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={forecastData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="month" stroke="#64748b" fontSize={12} />
<YAxis stroke="#64748b" fontSize={12} tickFormatter={(v) => `$${(v/1000).toFixed(0)}k`} />
<Tooltip
formatter={(value) => [`$${value.toLocaleString()}`, 'Savings']}
contentStyle={{ background: 'white', border: '1px solid #e2e8f0', borderRadius: '8px' }}
/>
<Bar dataKey="savings" fill="#0A39DF" radius={[4, 4, 0, 0]} name="Savings" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
{/* Savings Strategies */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="w-5 h-5 text-amber-500" />
Recommended Savings Strategies
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{savingsStrategies.map((strategy) => (
<div key={strategy.id} className="p-4 bg-slate-50 rounded-xl border border-slate-200 hover:shadow-md transition-all">
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-slate-900">{strategy.strategy}</h4>
<Badge className={strategy.impact === "High" ? "bg-red-100 text-red-700" : "bg-amber-100 text-amber-700"}>
{strategy.impact} Impact
</Badge>
</div>
<p className="text-sm text-slate-600">{strategy.description}</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-green-600">+{strategy.savingsPercent}%</p>
<p className="text-xs text-slate-500">potential savings</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1 text-slate-500">
<Clock className="w-4 h-4" />
{strategy.timeToImplement}
</div>
<div className="flex items-center gap-1 text-slate-500">
<CheckCircle className="w-4 h-4 text-green-500" />
{strategy.confidence}% confidence
</div>
</div>
<Button size="sm" className="bg-[#0A39DF] hover:bg-[#0831b8]" onClick={() => alert(`Implementing: ${strategy.strategy}`)}>
Implement
<ArrowRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Scenario Comparison */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
Scenario Analysis
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{scenarioData.map((scenario, idx) => (
<div
key={idx}
className={`p-6 rounded-xl border-2 text-center ${
idx === 1
? "bg-[#0A39DF] text-white border-[#0A39DF]"
: "bg-white border-slate-200"
}`}
>
<p className={`text-sm font-medium mb-2 ${idx === 1 ? "text-blue-200" : "text-slate-500"}`}>
{scenario.scenario}
</p>
<p className={`text-3xl font-bold ${idx === 1 ? "text-white" : "text-slate-900"}`}>
${scenario.savings.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
<p className={`text-sm mt-1 ${idx === 1 ? "text-blue-200" : "text-slate-500"}`}>
annual savings
</p>
{idx === 1 && (
<Badge className="mt-3 bg-white/20 text-white border-0">
Recommended
</Badge>
)}
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,359 @@
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
DollarSign, TrendingUp, Target, Users, Zap,
ArrowUpRight, CheckCircle, AlertTriangle, Shield,
Clock, Award, Calendar, Package, Star, BarChart3
} from "lucide-react";
export default function SavingsOverviewCards({ metrics, projections, timeRange, userRole }) {
const getProjectedSavings = () => {
switch (timeRange) {
case "7days": return projections.sevenDays;
case "30days": return projections.thirtyDays;
case "quarter": return projections.quarter;
case "year": return projections.year;
default: return projections.thirtyDays;
}
};
const getTimeLabel = () => {
switch (timeRange) {
case "7days": return "7-Day";
case "30days": return "30-Day";
case "quarter": return "Quarterly";
case "year": return "Annual";
default: return "30-Day";
}
};
// Role-specific card configurations
const getRoleCards = () => {
switch (userRole) {
case "procurement":
return [
{
title: "Vendor Performance Score",
value: `${(metrics.avgReliability + 5).toFixed(0)}%`,
change: `Top ${metrics.activeVendors} vendors tracked`,
trend: "up",
icon: Award,
color: "blue",
description: "Network-wide average",
},
{
title: "Contract Compliance",
value: `${metrics.contractedRatio.toFixed(1)}%`,
change: `${(100 - metrics.contractedRatio).toFixed(1)}% non-compliant`,
trend: metrics.contractedRatio > 70 ? "up" : "down",
icon: Shield,
color: metrics.contractedRatio > 70 ? "green" : "red",
description: "Spend under contract",
},
{
title: "Rate Optimization",
value: `$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}`,
change: "per hour savings potential",
trend: "up",
icon: DollarSign,
color: "emerald",
description: "Contract vs. spot rates",
},
{
title: "SLA Adherence",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${metrics.noShowRate.toFixed(1)}% no-show rate`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: CheckCircle,
color: metrics.fillRate > 90 ? "green" : "amber",
description: "Vendor delivery rate",
},
{
title: "Network Savings",
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: `${getTimeLabel()} projection`,
trend: "up",
icon: TrendingUp,
color: "purple",
description: "From vendor optimization",
},
];
case "operator":
return [
{
title: "Enterprise Fill Rate",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${metrics.completedOrders} orders fulfilled`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: Target,
color: metrics.fillRate > 90 ? "green" : "amber",
description: "Cross-sector average",
},
{
title: "Labor Efficiency",
value: `${metrics.avgReliability.toFixed(0)}%`,
change: `${metrics.noShowRate.toFixed(1)}% absence rate`,
trend: metrics.avgReliability > 85 ? "up" : "down",
icon: Users,
color: "blue",
description: "Workforce productivity",
},
{
title: "Cost per Order",
value: `$${(metrics.totalSpend / Math.max(metrics.completedOrders, 1)).toFixed(0)}`,
change: "average fulfillment cost",
trend: "up",
icon: DollarSign,
color: "purple",
description: "Operational efficiency",
},
{
title: "Sector Coverage",
value: `${metrics.activeVendors}`,
change: "active vendor partners",
trend: "up",
icon: Package,
color: "indigo",
description: "Available resources",
},
{
title: "Operational Savings",
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: `${getTimeLabel()} potential`,
trend: "up",
icon: TrendingUp,
color: "emerald",
description: "From efficiency gains",
},
];
case "sector":
return [
{
title: "Location Fill Rate",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${100 - metrics.fillRate > 0 ? (100 - metrics.fillRate).toFixed(1) + '% gaps' : 'No gaps'}`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: Target,
color: metrics.fillRate > 90 ? "green" : "red",
description: "Position coverage",
},
{
title: "Staff Reliability",
value: `${metrics.avgReliability.toFixed(0)}%`,
change: `${metrics.noShowRate.toFixed(1)}% no-shows`,
trend: metrics.avgReliability > 85 ? "up" : "down",
icon: Users,
color: metrics.avgReliability > 85 ? "blue" : "amber",
description: "At your location",
},
{
title: "Weekly Hours",
value: `${Math.floor(metrics.totalWorkforce * 32)}`,
change: "scheduled this period",
trend: "up",
icon: Clock,
color: "purple",
description: "Labor hours planned",
},
{
title: "Local Spend",
value: `$${metrics.totalSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: "labor investment",
trend: "up",
icon: DollarSign,
color: "slate",
description: "Your location budget",
},
];
case "client":
return [
{
title: "Event Coverage",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${metrics.completedOrders} events staffed`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: Calendar,
color: metrics.fillRate > 90 ? "green" : "red",
description: "Position fill rate",
},
{
title: "Staff Quality",
value: `${metrics.avgReliability.toFixed(0)}%`,
change: "reliability score",
trend: metrics.avgReliability > 85 ? "up" : "down",
icon: Star,
color: metrics.avgReliability > 85 ? "amber" : "orange",
description: "Assigned workforce",
},
{
title: "Cost Savings",
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: `${metrics.potentialSavingsPercent.toFixed(0)}% vs. spot rates`,
trend: "up",
icon: DollarSign,
color: "emerald",
description: `${getTimeLabel()} savings`,
},
{
title: "On-Time Rate",
value: `${(100 - metrics.noShowRate).toFixed(1)}%`,
change: "staff attendance",
trend: metrics.noShowRate < 5 ? "up" : "down",
icon: CheckCircle,
color: metrics.noShowRate < 5 ? "green" : "amber",
description: "Punctuality score",
},
];
case "vendor":
return [
{
title: "Your Fill Rate",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${metrics.completedOrders} orders completed`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: Target,
color: metrics.fillRate > 90 ? "green" : "amber",
description: "Order fulfillment",
},
{
title: "Workforce Reliability",
value: `${metrics.avgReliability.toFixed(0)}%`,
change: `${metrics.noShowRate.toFixed(1)}% no-show rate`,
trend: metrics.avgReliability > 85 ? "up" : "down",
icon: Users,
color: metrics.avgReliability > 85 ? "blue" : "orange",
description: "Your team score",
},
{
title: "Competitive Edge",
value: `$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}/hr`,
change: "savings vs. gig rates",
trend: "up",
icon: Zap,
color: "amber",
description: "Your value proposition",
},
{
title: "Revenue Potential",
value: `$${(metrics.totalSpend * 1.2).toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: "if 100% fill rate",
trend: "up",
icon: TrendingUp,
color: "purple",
description: "Growth opportunity",
},
{
title: "Active Workforce",
value: metrics.totalWorkforce.toString(),
change: "ready to deploy",
trend: "up",
icon: Shield,
color: "indigo",
description: "Available staff",
},
];
default: // admin
return [
{
title: `${getTimeLabel()} Potential Savings`,
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: `${metrics.potentialSavingsPercent.toFixed(1)}% opportunity`,
trend: "up",
icon: DollarSign,
color: "emerald",
description: "From contract conversion",
},
{
title: "Contract Coverage",
value: `${metrics.contractedRatio.toFixed(1)}%`,
change: `${(100 - metrics.contractedRatio).toFixed(1)}% non-contracted`,
trend: metrics.contractedRatio > 70 ? "up" : "down",
icon: Target,
color: metrics.contractedRatio > 70 ? "blue" : "amber",
description: "Labor under contract",
},
{
title: "Platform Reliability",
value: `${metrics.avgReliability.toFixed(0)}%`,
change: `${metrics.noShowRate.toFixed(1)}% no-show rate`,
trend: metrics.avgReliability > 85 ? "up" : "down",
icon: Users,
color: metrics.avgReliability > 85 ? "purple" : "orange",
description: "Workforce average",
},
{
title: "Fill Rate",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${metrics.completedOrders} orders completed`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: CheckCircle,
color: metrics.fillRate > 90 ? "green" : "red",
description: "Order fulfillment",
},
{
title: "Network Size",
value: `${metrics.activeVendors} / ${metrics.totalWorkforce}`,
change: "vendors / workforce",
trend: "up",
icon: BarChart3,
color: "indigo",
description: "Platform capacity",
},
];
}
};
const cards = getRoleCards();
const colorClasses = {
emerald: { bg: "bg-emerald-50", icon: "bg-emerald-500", text: "text-emerald-700", badge: "bg-emerald-100 text-emerald-700" },
blue: { bg: "bg-blue-50", icon: "bg-blue-500", text: "text-blue-700", badge: "bg-blue-100 text-blue-700" },
purple: { bg: "bg-purple-50", icon: "bg-purple-500", text: "text-purple-700", badge: "bg-purple-100 text-purple-700" },
green: { bg: "bg-green-50", icon: "bg-green-500", text: "text-green-700", badge: "bg-green-100 text-green-700" },
amber: { bg: "bg-amber-50", icon: "bg-amber-500", text: "text-amber-700", badge: "bg-amber-100 text-amber-700" },
orange: { bg: "bg-orange-50", icon: "bg-orange-500", text: "text-orange-700", badge: "bg-orange-100 text-orange-700" },
red: { bg: "bg-red-50", icon: "bg-red-500", text: "text-red-700", badge: "bg-red-100 text-red-700" },
indigo: { bg: "bg-indigo-50", icon: "bg-indigo-500", text: "text-indigo-700", badge: "bg-indigo-100 text-indigo-700" },
slate: { bg: "bg-slate-50", icon: "bg-slate-500", text: "text-slate-700", badge: "bg-slate-100 text-slate-700" },
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{cards.map((card, index) => {
const colors = colorClasses[card.color];
const Icon = card.icon;
return (
<Card key={index} className={`${colors.bg} border-0 shadow-sm hover:shadow-md transition-all`}>
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className={`w-12 h-12 ${colors.icon} rounded-xl flex items-center justify-center`}>
<Icon className="w-6 h-6 text-white" />
</div>
<Badge className={`${colors.badge} border-0 text-xs font-medium`}>
{card.trend === "up" ? (
<ArrowUpRight className="w-3 h-3 mr-1" />
) : (
<AlertTriangle className="w-3 h-3 mr-1" />
)}
{card.change}
</Badge>
</div>
<p className={`text-xs ${colors.text} uppercase tracking-wider font-semibold mb-1`}>
{card.title}
</p>
<p className={`text-3xl font-bold ${colors.text}`}>{card.value}</p>
<p className="text-xs text-slate-500 mt-1">{card.description}</p>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,299 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Package, Star, TrendingUp, TrendingDown, DollarSign,
Users, CheckCircle, AlertTriangle, Award, Shield, Zap
} from "lucide-react";
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ZAxis, Cell } from "recharts";
export default function VendorPerformanceMatrix({ vendors, assignments, rates, metrics, userRole }) {
// Generate vendor performance data
const vendorPerformance = vendors.slice(0, 8).map((vendor, idx) => ({
id: vendor.id,
name: vendor.legal_name || vendor.doing_business_as || `Vendor ${idx + 1}`,
region: vendor.region || "Bay Area",
avgRate: metrics.avgContractedRate - (Math.random() * 10 - 5),
reliability: 80 + Math.random() * 18,
fillRate: 85 + Math.random() * 14,
noShowRate: Math.random() * 5,
onTimeRate: 88 + Math.random() * 11,
workforce: vendor.workforce_count || Math.floor(50 + Math.random() * 150),
spend: Math.floor(10000 + Math.random() * 50000),
score: Math.floor(70 + Math.random() * 28),
tier: idx < 2 ? "Preferred" : idx < 5 ? "Approved" : "Standard",
savingsPotential: Math.floor(1000 + Math.random() * 5000),
}));
// Scatter plot data
const scatterData = vendorPerformance.map(v => ({
x: v.avgRate,
y: v.reliability,
z: v.spend / 1000,
name: v.name,
tier: v.tier,
}));
const tierColors = {
Preferred: "#22c55e",
Approved: "#0A39DF",
Standard: "#f59e0b",
};
const tierBadgeColors = {
Preferred: "bg-green-100 text-green-700",
Approved: "bg-blue-100 text-blue-700",
Standard: "bg-amber-100 text-amber-700",
};
const slaMetrics = [
{ metric: "Response Time", target: "< 2 hours", achieved: "1.5 hours", status: "met" },
{ metric: "Fill Rate", target: "> 95%", achieved: `${metrics.fillRate.toFixed(1)}%`, status: metrics.fillRate > 95 ? "met" : "at-risk" },
{ metric: "No-Show Rate", target: "< 3%", achieved: `${metrics.noShowRate.toFixed(1)}%`, status: metrics.noShowRate < 3 ? "met" : "at-risk" },
{ metric: "On-Time Arrival", target: "> 98%", achieved: "97.2%", status: "at-risk" },
];
return (
<div className="space-y-6">
{/* Vendor Scorecard Summary */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-green-50 border-green-200">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center">
<Star className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-green-600 font-medium">Preferred Vendors</p>
<p className="text-2xl font-bold text-green-900">{vendorPerformance.filter(v => v.tier === "Preferred").length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-blue-50 border-blue-200">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center">
<Shield className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-blue-600 font-medium">Approved Vendors</p>
<p className="text-2xl font-bold text-blue-900">{vendorPerformance.filter(v => v.tier === "Approved").length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-amber-50 border-amber-200">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center">
<Package className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-amber-600 font-medium">Standard Vendors</p>
<p className="text-2xl font-bold text-amber-900">{vendorPerformance.filter(v => v.tier === "Standard").length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-purple-50 border-purple-200">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-500 rounded-xl flex items-center justify-center">
<DollarSign className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-purple-600 font-medium">Total Savings Potential</p>
<p className="text-2xl font-bold text-purple-900">
${vendorPerformance.reduce((sum, v) => sum + v.savingsPotential, 0).toLocaleString()}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Rate vs Reliability Scatter */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-blue-500" />
Vendor Performance Matrix (Rate vs. Reliability)
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
type="number"
dataKey="x"
name="Rate"
unit="$/hr"
stroke="#64748b"
fontSize={12}
label={{ value: 'Hourly Rate ($)', position: 'bottom', offset: 0 }}
/>
<YAxis
type="number"
dataKey="y"
name="Reliability"
unit="%"
stroke="#64748b"
fontSize={12}
domain={[70, 100]}
label={{ value: 'Reliability (%)', angle: -90, position: 'left' }}
/>
<ZAxis type="number" dataKey="z" range={[100, 500]} />
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
content={({ payload }) => {
if (payload && payload[0]) {
const data = payload[0].payload;
return (
<div className="bg-white p-3 border border-slate-200 rounded-lg shadow-lg">
<p className="font-bold text-slate-900">{data.name}</p>
<p className="text-sm text-slate-600">Rate: ${data.x.toFixed(2)}/hr</p>
<p className="text-sm text-slate-600">Reliability: {data.y.toFixed(1)}%</p>
<Badge className={tierBadgeColors[data.tier]}>{data.tier}</Badge>
</div>
);
}
return null;
}}
/>
<Scatter data={scatterData}>
{scatterData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={tierColors[entry.tier]} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-6 mt-4">
{Object.entries(tierColors).map(([tier, color]) => (
<div key={tier} className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: color }} />
<span className="text-sm text-slate-600">{tier}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* Vendor Table */}
<Card>
<CardHeader>
<CardTitle>Vendor Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead>Vendor</TableHead>
<TableHead>Tier</TableHead>
<TableHead className="text-right">Avg Rate</TableHead>
<TableHead className="text-right">Reliability</TableHead>
<TableHead className="text-right">Fill Rate</TableHead>
<TableHead className="text-right">No-Show</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead className="text-right">Savings Potential</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vendorPerformance.map((vendor) => (
<TableRow key={vendor.id} className="hover:bg-slate-50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#0A39DF] rounded-lg flex items-center justify-center">
<Package className="w-4 h-4 text-white" />
</div>
<div>
<p className="font-medium text-slate-900">{vendor.name}</p>
<p className="text-xs text-slate-500">{vendor.region}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge className={tierBadgeColors[vendor.tier]}>{vendor.tier}</Badge>
</TableCell>
<TableCell className="text-right font-medium">${vendor.avgRate.toFixed(2)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Progress value={vendor.reliability} className="w-12 h-2" />
<span className="text-sm">{vendor.reliability.toFixed(0)}%</span>
</div>
</TableCell>
<TableCell className="text-right">{vendor.fillRate.toFixed(0)}%</TableCell>
<TableCell className="text-right">
<span className={vendor.noShowRate < 3 ? "text-green-600" : "text-red-600"}>
{vendor.noShowRate.toFixed(1)}%
</span>
</TableCell>
<TableCell className="text-right">
<Badge className={vendor.score >= 90 ? "bg-green-100 text-green-700" : vendor.score >= 75 ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"}>
{vendor.score}
</Badge>
</TableCell>
<TableCell className="text-right font-semibold text-green-600">
${vendor.savingsPotential.toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* SLA Tracking */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Award className="w-5 h-5 text-amber-500" />
Service Level Agreement (SLA) Tracking
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{slaMetrics.map((sla, idx) => (
<div key={idx} className={`p-4 rounded-xl border-2 ${sla.status === "met" ? "bg-green-50 border-green-200" : "bg-amber-50 border-amber-200"}`}>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-slate-700">{sla.metric}</span>
{sla.status === "met" ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<AlertTriangle className="w-5 h-5 text-amber-500" />
)}
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Target</span>
<span className="font-medium">{sla.target}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Achieved</span>
<span className={`font-bold ${sla.status === "met" ? "text-green-600" : "text-amber-600"}`}>
{sla.achieved}
</span>
</div>
</div>
<Badge className={`mt-2 ${sla.status === "met" ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"}`}>
{sla.status === "met" ? "SLA Met" : "At Risk"}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React from "react"
import { krowSDK } from "@/api/krowSDK";
import { base44 } from "@/api/base44Client";
const TOAST_LIMIT = 5
const TOAST_REMOVE_DELAY = 1000000
@@ -95,12 +95,7 @@ function dispatch(action) {
// Helper function to create notification in ActivityLog instead of toast
async function createNotification(title, description, variant) {
try {
const user = await krowSDK.auth.me();
if (!user) {
console.warn("Cannot create notification: user not authenticated.");
return;
}
const user = await base44.auth.me();
// Determine icon and color based on variant and title
let icon_type = "check";
@@ -129,7 +124,7 @@ async function createNotification(title, description, variant) {
activity_type = "staff_assigned";
}
const payload = {
await base44.entities.ActivityLog.create({
title: title.replace(/✅|❌|⚠️/g, '').trim(),
description: description || "",
activity_type: activity_type,
@@ -137,10 +132,7 @@ async function createNotification(title, description, variant) {
is_read: false,
icon_type: icon_type,
icon_color: icon_color,
};
await krowSDK.entities.ActivityLog.create({ data: payload });
});
} catch (error) {
console.error("Failed to create notification:", error);
}

View File

@@ -0,0 +1,194 @@
import React from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Progress } from "@/components/ui/progress";
import {
Heart, HeartOff, Shield, AlertTriangle, TrendingUp,
Users, Lock, Unlock, Star, DollarSign, ArrowRight
} from "lucide-react";
export default function ClientLoyaltyCard({ vendorId, vendorName }) {
// Fetch all users to check who has this vendor locked
const { data: users = [] } = useQuery({
queryKey: ['users-loyalty', vendorId],
queryFn: async () => {
const allUsers = await base44.entities.User.list();
return allUsers.filter(u =>
u.user_role === 'client' || u.role === 'client'
);
},
initialData: [],
});
// Fetch businesses/clients that have worked with this vendor
const { data: events = [] } = useQuery({
queryKey: ['vendor-events-loyalty', vendorId],
queryFn: async () => {
const allEvents = await base44.entities.Event.list();
return allEvents.filter(e =>
e.vendor_name === vendorName || e.vendor_id === vendorId
);
},
initialData: [],
});
// Get unique clients from events
const uniqueClients = [...new Set(events.map(e => e.business_name).filter(Boolean))];
// Categorize clients
const loyalClients = users.filter(u =>
u.locked_vendor_ids?.includes(vendorId) ||
u.preferred_vendor_id === vendorId
);
const atRiskClients = users.filter(u =>
u.allow_procurement_optimization !== false &&
!u.locked_vendor_ids?.includes(vendorId) &&
u.preferred_vendor_id !== vendorId &&
uniqueClients.some(c =>
u.company_name === c || u.full_name?.includes(c?.split(' ')[0])
)
);
const loyalCount = loyalClients.length;
const atRiskCount = atRiskClients.length;
const totalClients = uniqueClients.length || 1;
const loyaltyScore = totalClients > 0 ? Math.round((loyalCount / totalClients) * 100) : 0;
return (
<Card className="bg-white border-slate-200 shadow-sm">
<CardHeader className="pb-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Heart className="w-4 h-4 text-pink-600" />
Client Loyalty
</CardTitle>
<Badge className={`${loyaltyScore >= 70 ? 'bg-green-100 text-green-700' : loyaltyScore >= 40 ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'}`}>
{loyaltyScore}% Retention
</Badge>
</div>
</CardHeader>
<CardContent className="p-4 space-y-4">
{/* Loyalty Score Bar */}
<div>
<div className="flex items-center justify-between text-xs mb-2">
<span className="text-slate-500">Client Retention Score</span>
<span className="font-bold text-slate-700">{loyalCount}/{totalClients} locked</span>
</div>
<Progress
value={loyaltyScore}
className="h-2"
/>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center gap-2 mb-1">
<Lock className="w-4 h-4 text-green-600" />
<span className="text-xs font-medium text-green-700">Loyal</span>
</div>
<p className="text-2xl font-bold text-green-700">{loyalCount}</p>
<p className="text-xs text-green-600">Won't switch vendors</p>
</div>
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200">
<div className="flex items-center gap-2 mb-1">
<AlertTriangle className="w-4 h-4 text-amber-600" />
<span className="text-xs font-medium text-amber-700">At Risk</span>
</div>
<p className="text-2xl font-bold text-amber-700">{atRiskCount}</p>
<p className="text-xs text-amber-600">Open to optimization</p>
</div>
</div>
{/* Loyal Clients List */}
{loyalClients.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-600 mb-2 flex items-center gap-1">
<Shield className="w-3 h-3 text-green-600" />
Protected Relationships
</p>
<div className="space-y-2">
{loyalClients.slice(0, 3).map((client, idx) => (
<div
key={client.id || idx}
className="flex items-center gap-2 p-2 bg-green-50/50 rounded-lg"
>
<Avatar className="w-7 h-7">
<AvatarFallback className="bg-green-200 text-green-800 text-xs font-bold">
{client.full_name?.charAt(0) || client.company_name?.charAt(0) || 'C'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-slate-900 truncate">
{client.company_name || client.full_name}
</p>
</div>
<Badge className="bg-green-100 text-green-700 text-[10px]">
<Lock className="w-2.5 h-2.5 mr-0.5" />
Locked
</Badge>
</div>
))}
</div>
</div>
)}
{/* At Risk Clients */}
{atRiskClients.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-600 mb-2 flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-amber-600" />
Needs Attention
</p>
<div className="space-y-2">
{atRiskClients.slice(0, 3).map((client, idx) => (
<div
key={client.id || idx}
className="flex items-center gap-2 p-2 bg-amber-50/50 rounded-lg"
>
<Avatar className="w-7 h-7">
<AvatarFallback className="bg-amber-200 text-amber-800 text-xs font-bold">
{client.full_name?.charAt(0) || client.company_name?.charAt(0) || 'C'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-slate-900 truncate">
{client.company_name || client.full_name}
</p>
</div>
<Badge className="bg-amber-100 text-amber-700 text-[10px]">
<Unlock className="w-2.5 h-2.5 mr-0.5" />
At Risk
</Badge>
</div>
))}
</div>
</div>
)}
{/* Tips */}
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-xs font-medium text-blue-800 mb-1">💡 Retention Tips</p>
<ul className="text-xs text-blue-700 space-y-1">
<li> Maintain high fill rates to keep clients happy</li>
<li> Respond quickly to urgent requests</li>
<li> Offer competitive rates to at-risk clients</li>
</ul>
</div>
{/* No Data State */}
{totalClients === 0 && (
<div className="text-center py-4">
<Users className="w-8 h-8 mx-auto mb-2 text-slate-300" />
<p className="text-xs text-slate-500">No client data yet</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,386 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import {
Lock, Unlock, Shield, DollarSign, TrendingUp, AlertTriangle,
CheckCircle, X, Star, Users, Zap, ArrowRight, Heart, HeartOff,
Settings, Info, Sparkles
} from "lucide-react";
export default function ClientVendorPreferences({ user, vendors, onUpdate }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [savingsModal, setSavingsModal] = useState({ open: false, vendor: null, savings: 0 });
// Get user's locked vendors (vendors they don't want changed)
const lockedVendorIds = user?.locked_vendor_ids || [];
const allowOptimization = user?.allow_procurement_optimization !== false; // Default true
// Calculate potential savings for each non-preferred vendor
const vendorsWithSavings = vendors.map(vendor => {
const currentRate = vendor.avgRate || 50;
const preferredRate = 42; // Tier 1 rate
const savingsPercent = currentRate > preferredRate ? ((currentRate - preferredRate) / currentRate * 100) : 0;
const monthlySavings = savingsPercent > 0 ? (currentRate - preferredRate) * 40 * 4 : 0; // 40hrs/week * 4 weeks
return {
...vendor,
potentialSavingsPercent: savingsPercent,
potentialMonthlySavings: monthlySavings,
isLocked: lockedVendorIds.includes(vendor.id),
};
});
const updatePreferencesMutation = useMutation({
mutationFn: (data) => base44.auth.updateMe(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user-marketplace'] });
onUpdate?.();
},
});
const toggleVendorLock = (vendorId, vendorName) => {
const newLockedIds = lockedVendorIds.includes(vendorId)
? lockedVendorIds.filter(id => id !== vendorId)
: [...lockedVendorIds, vendorId];
updatePreferencesMutation.mutate({ locked_vendor_ids: newLockedIds });
toast({
title: lockedVendorIds.includes(vendorId) ? "Vendor Unlocked" : "Vendor Locked",
description: lockedVendorIds.includes(vendorId)
? `${vendorName} can now be optimized by Procurement`
: `${vendorName} will not be changed without your approval`,
});
};
const toggleOptimization = (allowed) => {
updatePreferencesMutation.mutate({ allow_procurement_optimization: allowed });
toast({
title: allowed ? "Optimization Enabled" : "Optimization Disabled",
description: allowed
? "Procurement can suggest vendor optimizations for cost savings"
: "Your vendor relationships will remain unchanged",
});
};
const handleViewSavings = (vendor) => {
setSavingsModal({
open: true,
vendor,
savings: vendor.potentialMonthlySavings,
});
};
const handleAcceptSavings = () => {
// Remove from locked list if locked
if (savingsModal.vendor?.isLocked) {
const newLockedIds = lockedVendorIds.filter(id => id !== savingsModal.vendor.id);
updatePreferencesMutation.mutate({ locked_vendor_ids: newLockedIds });
}
toast({
title: "Optimization Request Submitted",
description: `We'll find a better rate for your ${savingsModal.vendor?.legal_name} positions`,
});
setSavingsModal({ open: false, vendor: null, savings: 0 });
};
const totalPotentialSavings = vendorsWithSavings
.filter(v => !v.isLocked && v.potentialMonthlySavings > 0)
.reduce((sum, v) => sum + v.potentialMonthlySavings, 0);
const lockedVendors = vendorsWithSavings.filter(v => v.isLocked);
const optimizableVendors = vendorsWithSavings.filter(v => !v.isLocked && v.potentialSavingsPercent > 5);
return (
<div className="space-y-4">
{/* Main Control Card */}
<Card className="border-2 border-slate-200 bg-gradient-to-br from-white to-slate-50">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#0A39DF] rounded-xl flex items-center justify-center">
<Settings className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg">Vendor Preferences</CardTitle>
<p className="text-sm text-slate-500">Control how your vendors are managed</p>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Optimization Toggle */}
<div className="flex items-center justify-between p-4 bg-white rounded-xl border border-slate-200">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
allowOptimization ? 'bg-emerald-100' : 'bg-slate-100'
}`}>
{allowOptimization ? (
<Sparkles className="w-5 h-5 text-emerald-600" />
) : (
<Lock className="w-5 h-5 text-slate-500" />
)}
</div>
<div>
<p className="font-semibold text-slate-900">Allow Procurement Optimization</p>
<p className="text-sm text-slate-500">
{allowOptimization
? "Procurement can suggest cost-saving vendor changes"
: "Your vendor relationships remain unchanged"
}
</p>
</div>
</div>
<Switch
checked={allowOptimization}
onCheckedChange={toggleOptimization}
/>
</div>
{/* Potential Savings Banner */}
{allowOptimization && totalPotentialSavings > 0 && (
<div className="p-4 bg-gradient-to-r from-emerald-50 to-green-50 rounded-xl border border-emerald-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-emerald-500 rounded-xl flex items-center justify-center">
<DollarSign className="w-6 h-6 text-white" />
</div>
<div>
<p className="font-bold text-emerald-900">Potential Monthly Savings</p>
<p className="text-sm text-emerald-700">
{optimizableVendors.length} vendor(s) eligible for optimization
</p>
</div>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-emerald-600">
${totalPotentialSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
<p className="text-xs text-emerald-600">per month</p>
</div>
</div>
</div>
)}
{/* Locked Vendors */}
{lockedVendors.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-amber-600" />
<p className="font-semibold text-slate-700 text-sm">
Locked Vendors ({lockedVendors.length})
</p>
<Badge className="bg-amber-100 text-amber-700 text-xs">Protected</Badge>
</div>
<div className="space-y-2">
{lockedVendors.map(vendor => (
<div
key={vendor.id}
className="flex items-center justify-between p-3 bg-amber-50 rounded-lg border border-amber-200"
>
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-amber-200 text-amber-800 font-bold">
{vendor.legal_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-slate-900">{vendor.legal_name}</p>
<p className="text-xs text-slate-500">${vendor.avgRate?.toFixed(0)}/hr avg</p>
</div>
</div>
<div className="flex items-center gap-2">
{vendor.potentialMonthlySavings > 0 && (
<Badge className="bg-emerald-100 text-emerald-700 text-xs">
Could save ${vendor.potentialMonthlySavings.toFixed(0)}/mo
</Badge>
)}
<Button
size="sm"
variant="ghost"
onClick={() => toggleVendorLock(vendor.id, vendor.legal_name)}
className="text-amber-700 hover:text-amber-900 hover:bg-amber-100"
>
<Unlock className="w-4 h-4 mr-1" />
Unlock
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* Optimization Opportunities */}
{allowOptimization && optimizableVendors.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-4 h-4 text-emerald-600" />
<p className="font-semibold text-slate-700 text-sm">
Savings Opportunities ({optimizableVendors.length})
</p>
</div>
<div className="space-y-2">
{optimizableVendors.slice(0, 3).map(vendor => (
<div
key={vendor.id}
className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200 hover:border-emerald-300 hover:bg-emerald-50/50 transition-all"
>
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-slate-100 text-slate-700 font-bold">
{vendor.legal_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-slate-900">{vendor.legal_name}</p>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">${vendor.avgRate?.toFixed(0)}/hr</span>
<ArrowRight className="w-3 h-3 text-emerald-500" />
<span className="text-xs text-emerald-600 font-medium">$42/hr</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-right mr-2">
<p className="font-bold text-emerald-600">
Save ${vendor.potentialMonthlySavings.toFixed(0)}
</p>
<p className="text-xs text-slate-500">per month</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => toggleVendorLock(vendor.id, vendor.legal_name)}
className="border-amber-300 text-amber-700 hover:bg-amber-50"
>
<Lock className="w-3 h-3 mr-1" />
Keep
</Button>
<Button
size="sm"
onClick={() => handleViewSavings(vendor)}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
<Zap className="w-3 h-3 mr-1" />
Optimize
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* Info Box */}
<div className="flex items-start gap-3 p-3 bg-blue-50 rounded-lg border border-blue-200">
<Info className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium">How this works:</p>
<ul className="mt-1 space-y-1 text-blue-700">
<li> <strong>Locked vendors</strong> won't be changed without your approval</li>
<li> <strong>Optimization</strong> moves orders to preferred vendors for better rates</li>
<li> You can lock/unlock vendors anytime</li>
</ul>
</div>
</div>
</CardContent>
</Card>
{/* Savings Confirmation Modal */}
<Dialog open={savingsModal.open} onOpenChange={(open) => setSavingsModal({ ...savingsModal, open })}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-600" />
Switch to Preferred Vendor?
</DialogTitle>
<DialogDescription>
Optimize your staffing costs while maintaining quality
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Current vs New */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-amber-50 rounded-xl border border-amber-200 text-center">
<p className="text-xs text-amber-700 font-medium mb-1">CURRENT</p>
<p className="font-bold text-slate-900">{savingsModal.vendor?.legal_name}</p>
<p className="text-2xl font-bold text-amber-600 mt-2">
${savingsModal.vendor?.avgRate?.toFixed(0)}/hr
</p>
</div>
<div className="p-4 bg-emerald-50 rounded-xl border border-emerald-200 text-center">
<p className="text-xs text-emerald-700 font-medium mb-1">PREFERRED</p>
<p className="font-bold text-slate-900">Tier 1 Vendor</p>
<p className="text-2xl font-bold text-emerald-600 mt-2">$42/hr</p>
</div>
</div>
{/* Savings Highlight */}
<div className="p-4 bg-gradient-to-r from-emerald-500 to-green-500 rounded-xl text-white text-center">
<p className="text-sm opacity-90 mb-1">Your Monthly Savings</p>
<p className="text-4xl font-bold">
${savingsModal.savings.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
<p className="text-sm opacity-90 mt-1">Same quality staff Better rates</p>
</div>
{/* Benefits */}
<div className="space-y-2">
{[
"Same quality staff from verified vendors",
"Priority support and faster response times",
"Dedicated account manager",
].map((benefit, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm text-slate-700">
<CheckCircle className="w-4 h-4 text-emerald-500" />
{benefit}
</div>
))}
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => {
toggleVendorLock(savingsModal.vendor?.id, savingsModal.vendor?.legal_name);
setSavingsModal({ open: false, vendor: null, savings: 0 });
}}
className="border-amber-300 text-amber-700 hover:bg-amber-50"
>
<Lock className="w-4 h-4 mr-2" />
Keep Current Vendor
</Button>
<Button
onClick={handleAcceptSavings}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Zap className="w-4 h-4 mr-2" />
Switch & Save ${savingsModal.savings.toFixed(0)}/mo
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,408 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Sparkles, Target, TrendingUp, DollarSign, Users, Clock,
Shield, Zap, Brain, CheckCircle, ArrowRight, BarChart3,
Building2, MapPin, Calendar, Lightbulb, AlertTriangle
} from "lucide-react";
const STRATEGY_CATEGORIES = [
{ id: "cost", label: "Cost Optimization", icon: DollarSign, color: "green" },
{ id: "efficiency", label: "Operational Efficiency", icon: Zap, color: "blue" },
{ id: "quality", label: "Quality & Compliance", icon: Shield, color: "purple" },
{ id: "growth", label: "Revenue Growth", icon: TrendingUp, color: "amber" }
];
const SMART_STRATEGIES = {
cost: [
{
id: "vendor_consolidation",
title: "Vendor Consolidation Program",
description: "Reduce vendor count by 40% while maintaining service quality through strategic consolidation",
impact: "15-25% cost reduction",
timeframe: "90 days",
roi: "$45,000/year",
steps: [
"Analyze current vendor performance scores",
"Identify overlap in service coverage",
"Negotiate volume discounts with top performers",
"Transition low-performers to preferred vendors"
],
metrics: ["Vendor count reduction", "Rate savings %", "Fill rate impact"]
},
{
id: "rate_optimization",
title: "Dynamic Rate Optimization",
description: "AI-powered rate adjustments based on demand, seasonality, and market conditions",
impact: "8-12% savings on labor costs",
timeframe: "30 days",
roi: "$28,000/year",
steps: [
"Enable real-time market rate monitoring",
"Set rate caps based on role and region",
"Auto-negotiate when rates exceed thresholds",
"Lock in favorable rates for recurring positions"
],
metrics: ["Average rate reduction", "Market position", "Compliance rate"]
},
{
id: "overtime_reduction",
title: "Smart Overtime Management",
description: "Predictive scheduling to minimize costly overtime while maintaining coverage",
impact: "30-40% OT reduction",
timeframe: "45 days",
roi: "$18,000/year",
steps: [
"Analyze historical overtime patterns",
"Implement 8-hour shift maximums",
"Cross-train workers for flexibility",
"Use AI for shift optimization"
],
metrics: ["OT hours/week", "Cost per shift", "Worker satisfaction"]
}
],
efficiency: [
{
id: "auto_assignment",
title: "Automated Assignment Engine",
description: "AI matches workers to shifts based on skills, location, availability, and performance",
impact: "85% faster assignment",
timeframe: "14 days",
roi: "$22,000/year",
steps: [
"Enable smart matching algorithm",
"Define assignment rules and priorities",
"Set up worker preference profiles",
"Activate real-time notifications"
],
metrics: ["Time to fill", "Assignment accuracy", "Worker acceptance rate"]
},
{
id: "predictive_scheduling",
title: "Predictive Demand Scheduling",
description: "Forecast staffing needs 2-4 weeks ahead using historical data and event calendars",
impact: "95% schedule accuracy",
timeframe: "21 days",
roi: "$15,000/year",
steps: [
"Integrate historical demand data",
"Connect event and holiday calendars",
"Train prediction models",
"Automate schedule generation"
],
metrics: ["Forecast accuracy", "Understaffing incidents", "Overstaffing costs"]
},
{
id: "digital_timesheet",
title: "Digital Timesheet Automation",
description: "Eliminate manual time tracking with GPS-verified clock-in/out and auto-approval",
impact: "90% admin time saved",
timeframe: "7 days",
roi: "$12,000/year",
steps: [
"Deploy mobile clock-in app",
"Configure geofencing rules",
"Set up auto-approval thresholds",
"Enable real-time dashboard"
],
metrics: ["Timesheet accuracy", "Processing time", "Dispute rate"]
}
],
quality: [
{
id: "compliance_monitoring",
title: "Real-Time Compliance Dashboard",
description: "Continuous monitoring of certifications, background checks, and regulatory requirements",
impact: "100% compliance rate",
timeframe: "14 days",
roi: "Risk mitigation",
steps: [
"Integrate all compliance data sources",
"Set up expiration alerts (30/60/90 days)",
"Automate renewal reminders",
"Generate audit-ready reports"
],
metrics: ["Compliance score", "Expired certifications", "Audit pass rate"]
},
{
id: "performance_scoring",
title: "Worker Performance Index",
description: "Data-driven scoring system for reliability, quality, and client satisfaction",
impact: "25% quality improvement",
timeframe: "30 days",
roi: "$8,000/year",
steps: [
"Define performance metrics",
"Collect feedback from all parties",
"Calculate composite scores",
"Reward top performers"
],
metrics: ["Average rating", "Repeat request rate", "Client satisfaction"]
},
{
id: "incident_reduction",
title: "Proactive Incident Prevention",
description: "AI identifies high-risk assignments before issues occur",
impact: "60% fewer incidents",
timeframe: "45 days",
roi: "Risk mitigation",
steps: [
"Analyze historical incident data",
"Identify risk patterns",
"Flag high-risk assignments",
"Implement preventive protocols"
],
metrics: ["Incident rate", "Response time", "Resolution rate"]
}
],
growth: [
{
id: "market_expansion",
title: "Strategic Market Expansion",
description: "Data-driven identification of high-opportunity markets for vendor growth",
impact: "+35% addressable market",
timeframe: "60 days",
roi: "$65,000/year",
steps: [
"Analyze demand gaps by region",
"Identify underserved sectors",
"Recruit targeted vendor partners",
"Launch market-specific campaigns"
],
metrics: ["New markets entered", "Revenue per market", "Market share"]
},
{
id: "client_retention",
title: "Client Loyalty Program",
description: "Tiered benefits and exclusive rates for high-volume clients",
impact: "40% higher retention",
timeframe: "30 days",
roi: "$42,000/year",
steps: [
"Segment clients by value",
"Design tier benefits",
"Implement loyalty tracking",
"Launch exclusive offerings"
],
metrics: ["Client retention rate", "CLV increase", "Referral rate"]
},
{
id: "upsell_automation",
title: "Smart Upsell Engine",
description: "AI identifies opportunities to expand services with existing clients",
impact: "+18% revenue per client",
timeframe: "21 days",
roi: "$28,000/year",
steps: [
"Analyze client usage patterns",
"Identify cross-sell opportunities",
"Automate personalized offers",
"Track conversion rates"
],
metrics: ["Upsell rate", "Average order value", "Service expansion"]
}
]
};
export default function SmartOperationStrategies({ userRole = 'vendor', onSelectStrategy }) {
const [selectedCategory, setSelectedCategory] = useState("cost");
const [expandedStrategy, setExpandedStrategy] = useState(null);
const strategies = SMART_STRATEGIES[selectedCategory] || [];
// Calculate total potential savings
const totalPotentialSavings = Object.values(SMART_STRATEGIES)
.flat()
.reduce((sum, strategy) => {
const roiMatch = strategy.roi.match(/\$(\d+,?\d*)/);
if (roiMatch) {
return sum + parseInt(roiMatch[1].replace(',', ''));
}
return sum;
}, 0);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center">
<Brain className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="text-xl font-bold text-slate-900">Smart Operation Strategies</h2>
<p className="text-sm text-slate-500">AI-powered recommendations to optimize your operations</p>
</div>
</div>
<Badge className="bg-green-100 text-green-700 text-sm px-3 py-1">
${totalPotentialSavings.toLocaleString()}/year potential
</Badge>
</div>
{/* Impact Summary */}
<Card className="bg-gradient-to-r from-emerald-50 to-blue-50 border-2 border-emerald-200">
<CardContent className="p-4">
<div className="grid grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-emerald-700">15-25%</p>
<p className="text-xs text-emerald-600">Cost Reduction</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-blue-700">85%</p>
<p className="text-xs text-blue-600">Faster Operations</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-purple-700">100%</p>
<p className="text-xs text-purple-600">Compliance Rate</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-amber-700">+35%</p>
<p className="text-xs text-amber-600">Revenue Growth</p>
</div>
</div>
</CardContent>
</Card>
{/* Category Tabs */}
<div className="flex gap-2 flex-wrap">
{STRATEGY_CATEGORIES.map(cat => {
const Icon = cat.icon;
const isActive = selectedCategory === cat.id;
return (
<Button
key={cat.id}
variant={isActive ? "default" : "outline"}
onClick={() => setSelectedCategory(cat.id)}
className={isActive ? "bg-[#0A39DF]" : ""}
>
<Icon className="w-4 h-4 mr-2" />
{cat.label}
</Button>
);
})}
</div>
{/* Strategy Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{strategies.map((strategy) => {
const isExpanded = expandedStrategy === strategy.id;
return (
<Card
key={strategy.id}
className={`cursor-pointer transition-all hover:shadow-lg ${isExpanded ? 'md:col-span-3 border-2 border-[#0A39DF]' : ''}`}
onClick={() => setExpandedStrategy(isExpanded ? null : strategy.id)}
>
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[#0A39DF]" />
<Badge className="bg-green-100 text-green-700 text-[10px]">{strategy.impact}</Badge>
</div>
<Badge variant="outline" className="text-[10px]">{strategy.timeframe}</Badge>
</div>
<h3 className="font-bold text-slate-900 mb-2">{strategy.title}</h3>
<p className="text-sm text-slate-600 mb-3">{strategy.description}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-sm">
<DollarSign className="w-4 h-4 text-green-600" />
<span className="font-semibold text-green-700">{strategy.roi}</span>
</div>
<Button size="sm" variant="ghost" className="text-[#0A39DF]">
{isExpanded ? 'Collapse' : 'Details'} <ArrowRight className="w-3 h-3 ml-1" />
</Button>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="mt-4 pt-4 border-t space-y-4">
<div>
<h4 className="font-semibold text-sm mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
Implementation Steps
</h4>
<div className="space-y-2">
{strategy.steps.map((step, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="w-5 h-5 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold flex-shrink-0">
{idx + 1}
</div>
<span className="text-sm text-slate-600">{step}</span>
</div>
))}
</div>
</div>
<div>
<h4 className="font-semibold text-sm mb-2 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-purple-600" />
Key Metrics to Track
</h4>
<div className="flex gap-2 flex-wrap">
{strategy.metrics.map((metric, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{metric}
</Badge>
))}
</div>
</div>
<div className="flex gap-3">
<Button
className="flex-1 bg-[#0A39DF] hover:bg-[#0831b8]"
onClick={(e) => {
e.stopPropagation();
onSelectStrategy?.(strategy);
}}
>
<CheckCircle className="w-4 h-4 mr-2" />
Implement Strategy
</Button>
<Button
variant="outline"
onClick={(e) => {
e.stopPropagation();
window.location.href = '/Support';
}}
>
Schedule Demo
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{/* Quick Win Section */}
<Card className="border-l-4 border-l-amber-500 bg-amber-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Lightbulb className="w-5 h-5 text-amber-600 mt-0.5" />
<div className="flex-1">
<p className="font-semibold text-amber-900">Quick Win: Start with Rate Optimization</p>
<p className="text-sm text-amber-700 mt-1">
Enable dynamic rate optimization today no implementation required.
AI will start identifying savings opportunities within 24 hours with an average
8-12% reduction in labor costs.
</p>
</div>
<Button
size="sm"
className="bg-amber-600 hover:bg-amber-700"
onClick={() => window.location.href = '/VendorRates'}
>
Enable Now <Zap className="w-3 h-3 ml-1" />
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,90 +1,26 @@
import React from "react";
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { krowSDK } from "@/api/krowSDK";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { ArrowLeft } from "lucide-react";
import StaffForm from "@/components/staff/StaffForm";
export default function AddStaff() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const createStaffMutation = useMutation({
mutationFn: (staffPayload) => krowSDK.entities.Staff.create({ data: staffPayload }),
mutationFn: (staffData) => base44.entities.Staff.create(staffData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
toast({
title: "✅ Staff Member Added",
description: "The new staff member has been successfully created.",
});
navigate(createPageUrl("StaffDirectory"));
},
onError: (error) => {
toast({
title: "❌ Error Creating Staff",
description: error.message || "An unknown error occurred.",
variant: "destructive",
});
navigate(createPageUrl("Dashboard"));
},
});
const handleSubmit = (formData) => {
// 1. Map snake_case from form to camelCase for GraphQL
// 2. Transform enum values to uppercase
// 3. Add required fields not in the form
// 4. Filter out fields not in the mutation
const employmentTypeMap = {
"Full Time": "FULL_TIME",
"Part Time": "PART_TIME",
"On call": "ON_CALL",
"Weekends": "WEEKENDS",
"Specific Days": "SPECIFIC_DAYS",
"Seasonal": "SEASONAL",
"Medical Leave": "MEDICAL_LEAVE",
};
const englishLevelMap = {
"Fluent": "FLUENT",
"Intermediate": "INTERMEDIATE",
"Basic": "BASIC",
"None": "NONE",
};
const payload = {
// --- Fields from error messages ---
employeeName: formData.employee_name,
employmentType: employmentTypeMap[formData.employment_type],
english: englishLevelMap[formData.english],
backgroundCheckStatus: 'NOT_REQUIRED', // Default as it's missing from form
// --- Other likely fields (from form) ---
contactNumber: formData.contact_number,
hubLocation: formData.hub_location,
profileType: formData.profile_type,
reliabilityScore: parseInt(formData.reliability_score) || 100,
// --- Fields from form that might match schema ---
email: formData.email,
position: formData.position,
department: formData.department,
manager: formData.manager,
rate: parseFloat(formData.rate) || 0,
notes: formData.notes,
rating: parseFloat(formData.rating) || 0,
};
// Remove any keys with undefined values to keep the payload clean
Object.keys(payload).forEach(key => {
if (payload[key] === undefined || payload[key] === null) {
delete payload[key];
}
});
createStaffMutation.mutate(payload);
const handleSubmit = (staffData) => {
createStaffMutation.mutate(staffData);
};
return (
@@ -93,11 +29,11 @@ export default function AddStaff() {
<div className="mb-8">
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("StaffDirectory"))}
onClick={() => navigate(createPageUrl("Dashboard"))}
className="mb-4 hover:bg-slate-100"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Staff Directory
Back to Dashboard
</Button>
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">Add New Staff Member</h1>
<p className="text-slate-600">Fill in the details to add a new team member</p>

View File

@@ -1,18 +1,767 @@
import React from "react";
import { Award } from "lucide-react";
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Award, Search, Plus, AlertTriangle, CheckCircle2, Clock, XCircle,
Download, Send, Eye, Edit2, ShieldCheck, FileText, Sparkles,
Calendar, User, Building2, ChevronRight, Filter, Bell, TrendingUp
} from "lucide-react";
import { format, differenceInDays, parseISO } from "date-fns";
import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
const REQUIRED_CERTIFICATIONS = ["Background Check", "RBS", "Food Handler"];
const CERT_CONFIG = {
"Background Check": {
color: "from-purple-500 to-purple-600",
bgColor: "bg-purple-50",
textColor: "text-purple-700",
borderColor: "border-purple-200",
icon: ShieldCheck,
description: "Criminal background verification"
},
"RBS": {
color: "from-blue-500 to-blue-600",
bgColor: "bg-blue-50",
textColor: "text-blue-700",
borderColor: "border-blue-200",
icon: Award,
description: "Responsible Beverage Server"
},
"Food Handler": {
color: "from-emerald-500 to-emerald-600",
bgColor: "bg-emerald-50",
textColor: "text-emerald-700",
borderColor: "border-emerald-200",
icon: FileText,
description: "Food safety certification"
},
};
export default function Certification() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [certTypeFilter, setCertTypeFilter] = useState("all");
const [showAddModal, setShowAddModal] = useState(false);
const [editingCert, setEditingCert] = useState(null);
const [showReportModal, setShowReportModal] = useState(false);
const [selectedEmployee, setSelectedEmployee] = useState(null);
const { data: user } = useQuery({
queryKey: ['current-user-cert'],
queryFn: () => base44.auth.me(),
});
const { data: certifications = [] } = useQuery({
queryKey: ['certifications'],
queryFn: () => base44.entities.Certification.list(),
initialData: [],
});
const { data: staff = [] } = useQuery({
queryKey: ['staff-for-cert'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const userRole = user?.user_role || user?.role || "admin";
const isVendor = userRole === "vendor";
const isProcurement = userRole === "procurement";
const calculateStatus = (expiryDate) => {
if (!expiryDate) return "pending";
const days = differenceInDays(parseISO(expiryDate), new Date());
if (days < 0) return "expired";
if (days <= 30) return "expiring_soon";
return "current";
};
const processedCerts = useMemo(() => {
return certifications.map(cert => ({
...cert,
days_until_expiry: cert.expiry_date ? differenceInDays(parseISO(cert.expiry_date), new Date()) : null,
status: calculateStatus(cert.expiry_date),
}));
}, [certifications]);
const employeeCertMap = useMemo(() => {
const map = {};
staff.forEach(s => {
map[s.id] = {
employee_id: s.id,
employee_name: s.employee_name,
vendor_id: s.vendor_id,
vendor_name: s.vendor_name,
position: s.position,
certifications: { "Background Check": null, "RBS": null, "Food Handler": null },
allCurrent: false,
hasExpired: false,
hasExpiringSoon: false,
missingCount: 3,
canWork: false,
complianceScore: 0,
};
});
processedCerts.forEach(cert => {
const key = cert.employee_id;
if (!map[key]) {
map[key] = {
employee_id: cert.employee_id,
employee_name: cert.employee_name,
vendor_id: cert.vendor_id,
vendor_name: cert.vendor_name,
position: "",
certifications: { "Background Check": null, "RBS": null, "Food Handler": null },
allCurrent: false,
hasExpired: false,
hasExpiringSoon: false,
missingCount: 3,
canWork: false,
complianceScore: 0,
};
}
if (REQUIRED_CERTIFICATIONS.includes(cert.certification_type)) {
map[key].certifications[cert.certification_type] = cert;
}
});
Object.values(map).forEach(emp => {
const certs = Object.values(emp.certifications);
const validCerts = certs.filter(c => c && c.status === "current");
const expiredCerts = certs.filter(c => c && c.status === "expired");
const expiringSoonCerts = certs.filter(c => c && c.status === "expiring_soon");
const missingCerts = certs.filter(c => !c);
emp.allCurrent = validCerts.length === 3;
emp.hasExpired = expiredCerts.length > 0;
emp.hasExpiringSoon = expiringSoonCerts.length > 0;
emp.missingCount = missingCerts.length;
emp.canWork = validCerts.length === 3 || (validCerts.length + expiringSoonCerts.length === 3);
emp.complianceScore = Math.round(((validCerts.length + expiringSoonCerts.length * 0.5) / 3) * 100);
});
return map;
}, [processedCerts, staff]);
const employeeList = Object.values(employeeCertMap);
const filteredEmployees = useMemo(() => {
let filtered = employeeList;
if (isVendor && user?.vendor_id) {
filtered = filtered.filter(e => e.vendor_id === user.vendor_id);
}
if (searchTerm) {
filtered = filtered.filter(e =>
e.employee_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (activeTab === "compliant") filtered = filtered.filter(e => e.allCurrent);
else if (activeTab === "expiring") filtered = filtered.filter(e => e.hasExpiringSoon);
else if (activeTab === "expired") filtered = filtered.filter(e => e.hasExpired);
else if (activeTab === "incomplete") filtered = filtered.filter(e => e.missingCount > 0);
if (certTypeFilter !== "all") {
filtered = filtered.filter(e => {
const cert = e.certifications[certTypeFilter];
return cert !== null;
});
}
return filtered;
}, [employeeList, searchTerm, activeTab, certTypeFilter, isVendor, user]);
const stats = useMemo(() => {
const total = employeeList.length;
const compliant = employeeList.filter(e => e.allCurrent).length;
const expiring = employeeList.filter(e => e.hasExpiringSoon).length;
const expired = employeeList.filter(e => e.hasExpired).length;
const incomplete = employeeList.filter(e => e.missingCount > 0).length;
const avgCompliance = total > 0 ? Math.round(employeeList.reduce((sum, e) => sum + e.complianceScore, 0) / total) : 0;
return { total, compliant, expiring, expired, incomplete, avgCompliance };
}, [employeeList]);
const saveCertMutation = useMutation({
mutationFn: async (data) => {
if (data.id) return base44.entities.Certification.update(data.id, data);
return base44.entities.Certification.create(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
setShowAddModal(false);
setEditingCert(null);
toast({ title: "✅ Certification saved" });
},
});
const sendExpiryAlert = async (cert) => {
try {
await base44.integrations.Core.SendEmail({
to: user?.email || "admin@company.com",
subject: `⚠️ Certification Expiring: ${cert.employee_name} - ${cert.certification_type}`,
body: `<h2>Certification Expiring Alert</h2>
<p><strong>Employee:</strong> ${cert.employee_name}</p>
<p><strong>Certification:</strong> ${cert.certification_type}</p>
<p><strong>Expiry Date:</strong> ${format(parseISO(cert.expiry_date), 'MMM d, yyyy')}</p>
<p><strong>Days Until Expiry:</strong> ${cert.days_until_expiry} days</p>`
});
toast({ title: "✅ Alert sent" });
} catch (error) {
toast({ title: "Failed to send alert", variant: "destructive" });
}
};
const sendComplianceReport = async (clientEmail) => {
const compliantEmployees = employeeList.filter(e => e.allCurrent);
try {
await base44.integrations.Core.SendEmail({
to: clientEmail,
subject: "Staff Compliance Report",
body: `<h2>Staff Compliance Report</h2>
<p>Generated: ${format(new Date(), 'MMM d, yyyy')}</p>
<p><strong>Total Staff:</strong> ${stats.total}</p>
<p><strong>Fully Compliant:</strong> ${stats.compliant}</p>
<p><strong>Average Compliance:</strong> ${stats.avgCompliance}%</p>
<hr/><h3>Compliant Staff</h3>
<ul>${compliantEmployees.map(e => `<li>${e.employee_name}</li>`).join('')}</ul>`
});
toast({ title: "✅ Report sent" });
setShowReportModal(false);
} catch (error) {
toast({ title: "Failed to send report", variant: "destructive" });
}
};
return (
<div className="p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<Award className="w-8 h-8" />
<h1 className="text-3xl font-bold">Certification</h1>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
<div className="p-4 md:p-6 max-w-[1800px] mx-auto">
{/* Hero Header */}
<div className="relative overflow-hidden bg-gradient-to-r from-[#0A39DF] via-blue-600 to-[#1C323E] rounded-2xl p-6 md:p-8 mb-6 text-white">
<div className="absolute inset-0 opacity-50" style={{ backgroundImage: "url(\"data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E\")" }} />
<div className="relative z-10">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-white/20 backdrop-blur rounded-xl flex items-center justify-center">
<Award className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold">Certification Hub</h1>
<p className="text-blue-100 text-sm">Track & manage workforce compliance</p>
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" className="bg-white/20 hover:bg-white/30 text-white border-0" onClick={() => setShowReportModal(true)}>
<Send className="w-4 h-4 mr-2" />Send Report
</Button>
{(isVendor || userRole === "admin") && (
<Button className="bg-white text-blue-600 hover:bg-blue-50" onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4 mr-2" />Add Certification
</Button>
)}
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mt-6">
<div className="bg-white/10 backdrop-blur rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<User className="w-4 h-4 text-blue-200" />
<span className="text-xs text-blue-200">Total Staff</span>
</div>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="bg-emerald-500/30 backdrop-blur rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<CheckCircle2 className="w-4 h-4 text-emerald-200" />
<span className="text-xs text-emerald-200">Compliant</span>
</div>
<p className="text-2xl font-bold">{stats.compliant}</p>
</div>
<div className="bg-amber-500/30 backdrop-blur rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-amber-200" />
<span className="text-xs text-amber-200">Expiring 30d</span>
</div>
<p className="text-2xl font-bold">{stats.expiring}</p>
</div>
<div className="bg-red-500/30 backdrop-blur rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<XCircle className="w-4 h-4 text-red-200" />
<span className="text-xs text-red-200">Expired</span>
</div>
<p className="text-2xl font-bold">{stats.expired}</p>
</div>
<div className="bg-white/10 backdrop-blur rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-blue-200" />
<span className="text-xs text-blue-200">Avg. Compliance</span>
</div>
<p className="text-2xl font-bold">{stats.avgCompliance}%</p>
</div>
</div>
</div>
</div>
<div className="text-center py-16 bg-white rounded-xl border">
<Award className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<p className="text-slate-600">Certification management coming soon</p>
{/* Filters Bar */}
<Card className="border-0 shadow-lg mb-6 overflow-hidden">
<CardContent className="p-4">
<div className="flex flex-col md:flex-row md:items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search employees..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-11 bg-slate-50 border-slate-200 rounded-xl"
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-shrink-0">
<TabsList className="bg-slate-100 p-1 rounded-xl h-11">
<TabsTrigger value="all" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow">
All
</TabsTrigger>
<TabsTrigger value="compliant" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
Compliant
</TabsTrigger>
<TabsTrigger value="expiring" className="rounded-lg data-[state=active]:bg-amber-500 data-[state=active]:text-white">
30 Days
</TabsTrigger>
<TabsTrigger value="expired" className="rounded-lg data-[state=active]:bg-red-500 data-[state=active]:text-white">
Expired
</TabsTrigger>
<TabsTrigger value="incomplete" className="rounded-lg data-[state=active]:bg-slate-600 data-[state=active]:text-white">
Missing
</TabsTrigger>
</TabsList>
</Tabs>
<Select value={certTypeFilter} onValueChange={setCertTypeFilter}>
<SelectTrigger className="w-[180px] h-11 rounded-xl">
<Filter className="w-4 h-4 mr-2 text-slate-400" />
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="Background Check">Background Check</SelectItem>
<SelectItem value="RBS">RBS</SelectItem>
<SelectItem value="Food Handler">Food Handler</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Employee Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<AnimatePresence>
{filteredEmployees.length === 0 ? (
<div className="col-span-full">
<Card className="border-0 shadow-lg">
<CardContent className="p-12 text-center">
<Award className="w-16 h-16 mx-auto mb-4 text-slate-200" />
<h3 className="text-lg font-semibold text-slate-700 mb-2">No employees found</h3>
<p className="text-slate-500">Try adjusting your search or filters</p>
</CardContent>
</Card>
</div>
) : (
filteredEmployees.map((emp, idx) => (
<motion.div
key={emp.employee_id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: idx * 0.05 }}
>
<EmployeeCertCard
employee={emp}
onAddCert={(type) => {
setEditingCert({
employee_id: emp.employee_id,
employee_name: emp.employee_name,
vendor_id: emp.vendor_id,
vendor_name: emp.vendor_name,
certification_type: type,
});
setShowAddModal(true);
}}
onEditCert={(cert) => {
setEditingCert(cert);
setShowAddModal(true);
}}
onSendAlert={sendExpiryAlert}
showVendor={isProcurement || userRole === "admin"}
/>
</motion.div>
))
)}
</AnimatePresence>
</div>
{/* Add/Edit Modal */}
<Dialog open={showAddModal} onOpenChange={setShowAddModal}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Award className="w-5 h-5 text-blue-600" />
{editingCert?.id ? 'Update' : 'Add'} Certification
</DialogTitle>
{editingCert?.employee_name && (
<DialogDescription>For: {editingCert.employee_name}</DialogDescription>
)}
</DialogHeader>
<CertificationForm
certification={editingCert}
staff={staff}
onSave={(data) => saveCertMutation.mutate(data)}
onCancel={() => { setShowAddModal(false); setEditingCert(null); }}
isLoading={saveCertMutation.isPending}
/>
</DialogContent>
</Dialog>
{/* Report Modal */}
<Dialog open={showReportModal} onOpenChange={setShowReportModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Send className="w-5 h-5 text-blue-600" />
Send Compliance Report
</DialogTitle>
</DialogHeader>
<ReportForm onSend={sendComplianceReport} onCancel={() => setShowReportModal(false)} stats={stats} />
</DialogContent>
</Dialog>
</div>
</div>
);
}
function EmployeeCertCard({ employee, onAddCert, onEditCert, onSendAlert, showVendor }) {
const emp = employee;
return (
<Card className={`border-0 shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden ${
!emp.canWork ? 'ring-2 ring-red-200' : emp.allCurrent ? 'ring-2 ring-emerald-200' : ''
}`}>
<CardContent className="p-0">
{/* Header */}
<div className={`p-4 ${emp.allCurrent ? 'bg-gradient-to-r from-emerald-500 to-emerald-600' : emp.hasExpired ? 'bg-gradient-to-r from-red-500 to-red-600' : emp.hasExpiringSoon ? 'bg-gradient-to-r from-amber-500 to-amber-600' : 'bg-gradient-to-r from-slate-500 to-slate-600'} text-white`}>
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12 border-2 border-white/30">
<AvatarFallback className="bg-white/20 text-white font-bold text-lg">
{emp.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-lg truncate">{emp.employee_name}</h3>
<p className="text-sm text-white/80 truncate">{emp.position || "Staff Member"}</p>
</div>
<div className="text-right">
<div className={`px-3 py-1 rounded-full text-xs font-bold ${emp.canWork ? 'bg-white/20' : 'bg-white text-red-600'}`}>
{emp.canWork ? "Can Work" : "Cannot Work"}
</div>
</div>
</div>
{/* Compliance Progress */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-white/80">Compliance Score</span>
<span className="font-bold">{emp.complianceScore}%</span>
</div>
<Progress value={emp.complianceScore} className="h-2 bg-white/20" />
</div>
</div>
{/* Certifications */}
<div className="p-4 space-y-3">
{REQUIRED_CERTIFICATIONS.map(type => {
const cert = emp.certifications[type];
const config = CERT_CONFIG[type];
const Icon = config.icon;
return (
<div
key={type}
className={`flex items-center gap-3 p-3 rounded-xl border-2 transition-all ${
cert ? (
cert.status === "current" ? `${config.bgColor} ${config.borderColor}` :
cert.status === "expiring_soon" ? "bg-amber-50 border-amber-200" :
"bg-red-50 border-red-200"
) : "bg-slate-50 border-dashed border-slate-300"
}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
cert ? (
cert.status === "current" ? `bg-gradient-to-br ${config.color} text-white` :
cert.status === "expiring_soon" ? "bg-amber-500 text-white" :
"bg-red-500 text-white"
) : "bg-slate-200 text-slate-400"
}`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 text-sm">{type}</p>
{cert ? (
<p className={`text-xs ${cert.status === "current" ? "text-slate-500" : cert.status === "expiring_soon" ? "text-amber-600" : "text-red-600"}`}>
{cert.status === "expired" ? "Expired" : `Expires: ${format(parseISO(cert.expiry_date), 'MMM d, yyyy')}`}
{cert.days_until_expiry !== null && cert.days_until_expiry >= 0 && ` (${cert.days_until_expiry}d)`}
</p>
) : (
<p className="text-xs text-slate-400">Not uploaded</p>
)}
</div>
<div className="flex items-center gap-1">
{cert ? (
<>
{cert.status === "current" && (
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
)}
{cert.status === "expiring_soon" && (
<button
onClick={() => onSendAlert(cert)}
className="p-1.5 rounded-lg bg-amber-100 text-amber-600 hover:bg-amber-200 transition-colors"
title="Send reminder"
>
<Bell className="w-4 h-4" />
</button>
)}
{cert.status === "expired" && (
<XCircle className="w-5 h-5 text-red-500" />
)}
<button
onClick={() => onEditCert(cert)}
className="p-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
title="Edit"
>
<Edit2 className="w-4 h-4" />
</button>
</>
) : (
<button
onClick={() => onAddCert(type)}
className="p-1.5 rounded-lg bg-blue-100 text-blue-600 hover:bg-blue-200 transition-colors"
title="Add certification"
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
</div>
);
})}
</div>
{/* Footer */}
{showVendor && emp.vendor_name && (
<div className="px-4 pb-4">
<div className="flex items-center gap-2 text-xs text-slate-500">
<Building2 className="w-3 h-3" />
<span>{emp.vendor_name}</span>
</div>
</div>
)}
</CardContent>
</Card>
);
}
function CertificationForm({ certification, staff, onSave, onCancel, isLoading }) {
const [formData, setFormData] = useState({
employee_id: certification?.employee_id || "",
employee_name: certification?.employee_name || "",
vendor_id: certification?.vendor_id || "",
vendor_name: certification?.vendor_name || "",
certification_type: certification?.certification_type || "",
issue_date: certification?.issue_date || "",
expiry_date: certification?.expiry_date || "",
issuer: certification?.issuer || "",
certificate_number: certification?.certificate_number || "",
notes: certification?.notes || "",
...certification,
});
const handleSubmit = (e) => {
e.preventDefault();
onSave(formData);
};
const handleStaffSelect = (staffId) => {
const selectedStaff = staff.find(s => s.id === staffId);
if (selectedStaff) {
setFormData(prev => ({
...prev,
employee_id: staffId,
employee_name: selectedStaff.employee_name,
vendor_id: selectedStaff.vendor_id,
vendor_name: selectedStaff.vendor_name,
}));
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{!certification?.employee_id && (
<div>
<Label className="text-sm font-medium">Employee *</Label>
<Select value={formData.employee_id} onValueChange={handleStaffSelect}>
<SelectTrigger className="mt-1.5">
<SelectValue placeholder="Select employee" />
</SelectTrigger>
<SelectContent>
{staff.map(s => (
<SelectItem key={s.id} value={s.id}>{s.employee_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label className="text-sm font-medium">Certification Type *</Label>
<Select
value={formData.certification_type}
onValueChange={(v) => setFormData(prev => ({ ...prev, certification_type: v }))}
disabled={!!certification?.certification_type}
>
<SelectTrigger className="mt-1.5">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{REQUIRED_CERTIFICATIONS.map(type => (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">
{React.createElement(CERT_CONFIG[type].icon, { className: "w-4 h-4" })}
{type}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium">Issue Date</Label>
<Input
type="date"
value={formData.issue_date}
onChange={(e) => setFormData(prev => ({ ...prev, issue_date: e.target.value }))}
className="mt-1.5"
/>
</div>
<div>
<Label className="text-sm font-medium">Expiry Date *</Label>
<Input
type="date"
value={formData.expiry_date}
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))}
className="mt-1.5"
required
/>
</div>
</div>
<div>
<Label className="text-sm font-medium">Issuing Authority</Label>
<Input
value={formData.issuer}
onChange={(e) => setFormData(prev => ({ ...prev, issuer: e.target.value }))}
placeholder="e.g., California ABC"
className="mt-1.5"
/>
</div>
<div>
<Label className="text-sm font-medium">Certificate Number</Label>
<Input
value={formData.certificate_number}
onChange={(e) => setFormData(prev => ({ ...prev, certificate_number: e.target.value }))}
placeholder="Certificate ID"
className="mt-1.5"
/>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
<Button type="submit" disabled={isLoading} className="bg-blue-600 hover:bg-blue-700">
{isLoading ? "Saving..." : "Save Certification"}
</Button>
</div>
</form>
);
}
function ReportForm({ onSend, onCancel, stats }) {
const [email, setEmail] = useState("");
return (
<div className="space-y-4">
<div className="p-4 bg-gradient-to-br from-blue-50 to-slate-50 rounded-xl border border-blue-100">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-blue-600" />
Report Preview
</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-white p-3 rounded-lg">
<span className="text-slate-500 text-xs">Total Staff</span>
<p className="font-bold text-lg text-slate-900">{stats.total}</p>
</div>
<div className="bg-emerald-50 p-3 rounded-lg">
<span className="text-emerald-600 text-xs">Compliant</span>
<p className="font-bold text-lg text-emerald-700">{stats.compliant}</p>
</div>
<div className="bg-amber-50 p-3 rounded-lg">
<span className="text-amber-600 text-xs">Expiring Soon</span>
<p className="font-bold text-lg text-amber-700">{stats.expiring}</p>
</div>
<div className="bg-red-50 p-3 rounded-lg">
<span className="text-red-600 text-xs">Expired</span>
<p className="font-bold text-lg text-red-700">{stats.expired}</p>
</div>
</div>
</div>
<div>
<Label className="text-sm font-medium">Recipient Email *</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="client@company.com"
className="mt-1.5"
required
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button onClick={() => onSend(email)} disabled={!email} className="bg-blue-600 hover:bg-blue-700">
<Send className="w-4 h-4 mr-2" />Send Report
</Button>
</div>
</div>
);

View File

@@ -1,5 +1,5 @@
import React from "react";
import { krowSDK } from "@/api/krowSDK";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
@@ -21,17 +21,17 @@ export default function CreateEvent() {
const { data: currentUser } = useQuery({
queryKey: ['current-user-create-event'],
queryFn: () => krowSDK.auth.me(),
queryFn: () => base44.auth.me(),
});
const { data: allEvents = [] } = useQuery({
queryKey: ['events-for-conflict-check'],
queryFn: () => krowSDK.entities.Event.list(),
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const createEventMutation = useMutation({
mutationFn: (eventData) => krowSDK.entities.Event.create(eventData),
mutationFn: (eventData) => base44.entities.Event.create(eventData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['client-events'] });

View File

@@ -1,5 +1,4 @@
import React, { useState } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
@@ -13,6 +12,7 @@ import StatsCard from "@/components/staff/StatsCard";
import EcosystemWheel from "@/components/dashboard/EcosystemWheel";
import QuickMetrics from "@/components/dashboard/QuickMetrics";
import PageHeader from "@/components/common/PageHeader";
import DashboardCustomizer from "@/components/dashboard/DashboardCustomizer";
import { format, parseISO, isValid, isSameDay, startOfDay } from "date-fns";
const safeParseDate = (dateString) => {
@@ -108,6 +108,12 @@ const getAssignmentStatus = (event) => {
export default function Dashboard() {
const navigate = useNavigate();
const [selectedLayer, setSelectedLayer] = useState(null);
const [visibleWidgets, setVisibleWidgets] = useState([]);
const { data: user } = useQuery({
queryKey: ['current-user-admin-dashboard'],
queryFn: () => base44.auth.me(),
});
const { data: staff, isLoading: loadingStaff } = useQuery({
queryKey: ['staff'],
@@ -121,6 +127,30 @@ export default function Dashboard() {
initialData: [],
});
// Define available widgets for this dashboard
const availableWidgets = useMemo(() => [
{ id: 'global_metrics', title: 'Global Metrics', description: 'Fill rate, spend, score & active events', category: 'Overview', categoryColor: 'bg-blue-100 text-blue-700' },
{ id: 'todays_orders', title: "Today's Orders", description: 'Orders scheduled for today', category: 'Operations', categoryColor: 'bg-emerald-100 text-emerald-700' },
{ id: 'ecosystem_map', title: 'Ecosystem Map', description: 'Interactive connection map', category: 'Overview', categoryColor: 'bg-purple-100 text-purple-700' },
{ id: 'quick_access', title: 'Quick Access Cards', description: 'Procurement, Operator & Vendor dashboards', category: 'Navigation', categoryColor: 'bg-amber-100 text-amber-700' },
{ id: 'workforce_overview', title: 'Workforce Overview', description: 'Recent staff and workers', category: 'Workforce', categoryColor: 'bg-pink-100 text-pink-700' },
], []);
// Initialize visible widgets from user preferences or defaults
useEffect(() => {
const savedLayout = user?.dashboard_layout_admin;
if (savedLayout?.widgets && savedLayout.widgets.length > 0) {
const orderedWidgets = savedLayout.widgets
.map(id => availableWidgets.find(w => w.id === id))
.filter(Boolean);
setVisibleWidgets(orderedWidgets);
} else {
setVisibleWidgets(availableWidgets);
}
}, [user, availableWidgets]);
const isWidgetVisible = (widgetId) => visibleWidgets.some(w => w.id === widgetId);
// Filter events for today only
const today = startOfDay(new Date());
const todaysEvents = events.filter(event => {
@@ -198,6 +228,13 @@ export default function Dashboard() {
subtitle="Your Complete Workforce Management Ecosystem"
actions={
<>
<DashboardCustomizer
user={user}
availableWidgets={availableWidgets}
currentLayout={visibleWidgets}
onLayoutChange={setVisibleWidgets}
dashboardType="admin"
/>
<Button variant="outline" className="border-slate-300 hover:bg-slate-100">
<BarChart3 className="w-4 h-4 mr-2" />
Reports
@@ -213,282 +250,292 @@ export default function Dashboard() {
/>
{/* Global Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Fill Rate"
value={`${totalFillRate}%`}
icon={Target}
gradient="bg-gradient-to-br from-[#0A39DF] to-[#1C323E]"
change="+2.5% this month"
/>
<StatsCard
title="Total Spend"
value={`$${totalSpend}M`}
icon={DollarSign}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-700"
change="+$180K this month"
/>
<StatsCard
title="Overall Score"
value={overallScore}
icon={Award}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
/>
<StatsCard
title="Active Events"
value={activeEvents}
icon={Calendar}
gradient="bg-gradient-to-br from-purple-500 to-purple-700"
change={`${completionRate}% completion rate`}
/>
</div>
{isWidgetVisible('global_metrics') && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Fill Rate"
value={`${totalFillRate}%`}
icon={Target}
gradient="bg-gradient-to-br from-[#0A39DF] to-[#1C323E]"
change="+2.5% this month"
/>
<StatsCard
title="Total Spend"
value={`$${totalSpend}M`}
icon={DollarSign}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-700"
change="+$180K this month"
/>
<StatsCard
title="Overall Score"
value={overallScore}
icon={Award}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
/>
<StatsCard
title="Active Events"
value={activeEvents}
icon={Calendar}
gradient="bg-gradient-to-br from-purple-500 to-purple-700"
change={`${completionRate}% completion rate`}
/>
</div>
)}
{/* Today's Orders Section */}
<Card className="mb-8 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Calendar className="w-6 h-6 text-[#0A39DF]" />
Today's Orders - {format(today, 'EEEE, MMMM d, yyyy')}
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Orders scheduled for today only</p>
{isWidgetVisible('todays_orders') && (
<Card className="mb-8 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Calendar className="w-6 h-6 text-[#0A39DF]" />
Today's Orders - {format(today, 'EEEE, MMMM d, yyyy')}
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Orders scheduled for today only</p>
</div>
<Link to={createPageUrl("Events")}>
<Button variant="outline" className="border-slate-300">
View All Orders
</Button>
</Link>
</div>
<Link to={createPageUrl("Events")}>
<Button variant="outline" className="border-slate-300">
View All Orders
</Button>
</Link>
</div>
</CardHeader>
<CardContent className="p-0">
{todaysEvents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<Calendar className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No orders scheduled for today</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todaysEvents.map((event) => {
const assignmentStatus = getAssignmentStatus(event);
const eventTimes = getEventTimes(event);
const eventDate = safeParseDate(event.date);
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
return (
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors border-b">
<TableCell className="py-3">
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center gap-1.5 text-sm text-slate-500">
<MapPin className="w-3.5 h-3.5" />
{event.hub || event.event_location || "Main Hub"}
</div>
</TableCell>
<TableCell className="py-3">
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
</TableCell>
<TableCell className="py-3">
<div className="space-y-0.5">
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
<p className="text-xs text-slate-500">{dayOfWeek}</p>
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
<Clock className="w-3 h-3" />
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
</CardHeader>
<CardContent className="p-0">
{todaysEvents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<Calendar className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No orders scheduled for today</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todaysEvents.map((event) => {
const assignmentStatus = getAssignmentStatus(event);
const eventTimes = getEventTimes(event);
const eventDate = safeParseDate(event.date);
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
return (
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors border-b">
<TableCell className="py-3">
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center gap-1.5 text-sm text-slate-500">
<MapPin className="w-3.5 h-3.5" />
{event.hub || event.event_location || "Main Hub"}
</div>
</div>
</TableCell>
<TableCell className="py-3">
{getStatusBadge(event)}
</TableCell>
<TableCell className="text-center py-3">
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex flex-col items-center gap-1">
<div className={`w-10 h-10 rounded-full ${assignmentStatus.color} flex items-center justify-center font-bold text-sm`}>
{assignmentStatus.text}
</TableCell>
<TableCell className="py-3">
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
</TableCell>
<TableCell className="py-3">
<div className="space-y-0.5">
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
<p className="text-xs text-slate-500">{dayOfWeek}</p>
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
<Clock className="w-3 h-3" />
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
</div>
</div>
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{event.invoice_id && (
</TableCell>
<TableCell className="py-3">
{getStatusBadge(event)}
</TableCell>
<TableCell className="text-center py-3">
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex flex-col items-center gap-1">
<div className={`w-10 h-10 rounded-full ${assignmentStatus.color} flex items-center justify-center font-bold text-sm`}>
{assignmentStatus.text}
</div>
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`Invoices?id=${event.invoice_id}`))}
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View Invoice"
title="View"
>
<FileText className="w-4 h-4 text-blue-600" />
<Eye className="w-4 h-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{event.invoice_id && (
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`Invoices?id=${event.invoice_id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View Invoice"
>
<FileText className="w-4 h-4 text-blue-600" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
)}
{/* Ecosystem Puzzle */}
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Target className="w-6 h-6 text-[#0A39DF]" />
Ecosystem Connection Map
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Interactive puzzle showing how each layer connects Hover to see metrics Click to explore</p>
</CardHeader>
<CardContent className="p-8">
<EcosystemWheel
layers={ecosystemLayers}
onLayerClick={(layer) => navigate(createPageUrl(layer.route))}
selectedLayer={selectedLayer}
onLayerHover={setSelectedLayer}
/>
</CardContent>
</Card>
{isWidgetVisible('ecosystem_map') && (
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Target className="w-6 h-6 text-[#0A39DF]" />
Ecosystem Connection Map
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Interactive puzzle showing how each layer connects • Hover to see metrics • Click to explore</p>
</CardHeader>
<CardContent className="p-8">
<EcosystemWheel
layers={ecosystemLayers}
onLayerClick={(layer) => navigate(createPageUrl(layer.route))}
selectedLayer={selectedLayer}
onLayerHover={setSelectedLayer}
/>
</CardContent>
</Card>
)}
{/* Quick Access Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<QuickMetrics
title="Procurement & Vendor Intelligence"
description="Vendor efficiency, spend analysis, compliance tracking"
icon={Shield}
metrics={[
{ label: "Vendor Score", value: "A+", color: "text-green-600" },
{ label: "Compliance", value: "98%", color: "text-blue-600" },
{ label: "ESG Rating", value: "B+", color: "text-emerald-600" }
]}
route="ProcurementDashboard"
gradient="from-[#0A39DF]/10 to-[#1C323E]/10"
/>
{isWidgetVisible('quick_access') && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<QuickMetrics
title="Procurement & Vendor Intelligence"
description="Vendor efficiency, spend analysis, compliance tracking"
icon={Shield}
metrics={[
{ label: "Vendor Score", value: "A+", color: "text-green-600" },
{ label: "Compliance", value: "98%", color: "text-blue-600" },
{ label: "ESG Rating", value: "B+", color: "text-emerald-600" }
]}
route="ProcurementDashboard"
gradient="from-[#0A39DF]/10 to-[#1C323E]/10"
/>
<QuickMetrics
title="Operator & Sector Dashboard"
description="Live coverage, demand forecast, incident tracking"
icon={MapPin}
metrics={[
{ label: "Coverage", value: "94%", color: "text-green-600" },
{ label: "Incidents", value: "2", color: "text-yellow-600" },
{ label: "Forecast Accuracy", value: "91%", color: "text-blue-600" }
]}
route="OperatorDashboard"
gradient="from-emerald-500/10 to-emerald-700/10"
/>
<QuickMetrics
title="Operator & Sector Dashboard"
description="Live coverage, demand forecast, incident tracking"
icon={MapPin}
metrics={[
{ label: "Coverage", value: "94%", color: "text-green-600" },
{ label: "Incidents", value: "2", color: "text-yellow-600" },
{ label: "Forecast Accuracy", value: "91%", color: "text-blue-600" }
]}
route="OperatorDashboard"
gradient="from-emerald-500/10 to-emerald-700/10"
/>
<QuickMetrics
title="Vendor Dashboard"
description="Orders, invoices, workforce pulse, KROW score"
icon={Award}
metrics={[
{ label: "Fill Rate", value: "97%", color: "text-green-600" },
{ label: "Attendance", value: "95%", color: "text-blue-600" },
{ label: "Training", value: "92%", color: "text-purple-600" }
]}
route="VendorDashboard"
gradient="from-amber-500/10 to-amber-700/10"
/>
</div>
<QuickMetrics
title="Vendor Dashboard"
description="Orders, invoices, workforce pulse, KROW score"
icon={Award}
metrics={[
{ label: "Fill Rate", value: "97%", color: "text-green-600" },
{ label: "Attendance", value: "95%", color: "text-blue-600" },
{ label: "Training", value: "92%", color: "text-purple-600" }
]}
route="VendorDashboard"
gradient="from-amber-500/10 to-amber-700/10"
/>
</div>
)}
{/* Workforce Section */}
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Users className="w-6 h-6 text-[#0A39DF]" />
Workforce Overview
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Recent additions and active workers</p>
</div>
<div className="flex gap-2">
<Link to={createPageUrl("WorkforceDashboard")}>
<Button variant="outline" className="border-slate-300">
View Workforce App
</Button>
</Link>
<Link to={createPageUrl("StaffDirectory")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
View All Staff
</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{recentStaff.slice(0, 3).map((member) => (
<div key={member.id} className="p-4 rounded-lg border border-slate-200 hover:border-[#0A39DF] hover:shadow-md transition-all">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white font-bold">
{member.initial || member.employee_name?.charAt(0)}
</div>
<div>
<h4 className="font-semibold text-[#1C323E]">{member.employee_name}</h4>
<p className="text-sm text-slate-500">{member.position}</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Rating:</span>
<span className="font-semibold">{member.rating || 0}/5 </span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Coverage:</span>
<span className="font-semibold text-green-600">{member.shift_coverage_percentage || 0}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Cancellations:</span>
<span className="font-semibold text-red-600">{member.cancellation_count || 0}</span>
</div>
</div>
{isWidgetVisible('workforce_overview') && (
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Users className="w-6 h-6 text-[#0A39DF]" />
Workforce Overview
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Recent additions and active workers</p>
</div>
))}
</div>
</CardContent>
</Card>
<div className="flex gap-2">
<Link to={createPageUrl("WorkforceDashboard")}>
<Button variant="outline" className="border-slate-300">
View Workforce App
</Button>
</Link>
<Link to={createPageUrl("StaffDirectory")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
View All Staff
</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{recentStaff.slice(0, 3).map((member) => (
<div key={member.id} className="p-4 rounded-lg border border-slate-200 hover:border-[#0A39DF] hover:shadow-md transition-all">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white font-bold">
{member.initial || member.employee_name?.charAt(0)}
</div>
<div>
<h4 className="font-semibold text-[#1C323E]">{member.employee_name}</h4>
<p className="text-sm text-slate-500">{member.position}</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Rating:</span>
<span className="font-semibold">{member.rating || 0}/5 </span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Coverage:</span>
<span className="font-semibold text-green-600">{member.shift_coverage_percentage || 0}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Cancellations:</span>
<span className="font-semibold text-red-600">{member.cancellation_count || 0}</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,703 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
FileText, Search, Plus, Clock, CheckCircle2, XCircle, AlertTriangle,
Upload, Eye, Download, Activity, Filter, Users, ChevronRight
} from "lucide-react";
import { format, differenceInDays, parseISO } from "date-fns";
import { useToast } from "@/components/ui/use-toast";
const DOCUMENT_TYPES = [
"W-4 Form",
"I-9 Form",
"State Tax Form",
"Direct Deposit",
"ID Copy",
"SSN Card",
"Work Permit"
];
const STATUS_CONFIG = {
uploaded: { color: "bg-cyan-400", icon: FileText, label: "Uploaded" },
pending: { color: "bg-amber-300", icon: Clock, label: "Pending" },
expiring: { color: "bg-yellow-400", icon: Clock, label: "Expiring" },
expired: { color: "bg-red-400", icon: XCircle, label: "Expired" },
rejected: { color: "bg-red-500", icon: XCircle, label: "Rejected" },
missing: { color: "bg-slate-200", icon: Plus, label: "Missing" },
};
export default function EmployeeDocuments() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [showUploadModal, setShowUploadModal] = useState(false);
const [selectedCell, setSelectedCell] = useState(null);
const [showActivityPanel, setShowActivityPanel] = useState(false);
const [docTypeFilter, setDocTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const { data: user } = useQuery({
queryKey: ['current-user-docs'],
queryFn: () => base44.auth.me(),
});
const { data: documents = [] } = useQuery({
queryKey: ['employee-documents'],
queryFn: () => base44.entities.EmployeeDocument.list(),
initialData: [],
});
const { data: staff = [] } = useQuery({
queryKey: ['staff-for-docs'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const userRole = user?.user_role || user?.role || "admin";
const isVendor = userRole === "vendor";
// Filter staff by vendor
const filteredStaff = useMemo(() => {
let result = staff;
if (isVendor && user?.vendor_id) {
result = result.filter(s => s.vendor_id === user.vendor_id);
}
if (searchTerm) {
result = result.filter(s =>
s.employee_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return result;
}, [staff, isVendor, user, searchTerm]);
// Build document matrix
const documentMatrix = useMemo(() => {
const matrix = {};
filteredStaff.forEach(emp => {
matrix[emp.id] = {
employee: emp,
documents: {},
completionRate: 0,
};
DOCUMENT_TYPES.forEach(type => {
matrix[emp.id].documents[type] = null;
});
});
documents.forEach(doc => {
if (matrix[doc.employee_id]) {
// Calculate status based on expiry
let status = doc.status || "uploaded";
if (doc.expiry_date) {
const days = differenceInDays(parseISO(doc.expiry_date), new Date());
if (days < 0) status = "expired";
else if (days <= 30) status = "expiring";
}
matrix[doc.employee_id].documents[doc.document_type] = { ...doc, status };
}
});
// Calculate completion rates
Object.values(matrix).forEach(row => {
const uploaded = Object.values(row.documents).filter(d => d && d.status === "uploaded").length;
row.completionRate = Math.round((uploaded / DOCUMENT_TYPES.length) * 100);
});
return matrix;
}, [filteredStaff, documents]);
// Stats
const stats = useMemo(() => {
let total = 0, uploaded = 0, pending = 0, expiring = 0, expired = 0;
Object.values(documentMatrix).forEach(row => {
Object.values(row.documents).forEach(doc => {
total++;
if (!doc) return;
if (doc.status === "uploaded") uploaded++;
else if (doc.status === "pending") pending++;
else if (doc.status === "expiring") expiring++;
else if (doc.status === "expired") expired++;
});
});
const missing = total - uploaded - pending - expiring - expired;
return { total, uploaded, pending, expiring, expired, missing };
}, [documentMatrix]);
// Save document
const saveMutation = useMutation({
mutationFn: async (data) => {
if (data.id) {
return base44.entities.EmployeeDocument.update(data.id, data);
}
return base44.entities.EmployeeDocument.create(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employee-documents'] });
setShowUploadModal(false);
setSelectedCell(null);
toast({ title: "✅ Document saved" });
},
});
const handleCellClick = (employeeId, docType, existingDoc) => {
const emp = documentMatrix[employeeId]?.employee;
setSelectedCell({
employee_id: employeeId,
employee_name: emp?.employee_name,
vendor_id: emp?.vendor_id,
vendor_name: emp?.vendor_name,
document_type: docType,
...existingDoc,
});
setShowUploadModal(true);
};
const renderCell = (doc, employeeId, docType) => {
const status = doc?.status || "missing";
const config = STATUS_CONFIG[status];
const daysUntilExpiry = doc?.expiry_date ? differenceInDays(parseISO(doc.expiry_date), new Date()) : null;
const isExpirableDoc = docType === "Work Permit" || docType === "ID Copy";
return (
<button
onClick={() => handleCellClick(employeeId, docType, doc)}
className={`w-full h-14 rounded-lg flex flex-col items-center justify-center transition-all hover:scale-105 hover:shadow-md ${config.color} relative group`}
>
{status === "missing" ? (
<Plus className="w-5 h-5 text-slate-400" />
) : status === "uploaded" ? (
<>
<div className="flex items-center gap-1">
<FileText className="w-4 h-4 text-white" />
<span className="text-[9px] text-white font-bold bg-cyan-600 px-1 rounded"></span>
</div>
{isExpirableDoc && doc?.expiry_date && (
<span className="text-[9px] text-white/90 mt-0.5 font-medium">
{format(parseISO(doc.expiry_date), 'MM/dd/yy')}
</span>
)}
</>
) : status === "expiring" ? (
<>
<Clock className="w-4 h-4 text-amber-700" />
<span className="text-[9px] text-amber-800 mt-0.5 font-bold">
{daysUntilExpiry}d left
</span>
</>
) : status === "expired" ? (
<>
<XCircle className="w-4 h-4 text-white" />
<span className="text-[9px] text-white/90 mt-0.5 font-medium">Expired</span>
</>
) : status === "pending" ? (
<Clock className="w-5 h-5 text-amber-700" />
) : (
<config.icon className="w-5 h-5" />
)}
{/* Hover tooltip for expirable docs */}
{isExpirableDoc && doc?.expiry_date && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-slate-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
Expires: {format(parseISO(doc.expiry_date), 'MMM d, yyyy')}
</div>
)}
</button>
);
};
// Calculate column completion percentages
const columnStats = useMemo(() => {
const stats = {};
DOCUMENT_TYPES.forEach(type => {
const total = Object.keys(documentMatrix).length;
const uploaded = Object.values(documentMatrix).filter(
row => row.documents[type]?.status === "uploaded"
).length;
stats[type] = total > 0 ? Math.round((uploaded / total) * 100) : 0;
});
return stats;
}, [documentMatrix]);
// Filter matrix by status and doc type
const filteredMatrix = useMemo(() => {
let result = { ...documentMatrix };
// Filter by status
if (statusFilter !== "all") {
result = Object.fromEntries(
Object.entries(result).filter(([empId, row]) => {
const docs = Object.values(row.documents);
if (statusFilter === "uploaded") return docs.some(d => d?.status === "uploaded");
if (statusFilter === "expiring") return docs.some(d => d?.status === "expiring");
if (statusFilter === "expired") return docs.some(d => d?.status === "expired");
if (statusFilter === "missing") return docs.some(d => !d);
if (statusFilter === "pending") return docs.some(d => d?.status === "pending");
return true;
})
);
}
return result;
}, [documentMatrix, statusFilter, docTypeFilter]);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-100 via-blue-50 to-slate-100 p-4 md:p-6">
<div className="max-w-[1800px] mx-auto">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
<FileText className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Employee Documents</h1>
<p className="text-sm text-slate-500">Track and manage all required documents</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{filteredStaff.slice(0, 3).map((emp, i) => (
<Avatar key={emp.id} className="w-8 h-8 border-2 border-white">
<AvatarFallback className="bg-gradient-to-br from-blue-400 to-purple-500 text-white text-xs">
{emp.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
))}
{filteredStaff.length > 3 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-bold text-slate-600">
+{filteredStaff.length - 3}
</div>
)}
</div>
<Button
variant="outline"
className={`gap-2 ${showActivityPanel ? 'bg-orange-100 border-orange-300 text-orange-700' : ''}`}
onClick={() => setShowActivityPanel(!showActivityPanel)}
>
<Activity className="w-4 h-4" />
Activity
</Button>
</div>
</div>
{/* Stats Bar - Clickable */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 mb-6">
<Card
className={`border-0 shadow-sm bg-white cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'all' ? 'ring-2 ring-slate-400' : ''}`}
onClick={() => setStatusFilter('all')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-slate-100 rounded-lg flex items-center justify-center">
<Users className="w-4 h-4 text-slate-600" />
</div>
<div>
<p className="text-lg font-bold text-slate-900">{filteredStaff.length}</p>
<p className="text-[10px] text-slate-500">Employees</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`border-0 shadow-sm bg-cyan-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'uploaded' ? 'ring-2 ring-cyan-400' : ''}`}
onClick={() => setStatusFilter('uploaded')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-cyan-400 rounded-lg flex items-center justify-center">
<CheckCircle2 className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-lg font-bold text-cyan-700">{stats.uploaded}</p>
<p className="text-[10px] text-cyan-600">Uploaded</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`border-0 shadow-sm bg-amber-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'pending' ? 'ring-2 ring-amber-400' : ''}`}
onClick={() => setStatusFilter('pending')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-amber-400 rounded-lg flex items-center justify-center">
<Clock className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-lg font-bold text-amber-700">{stats.pending}</p>
<p className="text-[10px] text-amber-600">Pending</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`border-0 shadow-sm bg-yellow-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'expiring' ? 'ring-2 ring-yellow-400' : ''}`}
onClick={() => setStatusFilter('expiring')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-yellow-400 rounded-lg flex items-center justify-center">
<AlertTriangle className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-lg font-bold text-yellow-700">{stats.expiring}</p>
<p className="text-[10px] text-yellow-600">Expiring</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`border-0 shadow-sm bg-red-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'expired' ? 'ring-2 ring-red-400' : ''}`}
onClick={() => setStatusFilter('expired')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-red-400 rounded-lg flex items-center justify-center">
<XCircle className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-lg font-bold text-red-700">{stats.expired}</p>
<p className="text-[10px] text-red-600">Expired</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`border-0 shadow-sm bg-slate-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'missing' ? 'ring-2 ring-slate-400' : ''}`}
onClick={() => setStatusFilter('missing')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-slate-300 rounded-lg flex items-center justify-center">
<Plus className="w-4 h-4 text-slate-600" />
</div>
<div>
<p className="text-lg font-bold text-slate-700">{stats.missing}</p>
<p className="text-[10px] text-slate-500">Missing</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Main Document Matrix */}
<Card className="border-0 shadow-xl rounded-2xl overflow-hidden">
<CardContent className="p-0">
{/* Search & Filter Bar */}
<div className="p-4 border-b border-slate-100 bg-white flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search employees..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-slate-50 border-0 h-10"
/>
</div>
{/* Document Type Filter */}
<Select value={docTypeFilter} onValueChange={setDocTypeFilter}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Doc Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Documents</SelectItem>
{DOCUMENT_TYPES.map(type => (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">
<FileText className="w-3 h-3 text-blue-500" />
{type}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status Filter */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px] h-10">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="uploaded">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-cyan-400" />
Uploaded
</div>
</SelectItem>
<SelectItem value="expiring">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-400" />
Expiring Soon
</div>
</SelectItem>
<SelectItem value="expired">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-400" />
Expired
</div>
</SelectItem>
<SelectItem value="pending">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-amber-400" />
Pending
</div>
</SelectItem>
<SelectItem value="missing">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-300" />
Missing
</div>
</SelectItem>
</SelectContent>
</Select>
{(statusFilter !== "all" || docTypeFilter !== "all") && (
<Button
variant="ghost"
size="sm"
onClick={() => { setStatusFilter("all"); setDocTypeFilter("all"); }}
className="text-slate-500 hover:text-slate-700"
>
Clear filters
</Button>
)}
</div>
{/* Matrix Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50">
<th className="text-left p-4 font-semibold text-slate-700 min-w-[200px] sticky left-0 bg-slate-50 z-10">
Users
</th>
{DOCUMENT_TYPES.map(type => (
<th key={type} className="p-4 min-w-[130px]">
<div className="text-center">
<p className="font-semibold text-slate-700 text-sm mb-2">{type}</p>
<div className="flex gap-0.5 justify-center">
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 25 ? 'bg-cyan-400' : 'bg-slate-200'}`} />
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 50 ? 'bg-amber-400' : 'bg-slate-200'}`} />
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 75 ? 'bg-red-400' : 'bg-slate-200'}`} />
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 100 ? 'bg-green-400' : 'bg-slate-200'}`} />
</div>
</div>
</th>
))}
<th className="p-4 w-12">
<button className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 transition-colors">
<Plus className="w-4 h-4" />
</button>
</th>
</tr>
</thead>
<tbody>
{Object.entries(filteredMatrix).map(([empId, row], idx) => (
<tr key={empId} className={`border-t border-slate-100 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
<td className="p-4 sticky left-0 bg-inherit z-10">
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10 border-2 border-white shadow">
<AvatarFallback className="bg-gradient-to-br from-blue-400 to-purple-500 text-white font-bold">
{row.employee.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 truncate">{row.employee.employee_name}</p>
<div className="flex items-center gap-2">
<Progress value={row.completionRate} className="h-1.5 w-16" />
<span className="text-xs text-slate-500">{row.completionRate}%</span>
</div>
</div>
</div>
</td>
{DOCUMENT_TYPES.map(type => (
<td key={type} className={`p-2 ${docTypeFilter !== "all" && docTypeFilter !== type ? 'opacity-30' : ''}`}>
{renderCell(row.documents[type], empId, type)}
</td>
))}
<td className="p-2">
{row.completionRate === 100 && (
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle2 className="w-5 h-5 text-green-600" />
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{filteredStaff.length === 0 && (
<div className="p-12 text-center">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-200" />
<p className="text-slate-500">No employees found</p>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-100 bg-slate-50 text-center">
<p className="text-sm text-slate-500">
to make sure everything is always up to date
</p>
</div>
</CardContent>
</Card>
{/* Upload Modal */}
<Dialog open={showUploadModal} onOpenChange={setShowUploadModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
{selectedCell?.id ? 'Update' : 'Upload'} Document
</DialogTitle>
</DialogHeader>
{selectedCell && (
<DocumentUploadForm
data={selectedCell}
onSave={(data) => saveMutation.mutate(data)}
onCancel={() => { setShowUploadModal(false); setSelectedCell(null); }}
isLoading={saveMutation.isPending}
/>
)}
</DialogContent>
</Dialog>
{/* Activity Panel */}
{showActivityPanel && (
<div className="fixed right-0 top-0 h-full w-80 bg-white shadow-2xl border-l border-slate-200 z-50 p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="font-bold text-lg">Recent Activity</h3>
<Button variant="ghost" size="sm" onClick={() => setShowActivityPanel(false)}>
<XCircle className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
{[1,2,3,4,5].map(i => (
<div key={i} className="flex items-start gap-3 p-3 bg-slate-50 rounded-lg">
<div className="w-8 h-8 rounded-full bg-cyan-400 flex items-center justify-center">
<Upload className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-sm font-medium text-slate-900">Document uploaded</p>
<p className="text-xs text-slate-500">W-4 Form 2 hours ago</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
function DocumentUploadForm({ data, onSave, onCancel, isLoading }) {
const [formData, setFormData] = useState({
employee_id: data.employee_id || "",
employee_name: data.employee_name || "",
vendor_id: data.vendor_id || "",
vendor_name: data.vendor_name || "",
document_type: data.document_type || "",
status: data.status || "uploaded",
expiry_date: data.expiry_date || "",
document_url: data.document_url || "",
notes: data.notes || "",
...data,
});
const [uploading, setUploading] = useState(false);
const handleFileUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const result = await base44.integrations.Core.UploadFile({ file });
setFormData(prev => ({ ...prev, document_url: result.file_url, status: "uploaded" }));
} catch (error) {
console.error("Upload failed:", error);
}
setUploading(false);
};
return (
<div className="space-y-4">
<div className="p-4 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">Employee</p>
<p className="font-semibold text-slate-900">{formData.employee_name}</p>
<p className="text-sm text-blue-600 mt-1">{formData.document_type}</p>
</div>
<div>
<Label className="text-sm font-medium">Upload Document</Label>
<div className="mt-2 border-2 border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue-400 transition-colors">
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={handleFileUpload}
className="hidden"
id="doc-upload"
/>
<label htmlFor="doc-upload" className="cursor-pointer">
{uploading ? (
<div className="animate-pulse">Uploading...</div>
) : formData.document_url ? (
<div className="flex items-center justify-center gap-2 text-green-600">
<CheckCircle2 className="w-6 h-6" />
<span>Document uploaded</span>
</div>
) : (
<div>
<Upload className="w-8 h-8 mx-auto text-slate-400 mb-2" />
<p className="text-sm text-slate-600">Click to upload</p>
<p className="text-xs text-slate-400">PDF, JPG, PNG</p>
</div>
)}
</label>
</div>
</div>
<div>
<Label className="text-sm font-medium">Expiry Date (if applicable)</Label>
<Input
type="date"
value={formData.expiry_date}
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))}
className="mt-1.5"
/>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button
onClick={() => onSave(formData)}
disabled={isLoading}
className="bg-blue-600 hover:bg-blue-700"
>
{isLoading ? "Saving..." : "Save Document"}
</Button>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ import PageHeader from "@/components/common/PageHeader";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import AutoInvoiceGenerator from "@/components/invoices/AutoInvoiceGenerator";
import CreateInvoiceModal from "@/components/invoices/CreateInvoiceModal";
const statusColors = {
'Draft': 'bg-slate-100 text-slate-600 font-medium',
@@ -34,7 +34,7 @@ export default function Invoices() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const { data: user } = useQuery({
queryKey: ['current-user-invoices'],
@@ -212,335 +212,327 @@ export default function Invoices() {
<>
<AutoInvoiceGenerator />
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1600px] mx-auto">
<PageHeader
title="Invoices"
subtitle={`${filteredInvoices.length} invoices • $${metrics.all.toLocaleString()} total`}
actions={
userRole === "vendor" && (
<Button onClick={() => setShowCreateModal(true)} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-5 h-5 mr-2" />
Create Invoice
</Button>
)
}
/>
<div className="p-4 md:p-6 bg-slate-100 min-h-screen">
<div className="max-w-[1800px] mx-auto">
<div className="flex gap-6">
{/* Left Sidebar - Summary */}
<div className="hidden lg:block w-72 flex-shrink-0 space-y-4">
{/* Logo Card */}
<Card className="border-0 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white overflow-hidden">
<CardContent className="p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5" />
</div>
<div>
<h2 className="font-bold">Invoices</h2>
<p className="text-xs text-white/70">{visibleInvoices.length} total</p>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-white/70">Total Value</span>
<span className="font-bold text-lg">${metrics.all.toLocaleString()}</span>
</div>
<div className="h-2 bg-white/20 rounded-full overflow-hidden">
<div className="h-full bg-emerald-400 rounded-full" style={{ width: `${(metrics.paid / metrics.all) * 100 || 0}%` }} />
</div>
<p className="text-xs text-white/60">{((metrics.paid / metrics.all) * 100 || 0).toFixed(0)}% collected</p>
</div>
{userRole === "vendor" && (
<Button onClick={() => navigate(createPageUrl("InvoiceEditor"))} size="sm" className="w-full mt-4 bg-white text-[#0A39DF] hover:bg-white/90">
<Plus className="w-4 h-4 mr-1" /> New Invoice
</Button>
)}
</CardContent>
</Card>
{/* Alert Banners */}
{metrics.disputed > 0 && (
<div className="mb-6 p-4 bg-red-50 border-l-4 border-red-500 rounded-lg flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-600" />
<div>
<p className="font-semibold text-red-900">Disputed Invoices Require Attention</p>
<p className="text-sm text-red-700">{getStatusCount("Disputed")} invoices are currently disputed</p>
</div>
{/* Status Breakdown */}
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<h3 className="font-semibold text-slate-900 mb-3 text-sm">Status Breakdown</h3>
<div className="space-y-2">
{[
{ label: "Pending", status: "Pending Review", color: "bg-blue-500", value: getStatusCount("Pending Review"), amount: getTotalAmount("Pending Review") },
{ label: "Approved", status: "Approved", color: "bg-emerald-500", value: getStatusCount("Approved"), amount: getTotalAmount("Approved") },
{ label: "Disputed", status: "Disputed", color: "bg-red-500", value: getStatusCount("Disputed"), amount: getTotalAmount("Disputed") },
{ label: "Overdue", status: "Overdue", color: "bg-amber-500", value: getStatusCount("Overdue"), amount: getTotalAmount("Overdue") },
{ label: "Paid", status: "Paid", color: "bg-green-500", value: getStatusCount("Paid"), amount: getTotalAmount("Paid") },
{ label: "Reconciled", status: "Reconciled", color: "bg-purple-500", value: getStatusCount("Reconciled"), amount: getTotalAmount("Reconciled") },
].map(item => (
<button
key={item.status}
onClick={() => setActiveTab(item.label.toLowerCase())}
className={`w-full flex items-center gap-3 p-2 rounded-lg transition-all hover:bg-slate-50 ${activeTab === item.label.toLowerCase() ? 'bg-slate-100' : ''}`}
>
<div className={`w-2 h-2 rounded-full ${item.color}`} />
<span className="text-sm text-slate-700 flex-1 text-left">{item.label}</span>
<span className="text-xs text-slate-500 mr-1">${item.amount.toLocaleString()}</span>
<Badge variant="outline" className="text-xs">{item.value}</Badge>
</button>
))}
</div>
</CardContent>
</Card>
{/* Sales & Revenue - Vendor Focused */}
<Card className="border-0 shadow-sm bg-emerald-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-4 h-4 text-emerald-600" />
<h3 className="font-semibold text-emerald-900 text-sm">Sales & Revenue</h3>
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-emerald-700">Total Sales</span>
<span className="font-semibold text-emerald-900">${metrics.all.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-emerald-700">Collected</span>
<span className="font-semibold text-emerald-900">${metrics.paid.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-emerald-700">Pending Revenue</span>
<span className="font-semibold text-emerald-900">${metrics.outstanding.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-emerald-700">Collection Rate</span>
<span className="font-semibold text-emerald-900">{((metrics.paid / metrics.all) * 100 || 0).toFixed(0)}%</span>
</div>
<div className="flex justify-between">
<span className="text-emerald-700">Avg. Invoice</span>
<span className="font-semibold text-emerald-900">${visibleInvoices.length > 0 ? Math.round(metrics.all / visibleInvoices.length).toLocaleString() : 0}</span>
</div>
</div>
</CardContent>
</Card>
{/* Quick Insights */}
<Card className="border-0 shadow-sm bg-amber-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-4 h-4 text-amber-600" />
<h3 className="font-semibold text-amber-900 text-sm">Performance</h3>
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-amber-700">Avg. Payment</span>
<span className="font-semibold text-amber-900">{insights.avgDays} days</span>
</div>
<div className="flex justify-between">
<span className="text-amber-700">On-Time Rate</span>
<span className="font-semibold text-amber-900">{insights.onTimeRate}%</span>
</div>
<div className="flex justify-between">
<span className="text-amber-700">This Month</span>
<span className="font-semibold text-amber-900">${insights.currentTotal.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-amber-700">Monthly Count</span>
<span className="font-semibold text-amber-900">{insights.currentMonthCount} invoices</span>
</div>
<div className="flex justify-between">
<span className="text-amber-700">MoM Change</span>
<span className={`font-semibold ${insights.isGrowth ? 'text-emerald-700' : 'text-red-700'}`}>
{insights.isGrowth ? '+' : ''}{insights.percentChange}%
</span>
</div>
{insights.topClient && (
<div className="flex justify-between">
<span className="text-amber-700">Top Client</span>
<span className="font-semibold text-amber-900 truncate max-w-[100px]" title={insights.topClient.name}>
{insights.topClient.name}
</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Alerts */}
{(metrics.disputed > 0 || metrics.overdue > 0) && (
<Card className="border-0 shadow-sm border-l-4 border-l-red-500 bg-red-50">
<CardContent className="p-4">
<h3 className="font-semibold text-red-900 text-sm mb-2">Requires Attention</h3>
{metrics.disputed > 0 && (
<p className="text-xs text-red-700 mb-1"> {getStatusCount("Disputed")} disputed (${metrics.disputed.toLocaleString()})</p>
)}
{metrics.overdue > 0 && (
<p className="text-xs text-red-700"> {getStatusCount("Overdue")} overdue (${metrics.overdue.toLocaleString()})</p>
)}
</CardContent>
</Card>
)}
</div>
)}
{metrics.overdue > 0 && userRole === "client" && (
<div className="mb-6 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-lg flex items-center gap-3">
<Clock className="w-5 h-5 text-amber-600" />
<div>
<p className="font-semibold text-amber-900">Overdue Payments</p>
<p className="text-sm text-amber-700">${metrics.overdue.toLocaleString()} in overdue invoices</p>
</div>
</div>
)}
{/* Status Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-slate-100 border border-slate-200 h-auto p-1.5 flex-wrap gap-1">
<TabsTrigger
value="all"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<FileText className="w-4 h-4 mr-2" />
All
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("all")}</Badge>
</TabsTrigger>
<TabsTrigger
value="pending"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<Clock className="w-4 h-4 mr-2" />
Pending
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Pending Review")}</Badge>
</TabsTrigger>
<TabsTrigger
value="approved"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<CheckCircle className="w-4 h-4 mr-2" />
Approved
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Approved")}</Badge>
</TabsTrigger>
<TabsTrigger
value="disputed"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<AlertTriangle className="w-4 h-4 mr-2" />
Disputed
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Disputed")}</Badge>
</TabsTrigger>
<TabsTrigger
value="overdue"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<AlertTriangle className="w-4 h-4 mr-2" />
Overdue
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Overdue")}</Badge>
</TabsTrigger>
<TabsTrigger
value="paid"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<CheckCircle className="w-4 h-4 mr-2" />
Paid
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Paid")}</Badge>
</TabsTrigger>
<TabsTrigger
value="reconciled"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<CheckCircle className="w-4 h-4 mr-2" />
Reconciled
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Reconciled")}</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
{/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border-0 bg-blue-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center flex-shrink-0">
<FileText className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-xs text-blue-600 uppercase tracking-wider font-semibold mb-0.5">Total Value</p>
<p className="text-2xl font-bold text-blue-700">${metrics.all.toLocaleString()}</p>
</div>
{/* Main Content */}
<div className="flex-1 min-w-0">
{/* Top Bar */}
<div className="bg-white rounded-xl shadow-sm mb-4 p-3 flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search by invoice #, client, event..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-10 bg-slate-50 border-0"
/>
</div>
</CardContent>
</Card>
<Card className="border-0 bg-amber-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center flex-shrink-0">
<DollarSign className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-xs text-amber-600 uppercase tracking-wider font-semibold mb-0.5">Outstanding</p>
<p className="text-2xl font-bold text-amber-700">${metrics.outstanding.toLocaleString()}</p>
</div>
<div className="lg:hidden">
{userRole === "vendor" && (
<Button onClick={() => navigate(createPageUrl("InvoiceEditor"))} size="sm" className="bg-[#0A39DF]">
<Plus className="w-4 h-4" />
</Button>
)}
</div>
</CardContent>
</Card>
<Card className="border-0 bg-red-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-red-500 rounded-xl flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-xs text-red-600 uppercase tracking-wider font-semibold mb-0.5">Disputed</p>
<p className="text-2xl font-bold text-red-700">${metrics.disputed.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 bg-emerald-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-emerald-500 rounded-xl flex items-center justify-center flex-shrink-0">
<CheckCircle className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-xs text-emerald-600 uppercase tracking-wider font-semibold mb-0.5">Paid</p>
<p className="text-2xl font-bold text-emerald-700">${metrics.paid.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Smart Insights Banner */}
<div className="mb-6 bg-slate-100 rounded-2xl p-6 shadow-sm border border-slate-200">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-amber-500 rounded-xl flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">Smart Insights</h3>
<p className="text-sm text-slate-500">AI-powered analysis of your invoice performance</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">This Month</span>
<div className={`flex items-center gap-1 ${insights.isGrowth ? 'text-emerald-600' : 'text-red-600'}`}>
{insights.isGrowth ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
<span className="text-xs font-bold">{insights.percentChange}%</span>
</div>
</div>
<p className="text-2xl font-bold text-slate-900">${insights.currentTotal.toLocaleString()}</p>
<p className="text-xs text-slate-400 mt-1">{insights.currentMonthCount} invoices</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">Avg. Payment Time</span>
<Calendar className="w-4 h-4 text-slate-400" />
</div>
<p className="text-2xl font-bold text-slate-900">{insights.avgDays} days</p>
<p className="text-xs text-slate-400 mt-1">From issue to payment</p>
{/* Stats Row - Mobile */}
<div className="lg:hidden grid grid-cols-4 gap-2 mb-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-3 text-center">
<p className="text-lg font-bold text-slate-900">${(metrics.all / 1000).toFixed(0)}K</p>
<p className="text-[10px] text-slate-500">Total</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-3 text-center">
<p className="text-lg font-bold text-amber-600">${(metrics.outstanding / 1000).toFixed(0)}K</p>
<p className="text-[10px] text-slate-500">Pending</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-3 text-center">
<p className="text-lg font-bold text-red-600">{getStatusCount("Disputed")}</p>
<p className="text-[10px] text-slate-500">Disputed</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-3 text-center">
<p className="text-lg font-bold text-emerald-600">${(metrics.paid / 1000).toFixed(0)}K</p>
<p className="text-[10px] text-slate-500">Paid</p>
</CardContent>
</Card>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">On-Time Rate</span>
<CheckCircle className="w-4 h-4 text-slate-400" />
</div>
<p className="text-2xl font-bold text-slate-900">{insights.onTimeRate}%</p>
<p className="text-xs text-slate-400 mt-1">Paid before due date</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">
{userRole === "client" ? "Best Hub" : "Top Client"}
</span>
<ArrowUpRight className="w-4 h-4 text-slate-400" />
</div>
{userRole === "client" ? (
<>
<p className="text-lg font-bold text-slate-900 truncate">{insights.bestHub?.hub || "—"}</p>
<p className="text-xs text-slate-400 mt-1">{insights.bestHub?.rate || 0}% on-time</p>
</>
) : (
<>
<p className="text-lg font-bold text-slate-900 truncate">{insights.topClient?.name || "—"}</p>
<p className="text-xs text-slate-400 mt-1">${insights.topClient?.amount.toLocaleString() || 0}</p>
</>
)}
</div>
</div>
</div>
{/* Search */}
<div className="bg-white rounded-lg p-4 mb-6 border border-slate-200">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search by invoice number, client, event..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Invoices Table */}
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Invoice #</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Hub</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Event</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Manager</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Date & Time</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Amount</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Status</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredInvoices.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No invoices found</p>
</TableCell>
</TableRow>
) : (
filteredInvoices.map((invoice) => {
const invoiceDate = parseISO(invoice.issue_date);
const dayOfWeek = format(invoiceDate, 'EEEE');
const dateFormatted = format(invoiceDate, 'MM.dd.yy');
return (
<TableRow key={invoice.id} className="hover:bg-slate-50 transition-all border-b border-slate-100">
<TableCell className="font-bold text-slate-900">{invoice.invoice_number}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-purple-600" />
<span className="text-slate-900 font-medium">{invoice.hub || "—"}</span>
</div>
</TableCell>
<TableCell className="text-slate-900 font-medium">{invoice.event_name}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-slate-400" />
<span className="text-slate-700">{invoice.manager_name || invoice.created_by || "—"}</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-0.5">
<div className="text-slate-900 font-medium">{dateFormatted}</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Clock className="w-3 h-3" />
<span>{dayOfWeek}</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
<DollarSign className="w-3 h-3 text-white" />
</div>
<span className="font-bold text-slate-900">${invoice.amount?.toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<Badge className={`${statusColors[invoice.status]} px-3 py-1 rounded-md text-xs`}>
{invoice.status}
</Badge>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))}
className="font-semibold hover:bg-blue-50 hover:text-[#0A39DF]"
>
<Eye className="w-4 h-4 mr-2" />
View
</Button>
{/* Invoice Table */}
<Card className="border-0 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b border-slate-200">
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide py-4">Invoice</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide">Client</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide">Event / Hub</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide">Date</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide text-right">Amount</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide text-center">Status</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide text-center">Due</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredInvoices.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-16">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-200" />
<p className="font-medium text-slate-500">No invoices found</p>
<p className="text-sm text-slate-400 mt-1">Try adjusting your search or filters</p>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
filteredInvoices.map((invoice) => {
const invoiceDate = invoice.issue_date ? parseISO(invoice.issue_date) : new Date();
const dateFormatted = format(invoiceDate, 'MMM d, yyyy');
const dueDate = invoice.due_date ? format(parseISO(invoice.due_date), 'MMM d') : '—';
const isOverdue = invoice.due_date && isPast(parseISO(invoice.due_date)) && invoice.status !== "Paid" && invoice.status !== "Reconciled";
return (
<TableRow
key={invoice.id}
className={`hover:bg-blue-50/50 cursor-pointer transition-all border-b border-slate-100 ${isOverdue ? 'bg-red-50/30' : ''}`}
onClick={() => navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))}
>
<TableCell className="py-4">
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
invoice.status === 'Paid' || invoice.status === 'Reconciled' ? 'bg-emerald-100' :
invoice.status === 'Disputed' ? 'bg-red-100' :
invoice.status === 'Overdue' ? 'bg-amber-100' : 'bg-blue-100'
}`}>
<FileText className={`w-4 h-4 ${
invoice.status === 'Paid' || invoice.status === 'Reconciled' ? 'text-emerald-600' :
invoice.status === 'Disputed' ? 'text-red-600' :
invoice.status === 'Overdue' ? 'text-amber-600' : 'text-blue-600'
}`} />
</div>
<div>
<p className="font-bold text-slate-900">{invoice.invoice_number}</p>
<p className="text-xs text-slate-500">{invoice.manager_name || '—'}</p>
</div>
</div>
</TableCell>
<TableCell>
<p className="font-medium text-slate-900 truncate max-w-[150px]">{invoice.business_name || '—'}</p>
</TableCell>
<TableCell>
<p className="font-medium text-slate-900 truncate max-w-[200px]">{invoice.event_name || 'Untitled'}</p>
<div className="flex items-center gap-1 mt-0.5">
<MapPin className="w-3 h-3 text-slate-400" />
<span className="text-xs text-slate-500">{invoice.hub || '—'}</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-700">{dateFormatted}</span>
</TableCell>
<TableCell className="text-right">
<span className="font-bold text-slate-900 text-lg">${invoice.amount?.toLocaleString() || 0}</span>
</TableCell>
<TableCell className="text-center">
<Badge className={`${statusColors[invoice.status]} px-2.5 py-1`}>
{invoice.status}
</Badge>
</TableCell>
<TableCell className="text-center">
<span className={`text-sm font-medium ${isOverdue ? 'text-red-600' : 'text-slate-600'}`}>
{dueDate}
</span>
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="ghost" className="hover:bg-blue-100 hover:text-blue-700">
<Eye className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" className="hover:bg-slate-100">
<Edit className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Table Footer */}
{filteredInvoices.length > 0 && (
<div className="p-4 border-t border-slate-100 bg-slate-50 flex items-center justify-between">
<p className="text-sm text-slate-500">
Showing <span className="font-medium text-slate-700">{filteredInvoices.length}</span> of <span className="font-medium text-slate-700">{visibleInvoices.length}</span> invoices
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">Total: </span>
<span className="font-bold text-slate-900">${filteredInvoices.reduce((sum, inv) => sum + (inv.amount || 0), 0).toLocaleString()}</span>
</div>
</div>
)}
</Card>
</div>
</div>
</div>
</div>
<CreateInvoiceModal
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
/>
</>
);
}

View File

@@ -4,14 +4,12 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { auth } from "@/firebase";
import { signOut } from "firebase/auth";
import {
Users, LayoutDashboard, UserPlus, Calendar, Briefcase, FileText,
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft, FolderOpen
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -36,119 +34,206 @@ import NotificationPanel from "@/components/notifications/NotificationPanel";
import { NotificationEngine } from "@/components/notifications/NotificationEngine";
import { Toaster } from "@/components/ui/toaster";
// Navigation items for each role
// Navigation items for each role - organized by categories
const roleNavigationMap = {
admin: [
{ title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "User Management", url: createPageUrl("UserManagement"), icon: Users },
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
{ title: "Settings", url: createPageUrl("Settings"), icon: SettingsIcon },
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Operations", items: [
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Management", items: [
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
]},
{ category: "Workforce", items: [
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
]},
{ category: "Finance", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
]},
{ category: "Analytics", items: [
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
]},
{ category: "Communication", items: [
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
]},
{ category: "Settings", items: [
{ title: "User Management", url: createPageUrl("UserManagement"), icon: Users },
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
{ title: "Settings", url: createPageUrl("Settings"), icon: SettingsIcon },
]},
],
procurement: [
{ title: "Home", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Operations", items: [
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Network", items: [
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
]},
{ category: "Compliance", items: [
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
]},
{ category: "Finance", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
]},
{ category: "Analytics", items: [
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
],
operator: [
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Operations", items: [
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Network", items: [
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
]},
{ category: "Workforce", items: [
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
]},
{ category: "Analytics", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
],
sector: [
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Operations", items: [
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Network", items: [
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
]},
{ category: "Workforce", items: [
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
]},
{ category: "Analytics", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
],
client: [
{ title: "Home", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Orders", items: [
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Marketplace", items: [
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
]},
{ category: "Team", items: [
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
{ category: "Analytics", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
]},
{ category: "Support", items: [
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
]},
],
vendor: [
{ title: "Home", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
{ title: "Business", url: createPageUrl("Business"), icon: Briefcase },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
]},
{ category: "Workforce", items: [
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
{ title: "Employee Documents", url: createPageUrl("EmployeeDocuments"), icon: FolderOpen },
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
]},
{ category: "Operations", items: [
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
{ title: "Create Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Business", items: [
{ title: "Clients", url: createPageUrl("Business"), icon: Briefcase },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Finance", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
]},
{ category: "Analytics", items: [
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
],
workforce: [
{ title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Shift Requests", url: createPageUrl("WorkerShiftProposals"), icon: Calendar },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Profile", url: createPageUrl("WorkforceProfile"), icon: Users },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Profile", url: createPageUrl("WorkforceProfile"), icon: Users },
]},
{ category: "Work", items: [
{ title: "Shift Requests", url: createPageUrl("WorkerShiftProposals"), icon: Calendar },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Team", items: [
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
{ category: "Growth", items: [
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
]},
],
};
@@ -218,31 +303,37 @@ const getLayerColor = (role) => {
};
function NavigationMenu({ location, userRole, closeSheet }) {
const navigationItems = roleNavigationMap[userRole] || roleNavigationMap.admin;
const navigationCategories = roleNavigationMap[userRole] || roleNavigationMap.admin;
return (
<nav className="space-y-1">
<div className="px-4 py-2 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Main Menu
</div>
{navigationItems.map((item) => {
const isActive = location.pathname === item.url;
return (
<Link
key={item.title}
to={item.url}
onClick={closeSheet}
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-all duration-200 ${
isActive
? 'bg-[#0A39DF] text-white shadow-md font-medium'
: 'text-slate-600 hover:bg-slate-100 hover:text-[#1C323E]'
}`}
>
<item.icon className="w-5 h-5" />
<span className="text-sm">{item.title}</span>
</Link>
);
})}
<nav className="space-y-4">
{navigationCategories.map((categoryGroup) => (
<div key={categoryGroup.category}>
<div className="px-4 py-1.5 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{categoryGroup.category}
</div>
<div className="space-y-0.5">
{categoryGroup.items.map((item) => {
const isActive = location.pathname === item.url;
return (
<Link
key={item.title}
to={item.url}
onClick={closeSheet}
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-all duration-200 ${
isActive
? 'bg-[#0A39DF] text-white shadow-md font-medium'
: 'text-slate-600 hover:bg-slate-100 hover:text-[#1C323E]'
}`}
>
<item.icon className="w-4 h-4" />
<span className="text-sm">{item.title}</span>
</Link>
);
})}
</div>
</div>
))}
</nav>
);
}
@@ -281,7 +372,7 @@ export default function Layout({ children }) {
const userInitial = userName.charAt(0).toUpperCase();
const handleLogout = () => {
signOut(auth);
base44.auth.logout();
};
const handleRefresh = () => {
@@ -484,10 +575,6 @@ export default function Layout({ children }) {
<User className="w-4 h-4 mr-2" />My Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600 focus:text-red-600">
<LogOut className="w-4 h-4 mr-2" />
<span>Logout</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -4,18 +4,30 @@ import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Download, FileText, TrendingUp, Users, DollarSign, Zap } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Download, FileText, TrendingUp, Users, DollarSign, Zap, Calendar, Library, Clock, Coffee, Sparkles } from "lucide-react";
import StaffingCostReport from "@/components/reports/StaffingCostReport";
import StaffPerformanceReport from "@/components/reports/StaffPerformanceReport";
import ClientTrendsReport from "@/components/reports/ClientTrendsReport";
import OperationalEfficiencyReport from "@/components/reports/OperationalEfficiencyReport";
import CustomReportBuilder from "@/components/reports/CustomReportBuilder";
import ReportTemplateLibrary from "@/components/reports/ReportTemplateLibrary";
import ScheduledReports from "@/components/reports/ScheduledReports";
import ReportExporter from "@/components/reports/ReportExporter";
import { useToast } from "@/components/ui/use-toast";
export default function Reports() {
const [activeTab, setActiveTab] = useState("costs");
const [activeTab, setActiveTab] = useState("templates");
const [showExporter, setShowExporter] = useState(false);
const [exportReportName, setExportReportName] = useState("");
const [exportData, setExportData] = useState(null);
const { toast } = useToast();
const { data: user, refetch: refetchUser } = useQuery({
queryKey: ['current-user-reports'],
queryFn: () => base44.auth.me(),
});
const { data: events = [] } = useQuery({
queryKey: ['events-reports'],
queryFn: () => base44.entities.Event.list(),
@@ -34,6 +46,35 @@ export default function Reports() {
initialData: [],
});
const { data: vendors = [] } = useQuery({
queryKey: ['vendors-reports'],
queryFn: () => base44.entities.Vendor.list(),
initialData: [],
});
const userRole = user?.user_role || user?.role || 'admin';
const scheduledReports = user?.scheduled_reports || [];
const handleSelectTemplate = (template) => {
setExportReportName(template.name);
setExportData({
template: template.id,
events,
staff,
invoices,
vendors,
generated: new Date().toISOString()
});
setShowExporter(true);
};
const handlePreviewTemplate = (template) => {
toast({
title: `Preview: ${template.name}`,
description: template.description
});
};
const handleExportAll = () => {
const data = {
events,
@@ -57,126 +98,177 @@ export default function Reports() {
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Reports & Analytics</h1>
<p className="text-sm text-slate-500 mt-1">
Comprehensive insights into staffing, costs, and performance
</p>
<div className="max-w-[1600px] mx-auto">
{/* Hero Header - Compact */}
<div className="bg-gradient-to-r from-[#1C323E] to-[#0A39DF] rounded-2xl p-5 mb-6 text-white">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center backdrop-blur-sm">
<FileText className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold">On-Demand Reporting Suite</h1>
<p className="text-white/70 text-sm">Intelligence that drives decisions</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-3 mr-4">
<div className="text-center px-3">
<p className="text-2xl font-bold">{events.length}</p>
<p className="text-[10px] text-white/60 uppercase">Events</p>
</div>
<div className="w-px h-8 bg-white/20" />
<div className="text-center px-3">
<p className="text-2xl font-bold">{staff.length}</p>
<p className="text-[10px] text-white/60 uppercase">Staff</p>
</div>
<div className="w-px h-8 bg-white/20" />
<div className="text-center px-3">
<p className="text-2xl font-bold">${(invoices.reduce((s, i) => s + (i.amount || 0), 0) / 1000).toFixed(0)}K</p>
<p className="text-[10px] text-white/60 uppercase">Revenue</p>
</div>
</div>
<Button onClick={handleExportAll} size="sm" className="bg-white text-[#0A39DF] hover:bg-white/90 font-semibold">
<Download className="w-4 h-4 mr-1" />
Export All
</Button>
</div>
</div>
<Button onClick={handleExportAll} className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-2" />
Export All Data
</div>
{/* Quick Actions - Horizontal Chips */}
<div className="flex items-center gap-2 mb-5 overflow-x-auto pb-1">
<span className="text-xs text-slate-500 font-medium whitespace-nowrap">Quick:</span>
<Button
size="sm"
className="bg-emerald-600 hover:bg-emerald-700 h-8 text-xs whitespace-nowrap"
onClick={() => { setExportReportName('Weekly Labor Summary'); setExportData({ events, staff, invoices }); setShowExporter(true); }}
>
<Download className="w-3 h-3 mr-1" />
Labor Summary
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs whitespace-nowrap"
onClick={() => { setExportReportName('Vendor Scorecard'); setExportData({ vendors, events }); setShowExporter(true); }}
>
<TrendingUp className="w-3 h-3 mr-1" />
Vendor Scorecard
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs whitespace-nowrap"
onClick={() => { setExportReportName('Compliance Report'); setExportData({ staff, events }); setShowExporter(true); }}
>
<Users className="w-3 h-3 mr-1" />
Compliance
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs whitespace-nowrap"
onClick={() => { setExportReportName('Invoice Aging'); setExportData({ invoices }); setShowExporter(true); }}
>
<FileText className="w-3 h-3 mr-1" />
Invoice Aging
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs whitespace-nowrap"
onClick={() => { setExportReportName('Payroll Summary'); setExportData({ staff, events, invoices }); setShowExporter(true); }}
>
<DollarSign className="w-3 h-3 mr-1" />
Payroll
</Button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border-blue-200 bg-blue-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-blue-600 font-semibold uppercase">Total Events</p>
<p className="text-2xl font-bold text-blue-700">{events.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-green-200 bg-green-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-green-600 font-semibold uppercase">Active Staff</p>
<p className="text-2xl font-bold text-green-700">{staff.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-purple-200 bg-purple-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
<DollarSign className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-purple-600 font-semibold uppercase">Total Revenue</p>
<p className="text-2xl font-bold text-purple-700">
${invoices.reduce((sum, inv) => sum + (inv.amount || 0), 0).toLocaleString()}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-amber-200 bg-amber-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-amber-600 font-semibold uppercase">Automation</p>
<p className="text-2xl font-bold text-amber-700">85%</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Report Tabs */}
{/* Report Tabs - Clean Design */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-white border">
<TabsTrigger value="costs">
<DollarSign className="w-4 h-4 mr-2" />
Staffing Costs
</TabsTrigger>
<TabsTrigger value="performance">
<TrendingUp className="w-4 h-4 mr-2" />
Staff Performance
</TabsTrigger>
<TabsTrigger value="clients">
<Users className="w-4 h-4 mr-2" />
Client Trends
</TabsTrigger>
<TabsTrigger value="efficiency">
<Zap className="w-4 h-4 mr-2" />
Operational Efficiency
</TabsTrigger>
<TabsTrigger value="custom">
<FileText className="w-4 h-4 mr-2" />
Custom Reports
</TabsTrigger>
</TabsList>
<div className="bg-white rounded-xl border shadow-sm p-1 mb-6">
<TabsList className="bg-transparent p-0 h-auto flex flex-wrap gap-1">
<TabsTrigger value="templates" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<Library className="w-4 h-4 mr-1.5" />
Templates
</TabsTrigger>
<TabsTrigger value="scheduled" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<Clock className="w-4 h-4 mr-1.5" />
Scheduled
{scheduledReports.length > 0 && (
<Badge className="ml-1.5 bg-purple-100 text-purple-700 h-5 px-1.5 text-[10px]">{scheduledReports.length}</Badge>
)}
</TabsTrigger>
<div className="w-px h-6 bg-slate-200 mx-1 self-center" />
<TabsTrigger value="costs" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<DollarSign className="w-4 h-4 mr-1.5" />
Labor Spend
</TabsTrigger>
<TabsTrigger value="performance" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<TrendingUp className="w-4 h-4 mr-1.5" />
Performance
</TabsTrigger>
<TabsTrigger value="clients" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<Users className="w-4 h-4 mr-1.5" />
Clients
</TabsTrigger>
<TabsTrigger value="efficiency" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<Zap className="w-4 h-4 mr-1.5" />
Efficiency
</TabsTrigger>
<div className="w-px h-6 bg-slate-200 mx-1 self-center" />
<TabsTrigger value="custom" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<Sparkles className="w-4 h-4 mr-1.5" />
Custom Builder
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="templates" className="mt-6">
<ReportTemplateLibrary
userRole={userRole}
onSelectTemplate={handleSelectTemplate}
onPreview={handlePreviewTemplate}
/>
</TabsContent>
<TabsContent value="scheduled" className="mt-6">
<ScheduledReports
userRole={userRole}
scheduledReports={scheduledReports}
onUpdate={refetchUser}
/>
</TabsContent>
<TabsContent value="costs" className="mt-6">
<StaffingCostReport events={events} invoices={invoices} />
<StaffingCostReport events={events} invoices={invoices} userRole={userRole} />
</TabsContent>
<TabsContent value="performance" className="mt-6">
<StaffPerformanceReport staff={staff} events={events} />
<StaffPerformanceReport staff={staff} events={events} userRole={userRole} />
</TabsContent>
<TabsContent value="clients" className="mt-6">
<ClientTrendsReport events={events} invoices={invoices} />
<ClientTrendsReport events={events} invoices={invoices} userRole={userRole} />
</TabsContent>
<TabsContent value="efficiency" className="mt-6">
<OperationalEfficiencyReport events={events} staff={staff} />
<OperationalEfficiencyReport events={events} staff={staff} userRole={userRole} />
</TabsContent>
<TabsContent value="custom" className="mt-6">
<CustomReportBuilder events={events} staff={staff} invoices={invoices} />
</TabsContent>
</Tabs>
{/* Export Modal */}
<ReportExporter
open={showExporter}
onClose={() => setShowExporter(false)}
reportName={exportReportName}
reportData={exportData}
/>
</div>
</div>
);

View File

@@ -0,0 +1,326 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import {
TrendingUp, TrendingDown, DollarSign, Users, Target, Zap,
ArrowRight, CheckCircle, AlertTriangle, BarChart3, PieChart,
Calendar, Clock, Award, Shield, Sparkles, ArrowUpRight,
Building2, Briefcase, Package, RefreshCw, Download, Filter, Wallet, Brain
} from "lucide-react";
import PageHeader from "@/components/common/PageHeader";
import SavingsOverviewCards from "@/components/savings/SavingsOverviewCards";
import ContractConversionMap from "@/components/savings/ContractConversionMap";
import PredictiveSavingsModel from "@/components/savings/PredictiveSavingsModel";
import DynamicSavingsDashboard from "@/components/savings/DynamicSavingsDashboard";
import LaborSpendAnalysis from "@/components/savings/LaborSpendAnalysis";
import VendorPerformanceMatrix from "@/components/savings/VendorPerformanceMatrix";
import BudgetUtilizationTracker from "@/components/budget/BudgetUtilizationTracker";
import SmartOperationStrategies from "@/components/vendor/SmartOperationStrategies";
export default function SavingsEngine() {
const [activeTab, setActiveTab] = useState("overview");
const [timeRange, setTimeRange] = useState("30days");
const { data: user } = useQuery({
queryKey: ['current-user-savings'],
queryFn: () => base44.auth.me(),
});
const { data: assignments = [] } = useQuery({
queryKey: ['assignments-savings'],
queryFn: () => base44.entities.Assignment.list(),
initialData: [],
});
const { data: vendors = [] } = useQuery({
queryKey: ['vendors-savings'],
queryFn: () => base44.entities.Vendor.list(),
initialData: [],
});
const { data: workforce = [] } = useQuery({
queryKey: ['workforce-savings'],
queryFn: () => base44.entities.Workforce.list(),
initialData: [],
});
const { data: orders = [] } = useQuery({
queryKey: ['orders-savings'],
queryFn: () => base44.entities.Order.list(),
initialData: [],
});
const { data: rates = [] } = useQuery({
queryKey: ['rates-savings'],
queryFn: () => base44.entities.VendorRate.list(),
initialData: [],
});
const userRole = user?.user_role || user?.role || "admin";
// Calculate comprehensive metrics
const metrics = useMemo(() => {
const totalSpend = assignments.reduce((sum, a) => sum + (a.total_bill || 0), 0);
const contractedSpend = assignments.filter(a => a.vendor_id).reduce((sum, a) => sum + (a.total_bill || 0), 0);
const nonContractedSpend = totalSpend - contractedSpend;
const contractedRatio = totalSpend > 0 ? (contractedSpend / totalSpend) * 100 : 0;
// Calculate average rates
const avgContractedRate = rates.filter(r => r.is_approved_rate).reduce((sum, r) => sum + (r.client_rate || 0), 0) / Math.max(rates.filter(r => r.is_approved_rate).length, 1);
const avgNonContractedRate = rates.filter(r => !r.is_approved_rate).reduce((sum, r) => sum + (r.client_rate || 0), 0) / Math.max(rates.filter(r => !r.is_approved_rate).length, 1);
// Potential savings from conversion
const potentialSavingsPercent = avgNonContractedRate > avgContractedRate ? ((avgNonContractedRate - avgContractedRate) / avgNonContractedRate) * 100 : 15;
const potentialSavings = nonContractedSpend * (potentialSavingsPercent / 100);
// Performance metrics
const avgReliability = workforce.reduce((sum, w) => sum + (w.reliability_index || 75), 0) / Math.max(workforce.length, 1);
const noShowRate = workforce.reduce((sum, w) => sum + (w.no_shows || 0), 0) / Math.max(workforce.reduce((sum, w) => sum + (w.total_assignments || 1), 0), 1) * 100;
const fillRate = assignments.filter(a => a.assignment_status === "Completed").length / Math.max(assignments.length, 1) * 100;
return {
totalSpend,
contractedSpend,
nonContractedSpend,
contractedRatio,
potentialSavings,
potentialSavingsPercent,
avgContractedRate: avgContractedRate || 45,
avgNonContractedRate: avgNonContractedRate || 55,
avgReliability,
noShowRate,
fillRate,
activeVendors: vendors.filter(v => v.is_active).length,
totalWorkforce: workforce.length,
completedOrders: orders.filter(o => o.order_status === "Completed").length,
};
}, [assignments, vendors, workforce, orders, rates]);
// Generate savings projections
const projections = useMemo(() => {
const dailySavings = metrics.potentialSavings / 30;
return {
sevenDays: dailySavings * 7,
thirtyDays: metrics.potentialSavings,
quarter: metrics.potentialSavings * 3,
year: metrics.potentialSavings * 12,
};
}, [metrics]);
const getRoleSpecificTitle = () => {
switch (userRole) {
case "procurement": return "Vendor Network Intelligence";
case "operator": return "Workforce Optimization Hub";
case "sector": return "Staffing Performance Center";
case "client": return "Smart Staffing Savings";
case "vendor": return "Your Competitive Edge";
default: return "Workforce Optimization Engine";
}
};
const getRoleSpecificSubtitle = () => {
switch (userRole) {
case "procurement": return "Consolidate vendors, optimize rates, and strengthen your preferred network";
case "operator": return "Maximize utilization, reduce costs, and streamline vendor relationships";
case "sector": return "Improve coverage, boost reliability, and optimize staffing costs";
case "client": return "Get better rates by leveraging the Preferred Vendor Network";
case "vendor": return "Showcase your performance metrics and win more business";
default: return "Consolidate spend, optimize rates, and maximize vendor performance";
}
};
// Get role-specific tabs
const getRoleTabs = () => {
switch (userRole) {
case "procurement":
return [
{ value: "overview", label: "Network Overview", icon: BarChart3 },
{ value: "budget", label: "Budget Control", icon: Wallet },
{ value: "strategies", label: "Operation Strategies", icon: Brain },
{ value: "vendors", label: "Vendor Scorecard", icon: Package },
{ value: "predictions", label: "Rate Analysis", icon: TrendingUp },
{ value: "conversion", label: "Tier Optimization", icon: Target },
];
case "operator":
return [
{ value: "overview", label: "Enterprise View", icon: BarChart3 },
{ value: "budget", label: "Budget Tracker", icon: Wallet },
{ value: "strategies", label: "Smart Strategies", icon: Brain },
{ value: "labor", label: "Labor Efficiency", icon: Users },
{ value: "predictions", label: "Cost Forecasts", icon: TrendingUp },
{ value: "vendors", label: "Vendor Mix", icon: Package },
];
case "sector":
return [
{ value: "overview", label: "Location Dashboard", icon: BarChart3 },
{ value: "budget", label: "Site Budget", icon: Wallet },
{ value: "strategies", label: "Optimization", icon: Brain },
{ value: "labor", label: "Staff Coverage", icon: Users },
{ value: "predictions", label: "Scheduling", icon: Calendar },
];
case "client":
return [
{ value: "overview", label: "My Events", icon: BarChart3 },
{ value: "budget", label: "My Budget", icon: Wallet },
{ value: "strategies", label: "Cost Strategies", icon: Brain },
{ value: "predictions", label: "Cost Savings", icon: TrendingUp },
{ value: "vendors", label: "Vendor Options", icon: Package },
];
case "vendor":
return [
{ value: "overview", label: "My Performance", icon: BarChart3 },
{ value: "budget", label: "Revenue Tracker", icon: Wallet },
{ value: "strategies", label: "Growth Strategies", icon: Brain },
{ value: "labor", label: "Workforce Stats", icon: Users },
{ value: "predictions", label: "Growth Opportunities", icon: TrendingUp },
];
default:
return [
{ value: "overview", label: "Overview", icon: BarChart3 },
{ value: "budget", label: "Budget Intelligence", icon: Wallet },
{ value: "strategies", label: "Smart Strategies", icon: Brain },
{ value: "conversion", label: "Conversion Map", icon: Target },
{ value: "predictions", label: "Predictions", icon: TrendingUp },
{ value: "labor", label: "Labor Analysis", icon: Users },
{ value: "vendors", label: "Vendor Matrix", icon: Package },
];
}
};
const roleTabs = getRoleTabs();
return (
<div className="p-4 md:p-8 bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 min-h-screen">
<div className="max-w-[1800px] mx-auto">
<PageHeader
title={getRoleSpecificTitle()}
subtitle={getRoleSpecificSubtitle()}
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Download className="w-4 h-4 mr-2" />
Export Report
</Button>
<Button size="sm" className="bg-[#0A39DF] hover:bg-[#0831b8]" onClick={() => window.location.reload()}>
<Sparkles className="w-4 h-4 mr-2" />
Run Analysis
</Button>
</div>
}
/>
{/* Time Range Selector */}
<div className="flex items-center gap-2 mb-6">
<span className="text-sm text-slate-500 font-medium">Analyze:</span>
{[
{ value: "7days", label: "7 Days" },
{ value: "30days", label: "30 Days" },
{ value: "quarter", label: "Quarter" },
{ value: "year", label: "Year" },
].map((range) => (
<Button
key={range.value}
variant={timeRange === range.value ? "default" : "outline"}
size="sm"
onClick={() => setTimeRange(range.value)}
className={timeRange === range.value ? "bg-[#0A39DF]" : ""}
>
{range.label}
</Button>
))}
</div>
{/* Overview Cards */}
<SavingsOverviewCards metrics={metrics} projections={projections} timeRange={timeRange} userRole={userRole} />
{/* Main Tabs - Role Specific */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-8">
<TabsList className="bg-white border border-slate-200 p-1 h-auto flex-wrap">
{roleTabs.map((tab) => {
const Icon = tab.icon;
return (
<TabsTrigger
key={tab.value}
value={tab.value}
className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white"
>
<Icon className="w-4 h-4 mr-2" />
{tab.label}
</TabsTrigger>
);
})}
</TabsList>
<TabsContent value="overview" className="mt-6">
<DynamicSavingsDashboard
metrics={metrics}
projections={projections}
timeRange={timeRange}
userRole={userRole}
/>
</TabsContent>
<TabsContent value="conversion" className="mt-6">
<ContractConversionMap
assignments={assignments}
vendors={vendors}
workforce={workforce}
metrics={metrics}
userRole={userRole}
/>
</TabsContent>
<TabsContent value="predictions" className="mt-6">
<PredictiveSavingsModel
metrics={metrics}
projections={projections}
assignments={assignments}
rates={rates}
userRole={userRole}
/>
</TabsContent>
<TabsContent value="labor" className="mt-6">
<LaborSpendAnalysis
assignments={assignments}
workforce={workforce}
orders={orders}
metrics={metrics}
userRole={userRole}
/>
</TabsContent>
<TabsContent value="vendors" className="mt-6">
<VendorPerformanceMatrix
vendors={vendors}
assignments={assignments}
rates={rates}
metrics={metrics}
userRole={userRole}
/>
</TabsContent>
<TabsContent value="budget" className="mt-6">
<BudgetUtilizationTracker
userRole={userRole}
events={orders}
invoices={[]}
/>
</TabsContent>
<TabsContent value="strategies" className="mt-6">
<SmartOperationStrategies
userRole={userRole}
onSelectStrategy={(strategy) => console.log('Selected strategy:', strategy)}
/>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -1,14 +1,14 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { krowSDK } from "@/api/krowSDK";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { UserPlus, Users, LayoutGrid, List as ListIcon, Phone, MapPin, Calendar, Star } from "lucide-react";
import FilterBar from "@/components/staff/FilterBar";
import StaffCard from "@/components/staff/StaffCard";
import EmployeeCard from "@/components/staff/EmployeeCard";
import PageHeader from "@/components/common/PageHeader";
@@ -18,61 +18,50 @@ export default function StaffDirectory() {
const [locationFilter, setLocationFilter] = useState("all");
const [viewMode, setViewMode] = useState("grid"); // "grid" or "list"
const { user: authUser } = useAuth(); // Firebase auth user
const { data: krowUser, isLoading: isLoadingUser } = useQuery({
queryKey: ['krow-user', authUser?.uid],
queryFn: () => krowSDK.entities.User.get({ id: authUser.uid }), // Changed from .filter() to .get()
enabled: !!authUser?.uid,
select: (response) => response?.data?.user, // Adjusted to get single user object
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const { data: staff, isLoading: isLoadingStaff } = useQuery({
const { data: staff, isLoading } = useQuery({
queryKey: ['staff'],
queryFn: () => krowSDK.entities.Staff.list(),
queryFn: () => base44.entities.Staff.list('-created_date'),
initialData: [],
select: (response) => {
// The API returns { data: { staffs: [...] } }, so we need to access the nested array.
if (response && response.data && Array.isArray(response.data.staffs)) {
return response.data.staffs;
}
return []; // Return empty array if the structure is not as expected.
},
});
const { data: events } = useQuery({
queryKey: ['events-for-staff-filter'],
queryFn: () => krowSDK.entities.Event.list(),
initialData: { data: [] },
enabled: !!krowUser,
select: (data) => data.data || [],
queryFn: () => base44.entities.Event.list(),
initialData: [],
enabled: !!user
});
const visibleStaff = React.useMemo(() => {
if (!krowUser || !staff) return [];
const userRole = krowUser.user_role || krowUser.role;
if (['admin', 'procurement', 'operator', 'sector'].includes(userRole.toLowerCase())) {
const userRole = user?.user_role || user?.role;
if (['admin', 'procurement'].includes(userRole)) {
return staff;
}
if (['operator', 'sector'].includes(userRole)) {
return staff;
}
if (userRole === 'vendor') {
return staff.filter(s =>
s.vendor_id === krowUser.id ||
s.vendor_name === krowUser.company_name ||
//s.created_by === krowUser.email
e.created_by === krowUser.id
return staff.filter(s =>
s.vendor_id === user?.id ||
s.vendor_name === user?.company_name ||
s.created_by === user?.email
);
}
if (userRole === 'client') {
const clientEvents = events.filter(e =>
e.client_email === krowUser.email ||
e.business_name === krowUser.company_name ||
//e.created_by === krowUser.email
e.created_by === krowUser.id
const clientEvents = events.filter(e =>
e.client_email === user?.email ||
e.business_name === user?.company_name ||
e.created_by === user?.email
);
const assignedStaffIds = new Set();
clientEvents.forEach(event => {
if (event.assigned_staff) {
@@ -83,37 +72,36 @@ export default function StaffDirectory() {
});
}
});
return staff.filter(s => assignedStaffIds.has(s.id));
}
if (userRole === 'workforce') {
return staff;
}
return staff;
}, [staff, krowUser, events]);
}, [staff, user, events]);
const uniqueDepartments = [...new Set(visibleStaff.map(s => s.department).filter(Boolean))];
const uniqueLocations = [...new Set(visibleStaff.map(s => s.hub_location).filter(Boolean))];
const filteredStaff = visibleStaff.filter(member => {
const matchesSearch = !searchTerm ||
const matchesSearch = !searchTerm ||
member.employee_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
member.position?.toLowerCase().includes(searchTerm.toLowerCase()) ||
member.manager?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesDepartment = departmentFilter === "all" || member.department === departmentFilter;
const matchesLocation = locationFilter === "all" || member.hub_location === locationFilter;
return matchesSearch && matchesDepartment && matchesLocation;
});
const canAddStaff = krowUser && ['admin', 'procurement', 'operator', 'sector', 'vendor'].includes((krowUser.user_role || krowUser.role || '').toLowerCase());
const isLoading = isLoadingStaff || isLoadingUser;
const canAddStaff = ['admin', 'procurement', 'operator', 'sector', 'vendor'].includes(user?.user_role || user?.role);
const getPageTitle = () => {
const userRole = krowUser?.user_role || krowUser?.role;
const userRole = user?.user_role || user?.role;
if (userRole === 'vendor') return "My Staff Directory";
if (userRole === 'client') return "Event Staff Directory";
if (userRole === 'workforce') return "Team Directory";
@@ -121,14 +109,14 @@ export default function StaffDirectory() {
};
const getPageSubtitle = () => {
const userRole = krowUser?.user_role || krowUser?.role;
const userRole = user?.user_role || user?.role;
if (userRole === 'vendor') return `${filteredStaff.length} of your staff members`;
if (userRole === 'client') return `${filteredStaff.length} staff assigned to your events`;
if (userRole === 'workforce') return `${filteredStaff.length} team members`;
return `${filteredStaff.length} ${filteredStaff.length === 1 ? 'member' : 'members'} found`;
};
const getCoverageColor = (percentage) => {
const getCoverageColor = (percentage) => {
if (!percentage) return "bg-red-100 text-red-700";
if (percentage >= 90) return "bg-green-100 text-green-700";
if (percentage >= 50) return "bg-yellow-100 text-yellow-700";
@@ -138,7 +126,7 @@ export default function StaffDirectory() {
return (
<div className="p-4 md:p-8">
<div className="max-w-7xl mx-auto">
<PageHeader
<PageHeader
title={getPageTitle()}
subtitle={getPageSubtitle()}
actions={

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { krowSDK } from "@/api/krowSDK";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
@@ -79,7 +79,7 @@ export default function Teams() {
const { data: user } = useQuery({
queryKey: ['current-user-teams'],
queryFn: () => krowSDK.auth.me(),
queryFn: () => base44.auth.me(),
});
const userRole = user?.user_role || user?.role;
@@ -100,7 +100,7 @@ export default function Teams() {
*/
const { data: userTeam } = useQuery({
queryKey: ['user-team', user?.id, userRole],
queryFn: async () => {debugger;
queryFn: async () => {
if (!user?.id) {
console.warn("⚠️ No user ID found - cannot fetch team");
return null;
@@ -108,15 +108,13 @@ export default function Teams() {
// SECURITY: Fetch ALL teams and filter by owner_id
// This ensures only THIS user's team is returned
const result = await krowSDK.entities.Team.list('-created_date');
const allTeams = result?.data?.teams ?? [];//new, get array from object
const allTeams = await base44.entities.Team.list('-created_date');
// Find ONLY teams owned by this specific user
let team = allTeams.find(t => t.owner_id === user.id);
debugger;
// ISOLATION VERIFICATION
if (team && team.ownerId !== user.id) {//it had team.owner_id I changed it to team.ownerId
if (team && team.owner_id !== user.id) {
console.error("🚨 SECURITY VIOLATION: Team owner mismatch!");
return null;
}
@@ -124,29 +122,23 @@ export default function Teams() {
// Auto-create team if doesn't exist (first time user accesses Teams)
if (!team && user.id) {
console.log(`✅ Creating new isolated team for ${userRole} user: ${user.email}`);
const teamName = user.companyName || `${user.fullName}'s Team` || "My Team";
try {
team = await krowSDK.entities.Team.create({
data: {
teamName: teamName,
ownerId: user.id, // CRITICAL: Links team to THIS user only
ownerName: user.fullName || user.email,
ownerRole: userRole, // Tracks which layer this team belongs to
//email: user.email,
//phone: user.phone || "",
//totalMembers: 0,
//active_members: 0,
//total_hubs: 0,
favoriteStaff: 0,//favoriteStaff_count: 0,
blockedStaff: 0,//blockedStaff_count: 0,
//departments: [], // Initialize with an empty array for departments
}
});
} catch (err) {
console.log('🔥 Error in user-team queryFn:', err);
throw err; // deja que React Query lo maneje como error
}
const teamName = user.company_name || `${user.full_name}'s Team` || "My Team";
team = await base44.entities.Team.create({
team_name: teamName,
owner_id: user.id, // CRITICAL: Links team to THIS user only
owner_name: user.full_name || user.email,
owner_role: userRole, // Tracks which layer this team belongs to
email: user.email,
phone: user.phone || "",
total_members: 0,
active_members: 0,
total_hubs: 0,
favorite_staff_count: 0,
blocked_staff_count: 0,
departments: [], // Initialize with an empty array for departments
});
console.log(`✅ Team created successfully for ${userRole}: ${team.id}`);
}
@@ -185,7 +177,7 @@ export default function Teams() {
}
// Fetch all members and filter by team_id
const allMembers = await krowSDK.entities.TeamMember.list('-created_date');
const allMembers = await base44.entities.TeamMember.list('-created_date');
// SECURITY: Only return members that belong to THIS user's team
const filteredMembers = allMembers.filter(m => m.team_id === userTeam.id);
@@ -210,7 +202,7 @@ export default function Teams() {
queryKey: ['team-invites', userTeam?.id],
queryFn: async () => {
if (!userTeam?.id) return [];
const allInvites = await krowSDK.entities.TeamMemberInvite.list('-invited_date');
const allInvites = await base44.entities.TeamMemberInvite.list('-invited_date');
return allInvites.filter(inv => inv.team_id === userTeam.id && inv.invite_status === 'pending');
},
enabled: !!userTeam?.id,
@@ -219,7 +211,7 @@ export default function Teams() {
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-favorites'],
queryFn: () => krowSDK.entities.Staff.list(),
queryFn: () => base44.entities.Staff.list(),
enabled: !!userTeam?.id,
initialData: [],
});
@@ -228,7 +220,7 @@ export default function Teams() {
queryKey: ['team-hubs-main', userTeam?.id],
queryFn: async () => {
if (!userTeam?.id) return [];
const allHubs = await krowSDK.entities.TeamHub.list('-created_date');
const allHubs = await base44.entities.TeamHub.list('-created_date');
return allHubs.filter(h => h.team_id === userTeam.id);
},
enabled: !!userTeam?.id,
@@ -259,7 +251,7 @@ export default function Teams() {
const firstHub = teamHubs.length > 0 ? teamHubs[0].hub_name : "";
const firstDept = uniqueDepartments.length > 0 ? uniqueDepartments[0] : "Operations";
const invite = await krowSDK.entities.TeamMemberInvite.create({
const invite = await base44.entities.TeamMemberInvite.create({
team_id: userTeam.id,
team_name: userTeam.team_name || "Team",
invite_code: inviteCode,
@@ -303,7 +295,7 @@ export default function Teams() {
if (data.hub && !existingHub) {
// Create new hub with department
await krowSDK.entities.TeamHub.create({
await base44.entities.TeamHub.create({
team_id: userTeam.id,
hub_name: data.hub,
address: "",
@@ -317,7 +309,7 @@ export default function Teams() {
const departmentExists = hubDepartments.some(d => d.department_name === data.department);
if (!departmentExists) {
await krowSDK.entities.TeamHub.update(existingHub.id, {
await base44.entities.TeamHub.update(existingHub.id, {
departments: [...hubDepartments, { department_name: data.department, cost_center: "" }]
});
queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] });
@@ -326,7 +318,7 @@ export default function Teams() {
const inviteCode = `TEAM-${Math.floor(10000 + Math.random() * 90000)}`;
const invite = await krowSDK.entities.TeamMemberInvite.create({
const invite = await base44.entities.TeamMemberInvite.create({
team_id: userTeam.id,
team_name: userTeam.team_name || "Team",
invite_code: inviteCode,
@@ -343,7 +335,7 @@ export default function Teams() {
const registerUrl = `${window.location.origin}${createPageUrl('Onboarding')}?invite=${inviteCode}`;
await krowSDK.integrations.Core.SendEmail({
await base44.integrations.Core.SendEmail({
from_name: userTeam.team_name || "KROW",
to: data.email,
subject: `🚀 Welcome to KROW! You've been invited to ${data.hub || userTeam.team_name}`,
@@ -447,7 +439,7 @@ export default function Teams() {
mutationFn: async (invite) => {
const registerUrl = `${window.location.origin}${createPageUrl('Onboarding')}?invite=${invite.invite_code}`;
await krowSDK.integrations.Core.SendEmail({
await base44.integrations.Core.SendEmail({
from_name: userTeam.team_name || "Team",
to: invite.email,
subject: `Reminder: You're invited to join ${userTeam.team_name || 'our team'}!`,
@@ -509,7 +501,7 @@ export default function Teams() {
});
const updateMemberMutation = useMutation({
mutationFn: ({ id, data }) => krowSDK.entities.TeamMember.update(id, data),
mutationFn: ({ id, data }) => base44.entities.TeamMember.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
setShowEditMemberDialog(false);
@@ -522,7 +514,7 @@ export default function Teams() {
});
const deactivateMemberMutation = useMutation({
mutationFn: ({ id }) => krowSDK.entities.TeamMember.update(id, { is_active: false }),
mutationFn: ({ id }) => base44.entities.TeamMember.update(id, { is_active: false }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
toast({
@@ -533,7 +525,7 @@ export default function Teams() {
});
const activateMemberMutation = useMutation({
mutationFn: ({ id }) => krowSDK.entities.TeamMember.update(id, { is_active: true }),
mutationFn: ({ id }) => base44.entities.TeamMember.update(id, { is_active: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
toast({
@@ -615,7 +607,7 @@ export default function Teams() {
}
// Update the team with new departments list
await krowSDK.entities.Team.update(userTeam.id, {
await base44.entities.Team.update(userTeam.id, {
departments: updatedDepartments
});
@@ -646,7 +638,7 @@ export default function Teams() {
const currentDepartments = userTeam.departments || [];
const updatedDepartments = currentDepartments.filter(dept => dept !== deptToDelete);
await krowSDK.entities.Team.update(userTeam.id, {
await base44.entities.Team.update(userTeam.id, {
departments: updatedDepartments
});
@@ -666,7 +658,7 @@ export default function Teams() {
};
const updateTeamMutation = useMutation({
mutationFn: ({ id, data }) => krowSDK.entities.Team.update(id, data),
mutationFn: ({ id, data }) => base44.entities.Team.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user-team', user?.id, userRole] });
toast({
@@ -769,7 +761,7 @@ export default function Teams() {
}, [isGoogleMapsLoaded, showAddHubDialog]);
const createHubMutation = useMutation({
mutationFn: (hubData) => krowSDK.entities.TeamHub.create({
mutationFn: (hubData) => base44.entities.TeamHub.create({
...hubData,
team_id: userTeam.id,
is_active: true
@@ -885,12 +877,12 @@ export default function Teams() {
{member.department}
</Badge>
)}
{member.hub && (
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-200 flex items-center gap-1">
<MapPin className="w-3 h-3" />
{member.hub}
</Badge>
)}
{userRole !== 'vendor' && member.hub && (
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-200 flex items-center gap-1">
<MapPin className="w-3 h-3" />
{member.hub}
</Badge>
)}
</div>
</div>
@@ -1062,10 +1054,17 @@ export default function Teams() {
<Mail className="w-4 h-4" />
Invitations ({pendingInvites.length})
</TabsTrigger>
<TabsTrigger value="hubs" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
<MapPin className="w-4 h-4" />
Hubs ({teamHubs.length})
</TabsTrigger>
{userRole === 'vendor' ? (
<TabsTrigger value="departments" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
<Building2 className="w-4 h-4" />
Departments ({uniqueDepartments.length})
</TabsTrigger>
) : (
<TabsTrigger value="hubs" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
<MapPin className="w-4 h-4" />
Hubs ({teamHubs.length})
</TabsTrigger>
)}
<TabsTrigger value="favorites" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
<Star className="w-4 h-4" />
Favorites ({userTeam?.favorite_staff_count || 0})
@@ -1094,8 +1093,12 @@ export default function Teams() {
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Role</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Department</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub Address</th>
{userRole !== 'vendor' && (
<>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub Address</th>
</>
)}
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
@@ -1123,8 +1126,12 @@ export default function Teams() {
</Badge>
</td>
<td className="px-4 py-4 text-sm text-slate-700">{member.department || 'No Department'}</td>
<td className="px-4 py-4 text-sm text-slate-700">{member.hub || 'No Hub'}</td>
<td className="px-4 py-4 text-sm text-slate-600">{memberHub?.address || 'No Address'}</td>
{userRole !== 'vendor' && (
<>
<td className="px-4 py-4 text-sm text-slate-700">{member.hub || 'No Hub'}</td>
<td className="px-4 py-4 text-sm text-slate-600">{memberHub?.address || 'No Address'}</td>
</>
)}
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<Button
@@ -1195,8 +1202,12 @@ export default function Teams() {
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Role</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Department</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub Address</th>
{userRole !== 'vendor' && (
<>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub Address</th>
</>
)}
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
@@ -1227,8 +1238,12 @@ export default function Teams() {
</Badge>
</td>
<td className="px-4 py-4 text-sm text-slate-700">{member.department || 'No Department'}</td>
<td className="px-4 py-4 text-sm text-slate-700">{member.hub || 'No Hub'}</td>
<td className="px-4 py-4 text-sm text-slate-600">{memberHub?.address || 'No Address'}</td>
{userRole !== 'vendor' && (
<>
<td className="px-4 py-4 text-sm text-slate-700">{member.hub || 'No Hub'}</td>
<td className="px-4 py-4 text-sm text-slate-600">{memberHub?.address || 'No Address'}</td>
</>
)}
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<Button
@@ -1327,6 +1342,79 @@ export default function Teams() {
)}
</TabsContent>
{/* Departments Tab (for Vendors) */}
<TabsContent value="departments">
<div className="space-y-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-[#1C323E] flex items-center gap-2">
<Building2 className="w-5 h-5 text-[#0A39DF]" />
Departments
</h3>
<Button
variant="outline"
onClick={handleAddDepartment}
>
<Plus className="w-4 h-4 mr-2" />
Add Department
</Button>
</div>
{uniqueDepartments.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{uniqueDepartments.map((dept) => (
<Card key={dept} className="border-2 border-slate-200 hover:border-[#0A39DF] transition-all">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-blue-600 rounded-lg flex items-center justify-center">
<Building2 className="w-5 h-5 text-white" />
</div>
<h4 className="font-bold text-slate-900">{dept}</h4>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-blue-50 hover:text-[#0A39DF]"
onClick={() => {
setEditingDepartment(dept);
setNewDepartment(dept);
setShowDepartmentDialog(true);
}}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-red-50 hover:text-red-600"
onClick={() => handleDeleteDepartment(dept)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<div className="text-sm text-slate-600">
<span className="font-medium">{activeMembers.filter(m => m.department === dept).length}</span> members
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-200">
<Building2 className="w-12 h-12 mx-auto text-slate-300 mb-3" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Departments Yet</h3>
<p className="text-slate-500 mb-6">Create departments to organize your team</p>
<Button onClick={handleAddDepartment}>
<Plus className="w-4 h-4 mr-2" />
Add First Department
</Button>
</div>
)}
</div>
</TabsContent>
{/* Hubs Tab */}
<TabsContent value="hubs">
<div className="space-y-6">
@@ -2378,14 +2466,14 @@ export default function Teams() {
size="lg"
onClick={async () => {
const updatedDepartments = [...(selectedHubForDept.departments || []), newHubDepartment];
await krowSDK.entities.TeamHub.update(selectedHubForDept.id, {
await base44.entities.TeamHub.update(selectedHubForDept.id, {
departments: updatedDepartments
});
// Also add department to team's global department list
const teamDepartments = userTeam?.departments || [];
if (!teamDepartments.includes(newHubDepartment.department_name)) {
await krowSDK.entities.Team.update(userTeam.id, {
await base44.entities.Team.update(userTeam.id, {
departments: [...teamDepartments, newHubDepartment.department_name]
});
queryClient.invalidateQueries({ queryKey: ['user-team', user?.id, userRole] });

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import React, { useState, useMemo, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -26,6 +25,7 @@ import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import SmartAssignModal from "@/components/events/SmartAssignModal";
import ClientLoyaltyCard from "@/components/vendor/ClientLoyaltyCard";
const convertTo12Hour = (time24) => {
if (!time24 || time24 === "—") return time24;
@@ -119,6 +119,13 @@ const AVAILABLE_WIDGETS = [
category: 'Analytics',
categoryColor: 'bg-amber-100 text-amber-700',
},
{
id: 'client-loyalty',
title: 'Client Loyalty',
description: 'See which clients are loyal vs at-risk',
category: 'Insights',
categoryColor: 'bg-pink-100 text-pink-700',
},
{
id: 'top-performers',
title: 'Top Performers',
@@ -784,6 +791,10 @@ export default function VendorDashboard() {
</Card>
);
const renderClientLoyalty = () => (
<ClientLoyaltyCard vendorId={user?.id} vendorName={user?.company_name} />
);
const renderGoldVendors = () => (
<Card className="bg-white border-slate-200 shadow-sm">
<CardHeader className="pb-3 border-b border-slate-100">
@@ -839,6 +850,8 @@ export default function VendorDashboard() {
return renderTopClients();
case 'top-performers':
return renderTopPerformers();
case 'client-loyalty':
return renderClientLoyalty();
case 'gold-vendors':
return renderGoldVendors();
case 'quick-actions':
@@ -965,7 +978,7 @@ export default function VendorDashboard() {
</div>
)}
{(visibleWidgetIds.includes('revenue-carousel') || visibleWidgetIds.includes('top-clients') || visibleWidgetIds.includes('top-performers') || visibleWidgetIds.includes('gold-vendors') || visibleWidgetIds.includes('quick-actions')) && (
{(visibleWidgetIds.includes('revenue-carousel') || visibleWidgetIds.includes('top-clients') || visibleWidgetIds.includes('top-performers') || visibleWidgetIds.includes('client-loyalty') || visibleWidgetIds.includes('gold-vendors') || visibleWidgetIds.includes('quick-actions')) && (
<div className="grid grid-cols-3 gap-6">
{(visibleWidgetIds.includes('revenue-carousel') || visibleWidgetIds.includes('quick-actions')) && (
<div className="space-y-4">
@@ -992,7 +1005,7 @@ export default function VendorDashboard() {
</div>
)}
{(visibleWidgetIds.includes('top-clients') || visibleWidgetIds.includes('top-performers') || visibleWidgetIds.includes('gold-vendors')) && (
{(visibleWidgetIds.includes('top-clients') || visibleWidgetIds.includes('top-performers') || visibleWidgetIds.includes('client-loyalty') || visibleWidgetIds.includes('gold-vendors')) && (
<div className={`grid ${visibleWidgetIds.includes('revenue-carousel') || visibleWidgetIds.includes('quick-actions') ? 'col-span-2' : 'col-span-3'} grid-cols-3 gap-4`}>
{visibleWidgetIds.includes('top-clients') && (
<div className="relative group">
@@ -1014,6 +1027,16 @@ export default function VendorDashboard() {
{renderWidget('top-performers')}
</div>
)}
{visibleWidgetIds.includes('client-loyalty') && (
<div className="relative group">
{isCustomizing && (
<button onClick={() => handleRemoveWidget('client-loyalty')} className="absolute -top-3 -right-3 w-8 h-8 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center shadow-lg z-10">
<Minus className="w-4 h-4 text-white" />
</button>
)}
{renderWidget('client-loyalty')}
</div>
)}
{visibleWidgetIds.includes('gold-vendors') && (
<div className="relative group">
{isCustomizing && (
@@ -1140,4 +1163,4 @@ export default function VendorDashboard() {
/>
</div>
);
}
}

View File

@@ -29,8 +29,9 @@ import {
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, Handshake } from "lucide-react";
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, Handshake, Settings } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import ClientVendorPreferences from "@/components/vendor/ClientVendorPreferences";
export default function VendorMarketplace() {
const navigate = useNavigate();
@@ -451,6 +452,13 @@ export default function VendorMarketplace() {
</Card>
)}
{/* Client Vendor Preferences */}
<ClientVendorPreferences
user={user}
vendors={vendorsWithMetrics}
onUpdate={() => queryClient.invalidateQueries({ queryKey: ['current-user-marketplace'] })}
/>
{/* Stats Cards */}
<div className="grid grid-cols-4 gap-4">
<Card className="border border-slate-200 bg-slate-50/50 hover:shadow-md transition-all">

View File

@@ -1,267 +1,489 @@
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
// Auth components
import ProtectedRoute from '@/components/auth/ProtectedRoute';
import PublicRoute from '@/components/auth/PublicRoute';
// Layout and pages
import Layout from "./Layout.jsx";
import Home from "./Home";
import Login from "./Login";
import Register from "./Register";
import Dashboard from "./Dashboard";
import StaffDirectory from "./StaffDirectory";
import AddStaff from "./AddStaff";
import EditStaff from "./EditStaff";
import Events from "./Events";
import CreateEvent from "./CreateEvent";
import EditEvent from "./EditEvent";
import EventDetail from "./EventDetail";
import Business from "./Business";
import Invoices from "./Invoices";
import Payroll from "./Payroll";
import Certification from "./Certification";
import Support from "./Support";
import Reports from "./Reports";
import Settings from "./Settings";
import ActivityLog from "./ActivityLog";
import AddBusiness from "./AddBusiness";
import EditBusiness from "./EditBusiness";
import ProcurementDashboard from "./ProcurementDashboard";
import OperatorDashboard from "./OperatorDashboard";
import VendorDashboard from "./VendorDashboard";
import WorkforceDashboard from "./WorkforceDashboard";
import Messages from "./Messages";
import ClientDashboard from "./ClientDashboard";
import Onboarding from "./Onboarding";
import ClientOrders from "./ClientOrders";
import ClientInvoices from "./ClientInvoices";
import VendorOrders from "./VendorOrders";
import VendorStaff from "./VendorStaff";
import VendorInvoices from "./VendorInvoices";
import VendorPerformance from "./VendorPerformance";
import WorkforceShifts from "./WorkforceShifts";
import WorkforceEarnings from "./WorkforceEarnings";
import WorkforceProfile from "./WorkforceProfile";
import UserManagement from "./UserManagement";
import Home from "./Home";
import VendorRateCard from "./VendorRateCard";
import Permissions from "./Permissions";
import WorkforceCompliance from "./WorkforceCompliance";
import Teams from "./Teams";
import CreateTeam from "./CreateTeam";
import TeamDetails from "./TeamDetails";
import VendorManagement from "./VendorManagement";
import PartnerManagement from "./PartnerManagement";
import EnterpriseManagement from "./EnterpriseManagement";
import VendorOnboarding from "./VendorOnboarding";
import SectorManagement from "./SectorManagement";
import AddEnterprise from "./AddEnterprise";
import AddSector from "./AddSector";
import AddPartner from "./AddPartner";
import EditVendor from "./EditVendor";
import SmartVendorOnboarding from "./SmartVendorOnboarding";
import InviteVendor from "./InviteVendor";
import VendorCompliance from "./VendorCompliance";
import EditPartner from "./EditPartner";
import EditSector from "./EditSector";
import EditEnterprise from "./EditEnterprise";
import VendorRates from "./VendorRates";
import VendorDocumentReview from "./VendorDocumentReview";
import VendorMarketplace from "./VendorMarketplace";
import RapidOrder from "./RapidOrder";
import SmartScheduler from "./SmartScheduler";
import StaffOnboarding from "./StaffOnboarding";
import NotificationSettings from "./NotificationSettings";
import TaskBoard from "./TaskBoard";
import InvoiceDetail from "./InvoiceDetail";
import InvoiceEditor from "./InvoiceEditor";
import apiDocsRaw from "./api-docs-raw";
import Tutorials from "./Tutorials";
import Schedule from "./Schedule";
import StaffAvailability from "./StaffAvailability";
import WorkerShiftProposals from "./WorkerShiftProposals";
const PAGES = {
Dashboard,
StaffDirectory,
AddStaff,
EditStaff,
Events,
CreateEvent,
EditEvent,
EventDetail,
Business,
Invoices,
Payroll,
Certification,
Support,
Reports,
Settings,
ActivityLog,
AddBusiness,
EditBusiness,
ProcurementDashboard,
OperatorDashboard,
VendorDashboard,
WorkforceDashboard,
Messages,
ClientDashboard,
Onboarding,
ClientOrders,
ClientInvoices,
VendorOrders,
VendorStaff,
VendorInvoices,
VendorPerformance,
WorkforceShifts,
WorkforceEarnings,
WorkforceProfile,
UserManagement,
Home,
VendorRateCard,
Permissions,
WorkforceCompliance,
Teams,
CreateTeam,
TeamDetails,
VendorManagement,
PartnerManagement,
EnterpriseManagement,
VendorOnboarding,
SectorManagement,
AddEnterprise,
AddSector,
AddPartner,
EditVendor,
SmartVendorOnboarding,
InviteVendor,
VendorCompliance,
EditPartner,
EditSector,
EditEnterprise,
VendorRates,
VendorDocumentReview,
VendorMarketplace,
RapidOrder,
SmartScheduler,
StaffOnboarding,
NotificationSettings,
TaskBoard,
InvoiceDetail,
InvoiceEditor,
Tutorials,
Schedule,
StaffAvailability,
WorkerShiftProposals,
};
import SavingsEngine from "./SavingsEngine";
function _getCurrentPage(url) {
if (url.endsWith('/')) url = url.slice(0, -1);
let last = url.split('/').pop();
if (last.includes('?')) last = last.split('?')[0];
const pageName = Object.keys(PAGES).find(p => p.toLowerCase() === last.toLowerCase());
return pageName || 'Home'; // Default to Home
import EmployeeDocuments from "./EmployeeDocuments";
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
const PAGES = {
Dashboard: Dashboard,
StaffDirectory: StaffDirectory,
AddStaff: AddStaff,
EditStaff: EditStaff,
Events: Events,
CreateEvent: CreateEvent,
EditEvent: EditEvent,
EventDetail: EventDetail,
Business: Business,
Invoices: Invoices,
Payroll: Payroll,
Certification: Certification,
Support: Support,
Reports: Reports,
Settings: Settings,
ActivityLog: ActivityLog,
AddBusiness: AddBusiness,
EditBusiness: EditBusiness,
ProcurementDashboard: ProcurementDashboard,
OperatorDashboard: OperatorDashboard,
VendorDashboard: VendorDashboard,
WorkforceDashboard: WorkforceDashboard,
Messages: Messages,
ClientDashboard: ClientDashboard,
Onboarding: Onboarding,
ClientOrders: ClientOrders,
ClientInvoices: ClientInvoices,
VendorOrders: VendorOrders,
VendorStaff: VendorStaff,
VendorInvoices: VendorInvoices,
VendorPerformance: VendorPerformance,
WorkforceShifts: WorkforceShifts,
WorkforceEarnings: WorkforceEarnings,
WorkforceProfile: WorkforceProfile,
UserManagement: UserManagement,
Home: Home,
VendorRateCard: VendorRateCard,
Permissions: Permissions,
WorkforceCompliance: WorkforceCompliance,
Teams: Teams,
CreateTeam: CreateTeam,
TeamDetails: TeamDetails,
VendorManagement: VendorManagement,
PartnerManagement: PartnerManagement,
EnterpriseManagement: EnterpriseManagement,
VendorOnboarding: VendorOnboarding,
SectorManagement: SectorManagement,
AddEnterprise: AddEnterprise,
AddSector: AddSector,
AddPartner: AddPartner,
EditVendor: EditVendor,
SmartVendorOnboarding: SmartVendorOnboarding,
InviteVendor: InviteVendor,
VendorCompliance: VendorCompliance,
EditPartner: EditPartner,
EditSector: EditSector,
EditEnterprise: EditEnterprise,
VendorRates: VendorRates,
VendorDocumentReview: VendorDocumentReview,
VendorMarketplace: VendorMarketplace,
RapidOrder: RapidOrder,
SmartScheduler: SmartScheduler,
StaffOnboarding: StaffOnboarding,
NotificationSettings: NotificationSettings,
TaskBoard: TaskBoard,
InvoiceDetail: InvoiceDetail,
InvoiceEditor: InvoiceEditor,
apiDocsRaw: apiDocsRaw,
Tutorials: Tutorials,
Schedule: Schedule,
StaffAvailability: StaffAvailability,
WorkerShiftProposals: WorkerShiftProposals,
SavingsEngine: SavingsEngine,
EmployeeDocuments: EmployeeDocuments,
}
function _getCurrentPage(url) {
if (url.endsWith('/')) {
url = url.slice(0, -1);
}
let urlLastPart = url.split('/').pop();
if (urlLastPart.includes('?')) {
urlLastPart = urlLastPart.split('?')[0];
}
function AppRoutes() {
const pageName = Object.keys(PAGES).find(page => page.toLowerCase() === urlLastPart.toLowerCase());
return pageName || Object.keys(PAGES)[0];
}
// Create a wrapper component that uses useLocation inside the Router context
function PagesContent() {
const location = useLocation();
const currentPage = _getCurrentPage(location.pathname);
return (
<Routes>
{/* Public Routes */}
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/register" element={<PublicRoute><Register /></PublicRoute>} />
{/* Private Routes */}
<Route path="/*" element={
<ProtectedRoute>
<Layout currentPageName={currentPage}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/Dashboard" element={<Dashboard />} />
<Route path="/StaffDirectory" element={<StaffDirectory />} />
<Route path="/AddStaff" element={<AddStaff />} />
<Route path="/EditStaff" element={<EditStaff />} />
<Route path="/Events" element={<Events />} />
<Route path="/CreateEvent" element={<CreateEvent />} />
<Route path="/EditEvent" element={<EditEvent />} />
<Route path="/EventDetail" element={<EventDetail />} />
<Route path="/Business" element={<Business />} />
<Route path="/Invoices" element={<Invoices />} />
<Route path="/Payroll" element={<Payroll />} />
<Route path="/Certification" element={<Certification />} />
<Route path="/Support" element={<Support />} />
<Route path="/Reports" element={<Reports />} />
<Route path="/Settings" element={<Settings />} />
<Route path="/ActivityLog" element={<ActivityLog />} />
<Route path="/AddBusiness" element={<AddBusiness />} />
<Route path="/EditBusiness" element={<EditBusiness />} />
<Route path="/ProcurementDashboard" element={<ProcurementDashboard />} />
<Route path="/OperatorDashboard" element={<OperatorDashboard />} />
<Route path="/VendorDashboard" element={<VendorDashboard />} />
<Route path="/WorkforceDashboard" element={<WorkforceDashboard />} />
<Route path="/Messages" element={<Messages />} />
<Route path="/ClientDashboard" element={<ClientDashboard />} />
<Route path="/Onboarding" element={<Onboarding />} />
<Route path="/ClientOrders" element={<ClientOrders />} />
<Route path="/ClientInvoices" element={<ClientInvoices />} />
<Route path="/VendorOrders" element={<VendorOrders />} />
<Route path="/VendorStaff" element={<VendorStaff />} />
<Route path="/VendorInvoices" element={<VendorInvoices />} />
<Route path="/VendorPerformance" element={<VendorPerformance />} />
<Route path="/WorkforceShifts" element={<WorkforceShifts />} />
<Route path="/WorkforceEarnings" element={<WorkforceEarnings />} />
<Route path="/WorkforceProfile" element={<WorkforceProfile />} />
<Route path="/UserManagement" element={<UserManagement />} />
<Route path="/Home" element={<Home />} />
<Route path="/VendorRateCard" element={<VendorRateCard />} />
<Route path="/Permissions" element={<Permissions />} />
<Route path="/WorkforceCompliance" element={<WorkforceCompliance />} />
<Route path="/Teams" element={<Teams />} />
<Route path="/CreateTeam" element={<CreateTeam />} />
<Route path="/TeamDetails" element={<TeamDetails />} />
<Route path="/VendorManagement" element={<VendorManagement />} />
<Route path="/PartnerManagement" element={<PartnerManagement />} />
<Route path="/EnterpriseManagement" element={<EnterpriseManagement />} />
<Route path="/VendorOnboarding" element={<VendorOnboarding />} />
<Route path="/SectorManagement" element={<SectorManagement />} />
<Route path="/AddEnterprise" element={<AddEnterprise />} />
<Route path="/AddSector" element={<AddSector />} />
<Route path="/AddPartner" element={<AddPartner />} />
<Route path="/EditVendor" element={<EditVendor />} />
<Route path="/SmartVendorOnboarding" element={<SmartVendorOnboarding />} />
<Route path="/InviteVendor" element={<InviteVendor />} />
<Route path="/VendorCompliance" element={<VendorCompliance />} />
<Route path="/EditPartner" element={<EditPartner />} />
<Route path="/EditSector" element={<EditSector />} />
<Route path="/EditEnterprise" element={<EditEnterprise />} />
<Route path="/VendorRates" element={<VendorRates />} />
<Route path="/VendorDocumentReview" element={<VendorDocumentReview />} />
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
<Route path="/RapidOrder" element={<RapidOrder />} />
<Route path="/SmartScheduler" element={<SmartScheduler />} />
<Route path="/StaffOnboarding" element={<StaffOnboarding />} />
<Route path="/NotificationSettings" element={<NotificationSettings />} />
<Route path="/TaskBoard" element={<TaskBoard />} />
<Route path="/InvoiceDetail" element={<InvoiceDetail />} />
<Route path="/InvoiceEditor" element={<InvoiceEditor />} />
<Route path="/Tutorials" element={<Tutorials />} />
<Route path="/Schedule" element={<Schedule />} />
<Route path="/StaffAvailability" element={<StaffAvailability />} />
<Route path="/WorkerShiftProposals" element={<WorkerShiftProposals />} />
</Routes>
</Layout>
</ProtectedRoute>
} />
</Routes>
<Layout currentPageName={currentPage}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/Dashboard" element={<Dashboard />} />
<Route path="/StaffDirectory" element={<StaffDirectory />} />
<Route path="/AddStaff" element={<AddStaff />} />
<Route path="/EditStaff" element={<EditStaff />} />
<Route path="/Events" element={<Events />} />
<Route path="/CreateEvent" element={<CreateEvent />} />
<Route path="/EditEvent" element={<EditEvent />} />
<Route path="/EventDetail" element={<EventDetail />} />
<Route path="/Business" element={<Business />} />
<Route path="/Invoices" element={<Invoices />} />
<Route path="/Payroll" element={<Payroll />} />
<Route path="/Certification" element={<Certification />} />
<Route path="/Support" element={<Support />} />
<Route path="/Reports" element={<Reports />} />
<Route path="/Settings" element={<Settings />} />
<Route path="/ActivityLog" element={<ActivityLog />} />
<Route path="/AddBusiness" element={<AddBusiness />} />
<Route path="/EditBusiness" element={<EditBusiness />} />
<Route path="/ProcurementDashboard" element={<ProcurementDashboard />} />
<Route path="/OperatorDashboard" element={<OperatorDashboard />} />
<Route path="/VendorDashboard" element={<VendorDashboard />} />
<Route path="/WorkforceDashboard" element={<WorkforceDashboard />} />
<Route path="/Messages" element={<Messages />} />
<Route path="/ClientDashboard" element={<ClientDashboard />} />
<Route path="/Onboarding" element={<Onboarding />} />
<Route path="/ClientOrders" element={<ClientOrders />} />
<Route path="/ClientInvoices" element={<ClientInvoices />} />
<Route path="/VendorOrders" element={<VendorOrders />} />
<Route path="/VendorStaff" element={<VendorStaff />} />
<Route path="/VendorInvoices" element={<VendorInvoices />} />
<Route path="/VendorPerformance" element={<VendorPerformance />} />
<Route path="/WorkforceShifts" element={<WorkforceShifts />} />
<Route path="/WorkforceEarnings" element={<WorkforceEarnings />} />
<Route path="/WorkforceProfile" element={<WorkforceProfile />} />
<Route path="/UserManagement" element={<UserManagement />} />
<Route path="/Home" element={<Home />} />
<Route path="/VendorRateCard" element={<VendorRateCard />} />
<Route path="/Permissions" element={<Permissions />} />
<Route path="/WorkforceCompliance" element={<WorkforceCompliance />} />
<Route path="/Teams" element={<Teams />} />
<Route path="/CreateTeam" element={<CreateTeam />} />
<Route path="/TeamDetails" element={<TeamDetails />} />
<Route path="/VendorManagement" element={<VendorManagement />} />
<Route path="/PartnerManagement" element={<PartnerManagement />} />
<Route path="/EnterpriseManagement" element={<EnterpriseManagement />} />
<Route path="/VendorOnboarding" element={<VendorOnboarding />} />
<Route path="/SectorManagement" element={<SectorManagement />} />
<Route path="/AddEnterprise" element={<AddEnterprise />} />
<Route path="/AddSector" element={<AddSector />} />
<Route path="/AddPartner" element={<AddPartner />} />
<Route path="/EditVendor" element={<EditVendor />} />
<Route path="/SmartVendorOnboarding" element={<SmartVendorOnboarding />} />
<Route path="/InviteVendor" element={<InviteVendor />} />
<Route path="/VendorCompliance" element={<VendorCompliance />} />
<Route path="/EditPartner" element={<EditPartner />} />
<Route path="/EditSector" element={<EditSector />} />
<Route path="/EditEnterprise" element={<EditEnterprise />} />
<Route path="/VendorRates" element={<VendorRates />} />
<Route path="/VendorDocumentReview" element={<VendorDocumentReview />} />
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
<Route path="/RapidOrder" element={<RapidOrder />} />
<Route path="/SmartScheduler" element={<SmartScheduler />} />
<Route path="/StaffOnboarding" element={<StaffOnboarding />} />
<Route path="/NotificationSettings" element={<NotificationSettings />} />
<Route path="/TaskBoard" element={<TaskBoard />} />
<Route path="/InvoiceDetail" element={<InvoiceDetail />} />
<Route path="/InvoiceEditor" element={<InvoiceEditor />} />
<Route path="/api-docs-raw" element={<apiDocsRaw />} />
<Route path="/Tutorials" element={<Tutorials />} />
<Route path="/Schedule" element={<Schedule />} />
<Route path="/StaffAvailability" element={<StaffAvailability />} />
<Route path="/WorkerShiftProposals" element={<WorkerShiftProposals />} />
<Route path="/SavingsEngine" element={<SavingsEngine />} />
<Route path="/EmployeeDocuments" element={<EmployeeDocuments />} />
</Routes>
</Layout>
);
}
export default function Pages() {
return (
<Router>
<AppRoutes />
<PagesContent />
</Router>
);
}