other modifications days ago
This commit is contained in:
401
frontend-web/src/components/budget/BudgetUtilizationTracker.jsx
Normal file
401
frontend-web/src/components/budget/BudgetUtilizationTracker.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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
342
frontend-web/src/components/invoices/InvoiceQuickActions.jsx
Normal file
342
frontend-web/src/components/invoices/InvoiceQuickActions.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
325
frontend-web/src/components/reports/ReportExporter.jsx
Normal file
325
frontend-web/src/components/reports/ReportExporter.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
frontend-web/src/components/reports/ReportInsightsBanner.jsx
Normal file
178
frontend-web/src/components/reports/ReportInsightsBanner.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
frontend-web/src/components/reports/ReportPDFPreview.jsx
Normal file
184
frontend-web/src/components/reports/ReportPDFPreview.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
frontend-web/src/components/reports/ReportTemplateLibrary.jsx
Normal file
180
frontend-web/src/components/reports/ReportTemplateLibrary.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
313
frontend-web/src/components/reports/ScheduledReports.jsx
Normal file
313
frontend-web/src/components/reports/ScheduledReports.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 <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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
422
frontend-web/src/components/savings/ContractConversionMap.jsx
Normal file
422
frontend-web/src/components/savings/ContractConversionMap.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
690
frontend-web/src/components/savings/ConversionModal.jsx
Normal file
690
frontend-web/src/components/savings/ConversionModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
335
frontend-web/src/components/savings/DynamicSavingsDashboard.jsx
Normal file
335
frontend-web/src/components/savings/DynamicSavingsDashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
frontend-web/src/components/savings/LaborSpendAnalysis.jsx
Normal file
272
frontend-web/src/components/savings/LaborSpendAnalysis.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
frontend-web/src/components/savings/PredictiveSavingsModel.jsx
Normal file
259
frontend-web/src/components/savings/PredictiveSavingsModel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
359
frontend-web/src/components/savings/SavingsOverviewCards.jsx
Normal file
359
frontend-web/src/components/savings/SavingsOverviewCards.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
299
frontend-web/src/components/savings/VendorPerformanceMatrix.jsx
Normal file
299
frontend-web/src/components/savings/VendorPerformanceMatrix.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
194
frontend-web/src/components/vendor/ClientLoyaltyCard.jsx
vendored
Normal file
194
frontend-web/src/components/vendor/ClientLoyaltyCard.jsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
386
frontend-web/src/components/vendor/ClientVendorPreferences.jsx
vendored
Normal file
386
frontend-web/src/components/vendor/ClientVendorPreferences.jsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
408
frontend-web/src/components/vendor/SmartOperationStrategies.jsx
vendored
Normal file
408
frontend-web/src/components/vendor/SmartOperationStrategies.jsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'] });
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
703
frontend-web/src/pages/EmployeeDocuments.jsx
Normal file
703
frontend-web/src/pages/EmployeeDocuments.jsx
Normal 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
@@ -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)}
|
||||
/>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
326
frontend-web/src/pages/SavingsEngine.jsx
Normal file
326
frontend-web/src/pages/SavingsEngine.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user