initial commit

This commit is contained in:
Josh Myers
2026-04-09 20:36:10 -07:00
commit 4681b1a3c8
248 changed files with 97032 additions and 0 deletions

View File

@@ -0,0 +1,574 @@
'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;