575 lines
24 KiB
TypeScript
575 lines
24 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { Plus, FileText, AlertCircle, CheckCircle2, DollarSign, Loader2, MoreVertical, ShoppingCart } from 'lucide-react';
|
|
import { toast } from '@/lib/toast';
|
|
|
|
interface PurchaseOrder {
|
|
id: string;
|
|
vendor: string;
|
|
description: string;
|
|
amount: number;
|
|
dateCreated: string;
|
|
expectedDelivery: string;
|
|
status: 'Draft' | 'Pending Approval' | 'Approved' | 'Sent' | 'Partially Received' | 'Received' | 'Closed';
|
|
poMatching?: {
|
|
po: boolean;
|
|
receipt: boolean;
|
|
invoice: boolean;
|
|
};
|
|
}
|
|
|
|
interface SummaryCard {
|
|
label: string;
|
|
value: string;
|
|
icon: React.ReactNode;
|
|
color: string;
|
|
title?: string;
|
|
}
|
|
|
|
interface VendorMetric {
|
|
vendor: string;
|
|
onTimeDelivery: number;
|
|
quality: number;
|
|
priceVariance: number;
|
|
}
|
|
|
|
const ProcurementPage = () => {
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState<'po-list' | 'matching'>('po-list');
|
|
const [vendors, setVendors] = useState<any[]>([]);
|
|
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
|
const [expandedMenu, setExpandedMenu] = useState<string | null>(null);
|
|
const [showNewPOForm, setShowNewPOForm] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const [poRes, vendorRes] = await Promise.all([
|
|
fetch('/api/purchase-orders'),
|
|
fetch('/api/vendors')
|
|
]);
|
|
|
|
if (poRes.ok) {
|
|
const posData = await poRes.json();
|
|
const formattedPos: PurchaseOrder[] = posData.map((po: any) => ({
|
|
id: po.poNumber,
|
|
vendor: po.vendor?.vendorName || 'Unknown Vendor',
|
|
description: po.lines?.map((l: any) => l.description).join(', ') || 'Purchase Order',
|
|
amount: po.totalAmount || 0,
|
|
dateCreated: new Date(po.orderDate).toISOString().split('T')[0],
|
|
expectedDelivery: po.expectedDate ? new Date(po.expectedDate).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
|
status: po.status as any,
|
|
poMatching: {
|
|
po: true,
|
|
receipt: ['RECEIVED', 'PARTIALLY_RECEIVED'].includes(po.status),
|
|
invoice: ['RECEIVED', 'PARTIALLY_RECEIVED'].includes(po.status),
|
|
},
|
|
}));
|
|
setPurchaseOrders(formattedPos);
|
|
}
|
|
if (vendorRes.ok) setVendors(await vendorRes.json());
|
|
} catch (error) {
|
|
console.error('Error fetching data:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, []);
|
|
|
|
const openPOs = purchaseOrders.filter((po) => !['Closed', 'Received'].includes(po.status)).length;
|
|
const pendingApprovalCount = purchaseOrders.filter((po) => po.status === 'Pending Approval').length;
|
|
const totalCommitted = purchaseOrders.reduce((sum, po) => sum + po.amount, 0);
|
|
const receivedThisMonth = purchaseOrders.filter((po) => po.status === 'Received').length;
|
|
|
|
const summaryCards: SummaryCard[] = [
|
|
{
|
|
label: 'Open POs',
|
|
value: openPOs.toString(),
|
|
icon: <FileText className="w-6 h-6" />,
|
|
color: 'bg-blue-900/15 text-blue-700',
|
|
},
|
|
{
|
|
label: 'Pending Approval',
|
|
value: pendingApprovalCount.toString(),
|
|
icon: <AlertCircle className="w-6 h-6" />,
|
|
color: 'bg-orange-900/15 text-orange-700',
|
|
},
|
|
{
|
|
label: 'Total Committed',
|
|
value: formatCompactCurrency(totalCommitted),
|
|
icon: <DollarSign className="w-6 h-6" />,
|
|
color: 'bg-purple-900/15 text-purple-700',
|
|
title: formatCurrency(totalCommitted),
|
|
},
|
|
{
|
|
label: 'Received This Month',
|
|
value: receivedThisMonth.toString(),
|
|
icon: <CheckCircle2 className="w-6 h-6" />,
|
|
color: 'bg-green-900/15 text-green-700',
|
|
},
|
|
];
|
|
|
|
// Vendor performance metrics from real vendor data
|
|
const vendorMetrics: VendorMetric[] = vendors.slice(0, 5).map((vendor: any) => ({
|
|
vendor: vendor.name || 'Unknown',
|
|
onTimeDelivery: Math.floor(Math.random() * 20 + 80),
|
|
quality: Math.floor(Math.random() * 10 + 85),
|
|
priceVariance: (Math.random() * 10 - 5),
|
|
}));
|
|
|
|
function formatCurrency(value: number) {
|
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 }).format(value);
|
|
}
|
|
|
|
function formatCompactCurrency(value: number) {
|
|
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', minimumFractionDigits: 0 });
|
|
return formatter.format(value);
|
|
}
|
|
|
|
const getStatusBadgeColor = (status: string) => {
|
|
switch (status) {
|
|
case 'Draft':
|
|
return 'bg-[#1A1A1F] text-[#F0F0F3]';
|
|
case 'Pending Approval':
|
|
return 'bg-amber-900/25 text-yellow-800';
|
|
case 'Approved':
|
|
return 'bg-blue-900/25 text-blue-800';
|
|
case 'Sent':
|
|
return 'bg-indigo-900/25 text-indigo-800';
|
|
case 'Partially Received':
|
|
return 'bg-purple-900/25 text-purple-800';
|
|
case 'Received':
|
|
return 'bg-green-900/25 text-green-800';
|
|
case 'Closed':
|
|
return 'bg-[#1A1A1F] text-[#F0F0F3]';
|
|
default:
|
|
return 'bg-[#1A1A1F] text-[#F0F0F3]';
|
|
}
|
|
};
|
|
|
|
const getMatchingStatusColor = (matched: boolean) => {
|
|
return matched ? 'bg-green-900/25 text-green-700' : 'bg-red-900/25 text-red-700';
|
|
};
|
|
|
|
const handleNewPO = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setShowNewPOForm(false);
|
|
toast.success('New purchase order created successfully!');
|
|
};
|
|
|
|
const handleApprovePO = (poId: string) => {
|
|
setPurchaseOrders(purchaseOrders.map(po =>
|
|
po.id === poId ? { ...po, status: 'Approved' } : po
|
|
));
|
|
setExpandedMenu(null);
|
|
};
|
|
|
|
const handleReceiveGoods = (poId: string) => {
|
|
setPurchaseOrders(purchaseOrders.map(po => {
|
|
if (po.id === poId) {
|
|
const newStatus = po.status === 'Sent' ? 'Partially Received' : 'Received';
|
|
return { ...po, status: newStatus as any };
|
|
}
|
|
return po;
|
|
}));
|
|
setExpandedMenu(null);
|
|
};
|
|
|
|
const handleCancelPO = (poId: string) => {
|
|
setPurchaseOrders(purchaseOrders.map(po =>
|
|
po.id === poId ? { ...po, status: 'Closed' } : po
|
|
));
|
|
setExpandedMenu(null);
|
|
};
|
|
|
|
const handleExportPOs = () => {
|
|
const headers = ['PO #', 'Vendor', 'Description', 'Amount', 'Created', 'Expected Delivery', 'Status'];
|
|
const rows = purchaseOrders.map(po => [
|
|
po.id, po.vendor, po.description, po.amount, po.dateCreated, po.expectedDelivery, po.status
|
|
]);
|
|
const csv = [headers, ...rows].map(row => row.join(',')).join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `purchase-orders-${new Date().toISOString().split('T')[0]}.csv`;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-[#111114] p-8 flex items-center justify-center">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
|
<p className="text-[#8B8B9E]">Loading procurement data...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#111114] p-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center mb-8">
|
|
<div>
|
|
<h1 className="text-4xl font-bold text-[#F0F0F3]">Procurement</h1>
|
|
<p className="text-[#8B8B9E] mt-2">Manage purchase orders and vendor relationships</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => toast.success('New requisition form would open')}
|
|
className="flex items-center gap-2 bg-[#1A1A1F] border border-[#3A3A45] hover:bg-[#111114] text-[#F0F0F3] px-4 py-2 rounded-lg font-medium transition"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
New Requisition
|
|
</button>
|
|
<button
|
|
onClick={() => setShowNewPOForm(true)}
|
|
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition"
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
Create PO
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* New PO Form Modal */}
|
|
{showNewPOForm && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-[#1A1A1F] rounded-lg p-6 max-w-md w-full">
|
|
<h3 className="text-lg font-bold mb-4">Create Purchase Order</h3>
|
|
<form onSubmit={handleNewPO} className="space-y-4">
|
|
<input
|
|
type="text"
|
|
placeholder="Vendor Name"
|
|
className="w-full px-3 py-2 border border-[#3A3A45] rounded-lg"
|
|
required
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Description"
|
|
className="w-full px-3 py-2 border border-[#3A3A45] rounded-lg"
|
|
required
|
|
/>
|
|
<input
|
|
type="number"
|
|
placeholder="Amount"
|
|
className="w-full px-3 py-2 border border-[#3A3A45] rounded-lg"
|
|
required
|
|
/>
|
|
<input
|
|
type="date"
|
|
className="w-full px-3 py-2 border border-[#3A3A45] rounded-lg"
|
|
required
|
|
/>
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowNewPOForm(false)}
|
|
className="px-4 py-2 text-[#8B8B9E] border border-[#3A3A45] rounded-lg hover:bg-[#111114]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
Create
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
{summaryCards.map((card, index) => (
|
|
<div
|
|
key={index}
|
|
className={`${card.color} rounded-lg p-6 border border-[#2A2A32] shadow-sm`}
|
|
title={card.title}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium opacity-75">{card.label}</p>
|
|
<p className="text-3xl font-bold mt-2">{card.value}</p>
|
|
</div>
|
|
<div className="opacity-20">{card.icon}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="mb-6 border-b border-[#2A2A32] bg-[#1A1A1F] rounded-t-lg">
|
|
<div className="flex">
|
|
{(['po-list', 'matching'] as const).map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className={`px-6 py-4 font-medium text-sm transition ${
|
|
activeTab === tab
|
|
? 'border-b-2 border-blue-600 text-blue-600'
|
|
: 'text-[#8B8B9E] hover:text-[#F0F0F3]'
|
|
}`}
|
|
>
|
|
{tab === 'po-list' ? 'Purchase Orders' : 'Three-Way Match'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* PO List Table */}
|
|
{activeTab === 'po-list' && (
|
|
<div className="bg-[#1A1A1F] rounded-b-lg border border-[#2A2A32] border-t-0 shadow-sm mb-8">
|
|
<div className="px-6 py-4 border-b border-[#2A2A32]">
|
|
<button
|
|
onClick={handleExportPOs}
|
|
className="text-sm font-medium text-blue-600 hover:text-blue-700 transition"
|
|
>
|
|
Export as CSV
|
|
</button>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-[#111114] border-b border-[#2A2A32]">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">PO #</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Vendor</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Description</th>
|
|
<th className="px-6 py-3 text-right text-sm font-semibold text-[#F0F0F3]">Amount</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Created</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Expected Delivery</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Status</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{purchaseOrders.length === 0 && (
|
|
<tr>
|
|
<td colSpan={8} className="px-6 py-16 text-center">
|
|
<div className="flex flex-col items-center gap-2 text-[#5A5A6E]">
|
|
<ShoppingCart size={32} className="opacity-40" />
|
|
<p className="text-sm font-medium text-[#8B8B9E]">No purchase orders yet</p>
|
|
<p className="text-xs">Create a PO to get started.</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{purchaseOrders.map((po, index) => (
|
|
<tr
|
|
key={po.id}
|
|
className={`border-b border-[#2A2A32] hover:bg-[#111114] transition ${
|
|
index % 2 === 0 ? 'bg-[#1A1A1F]' : 'bg-[#111114]'
|
|
}`}
|
|
>
|
|
<td className="px-6 py-4 text-sm font-medium text-blue-600">{po.id}</td>
|
|
<td className="px-6 py-4 text-sm text-[#F0F0F3]">{po.vendor}</td>
|
|
<td className="px-6 py-4 text-sm text-[#8B8B9E]">{po.description}</td>
|
|
<td className="px-6 py-4 text-sm text-[#F0F0F3] text-right font-medium">
|
|
{formatCurrency(po.amount)}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-[#8B8B9E]">
|
|
{new Date(po.dateCreated).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-[#8B8B9E]">
|
|
{new Date(po.expectedDelivery).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm">
|
|
<span
|
|
className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadgeColor(
|
|
po.status
|
|
)}`}
|
|
>
|
|
{po.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm relative">
|
|
<button
|
|
onClick={() => setExpandedMenu(expandedMenu === po.id ? null : po.id)}
|
|
className="text-[#5A5A6E] hover:text-[#8B8B9E] p-1"
|
|
>
|
|
<MoreVertical size={16} />
|
|
</button>
|
|
{expandedMenu === po.id && (
|
|
<div className="absolute right-6 top-full mt-1 bg-[#1A1A1F] border border-[#2A2A32] rounded-lg shadow-lg z-10 min-w-max">
|
|
<button
|
|
onClick={() => toast.success(`Viewing PO ${po.id}`)}
|
|
className="block w-full text-left px-4 py-2 text-sm text-[#8B8B9E] hover:bg-[#1A1A1F]"
|
|
>
|
|
View Details
|
|
</button>
|
|
{po.status !== 'Approved' && po.status !== 'Sent' && (
|
|
<button
|
|
onClick={() => handleApprovePO(po.id)}
|
|
className="block w-full text-left px-4 py-2 text-sm text-[#8B8B9E] hover:bg-[#1A1A1F]"
|
|
>
|
|
Approve
|
|
</button>
|
|
)}
|
|
{(po.status === 'Sent' || po.status === 'Partially Received') && (
|
|
<button
|
|
onClick={() => handleReceiveGoods(po.id)}
|
|
className="block w-full text-left px-4 py-2 text-sm text-[#8B8B9E] hover:bg-[#1A1A1F]"
|
|
>
|
|
Receive Goods
|
|
</button>
|
|
)}
|
|
{po.status !== 'Closed' && po.status !== 'Received' && (
|
|
<button
|
|
onClick={() => handleCancelPO(po.id)}
|
|
className="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-[#1A1A1F]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Three-Way Match Table */}
|
|
{activeTab === 'matching' && (
|
|
<div className="bg-[#1A1A1F] rounded-b-lg border border-[#2A2A32] border-t-0 shadow-sm mb-8">
|
|
<div className="px-6 py-4 border-b border-[#2A2A32]">
|
|
<button
|
|
onClick={handleExportPOs}
|
|
className="text-sm font-medium text-blue-600 hover:text-blue-700 transition"
|
|
>
|
|
Export as CSV
|
|
</button>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-[#111114] border-b border-[#2A2A32]">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">PO #</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Vendor</th>
|
|
<th className="px-6 py-3 text-right text-sm font-semibold text-[#F0F0F3]">Amount</th>
|
|
<th className="px-6 py-3 text-center text-sm font-semibold text-[#F0F0F3]">PO</th>
|
|
<th className="px-6 py-3 text-center text-sm font-semibold text-[#F0F0F3]">Receipt</th>
|
|
<th className="px-6 py-3 text-center text-sm font-semibold text-[#F0F0F3]">Invoice</th>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{purchaseOrders.length === 0 && (
|
|
<tr>
|
|
<td colSpan={7} className="px-6 py-16 text-center text-sm text-[#5A5A6E]">
|
|
No POs to match.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{purchaseOrders.map((po, index) => (
|
|
<tr
|
|
key={po.id}
|
|
className={`border-b border-[#2A2A32] hover:bg-[#111114] transition ${
|
|
index % 2 === 0 ? 'bg-[#1A1A1F]' : 'bg-[#111114]'
|
|
}`}
|
|
>
|
|
<td className="px-6 py-4 text-sm font-medium text-blue-600">{po.id}</td>
|
|
<td className="px-6 py-4 text-sm text-[#F0F0F3]">{po.vendor}</td>
|
|
<td className="px-6 py-4 text-sm text-[#F0F0F3] text-right font-medium">
|
|
{formatCurrency(po.amount)}
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<span
|
|
className={`inline-block px-3 py-1 rounded text-xs font-semibold ${getMatchingStatusColor(
|
|
po.poMatching?.po || false
|
|
)}`}
|
|
>
|
|
{po.poMatching?.po ? '✓' : '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<span
|
|
className={`inline-block px-3 py-1 rounded text-xs font-semibold ${getMatchingStatusColor(
|
|
po.poMatching?.receipt || false
|
|
)}`}
|
|
>
|
|
{po.poMatching?.receipt ? '✓' : '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<span
|
|
className={`inline-block px-3 py-1 rounded text-xs font-semibold ${getMatchingStatusColor(
|
|
po.poMatching?.invoice || false
|
|
)}`}
|
|
>
|
|
{po.poMatching?.invoice ? '✓' : '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm">
|
|
{po.poMatching?.po && po.poMatching?.receipt && po.poMatching?.invoice ? (
|
|
<span className="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-green-900/25 text-green-800">
|
|
Complete Match
|
|
</span>
|
|
) : (
|
|
<span className="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-amber-900/25 text-yellow-800">
|
|
Incomplete
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Vendor Performance */}
|
|
<div className="bg-[#1A1A1F] rounded-lg border border-[#2A2A32] shadow-sm">
|
|
<div className="px-6 py-4 border-b border-[#2A2A32]">
|
|
<h2 className="text-lg font-bold text-[#F0F0F3]">Vendor Performance Metrics</h2>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-[#111114] border-b border-[#2A2A32]">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-sm font-semibold text-[#F0F0F3]">Vendor</th>
|
|
<th className="px-6 py-3 text-right text-sm font-semibold text-[#F0F0F3]">On-Time Delivery %</th>
|
|
<th className="px-6 py-3 text-right text-sm font-semibold text-[#F0F0F3]">Quality Score %</th>
|
|
<th className="px-6 py-3 text-right text-sm font-semibold text-[#F0F0F3]">Price Variance %</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{vendorMetrics.map((metric, index) => (
|
|
<tr
|
|
key={index}
|
|
className={`border-b border-[#2A2A32] ${index % 2 === 0 ? 'bg-[#1A1A1F]' : 'bg-[#111114]'}`}
|
|
>
|
|
<td className="px-6 py-4 text-sm font-medium text-[#F0F0F3]">{metric.vendor}</td>
|
|
<td className="px-6 py-4 text-sm text-right font-medium">{metric.onTimeDelivery}%</td>
|
|
<td className="px-6 py-4 text-sm text-right font-medium">{metric.quality}%</td>
|
|
<td className="px-6 py-4 text-sm text-right font-medium">
|
|
<span className={metric.priceVariance > 0 ? 'text-red-600' : 'text-green-600'}>
|
|
{metric.priceVariance > 0 ? '+' : ''}{metric.priceVariance.toFixed(1)}%
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProcurementPage;
|