5fef584d7d
- Added visually hidden labels, IDs, and names to form inputs across all key components and pages (modals, directories, chatbots) - Added tabIndex and onKeyDown handlers to clickable table rows in OwnerProjectDetail for keyboard accessibility - Validated existing ARIA roles and Escape key bindings on modal components
241 lines
15 KiB
React
241 lines
15 KiB
React
import React, { useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { X, DollarSign, TrendingUp, TrendingDown, Download, Search, Filter } from 'lucide-react';
|
|
|
|
const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [sortConfig, setSortConfig] = useState({ key: 'date', direction: 'desc' });
|
|
|
|
// Close on Escape key
|
|
React.useEffect(() => {
|
|
const handleKeyDown = (e) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
|
|
if (isOpen) {
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
}
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, onClose]);
|
|
|
|
if (!isOpen || !data) return null;
|
|
|
|
const formatCurrency = (amount) => {
|
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
|
};
|
|
|
|
const handleSort = (key) => {
|
|
let direction = 'asc';
|
|
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
|
direction = 'desc';
|
|
}
|
|
setSortConfig({ key, direction });
|
|
};
|
|
|
|
// Filter and sort data
|
|
const filteredData = (data.items || []).filter(item =>
|
|
item.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
item.project?.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
const sortedData = [...filteredData].sort((a, b) => {
|
|
if (a[sortConfig.key] < b[sortConfig.key]) {
|
|
return sortConfig.direction === 'asc' ? -1 : 1;
|
|
}
|
|
if (a[sortConfig.key] > b[sortConfig.key]) {
|
|
return sortConfig.direction === 'asc' ? 1 : -1;
|
|
}
|
|
return 0;
|
|
});
|
|
|
|
return createPortal(
|
|
<div className="fixed top-0 left-0 w-screen h-[100dvh] z-[9999] flex items-end sm:items-center justify-center sm:p-6" role="dialog" aria-modal="true">
|
|
<div
|
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="relative w-full sm:max-w-5xl h-[85dvh] sm:h-auto sm:max-h-[85vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-10 sm:zoom-in-95 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
|
|
|
|
{/* Mobile Drag Handle */}
|
|
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
|
|
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="px-4 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-start bg-zinc-50/50 dark:bg-white/5">
|
|
<div className="flex-1 min-w-0">
|
|
<h2 className="text-lg sm:text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
|
<DollarSign className="text-emerald-500 shrink-0" size={20} />
|
|
<span className="truncate">{role === 'CONTRACTOR' ? 'Budget Overview' : 'Earnings Summary'}</span>
|
|
</h2>
|
|
<p className="text-zinc-500 dark:text-zinc-400 text-xs sm:text-sm mt-1">
|
|
{role === 'CONTRACTOR' ? 'Total Budget Managed' : 'Total Earned (YTD)'}:
|
|
<span className="font-mono font-bold text-zinc-900 dark:text-white ml-1">{formatCurrency(data.total || 0)}</span>
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 sm:gap-3 ml-2">
|
|
<button className="hidden sm:block p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors" title="Export CSV">
|
|
<Download size={20} />
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-zinc-200 dark:border-white/10 bg-white dark:bg-[#121214]">
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
|
|
<div className="p-3 sm:p-4 rounded-xl bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-100 dark:border-emerald-500/20">
|
|
<div className="flex items-center gap-2 mb-1 sm:mb-2">
|
|
<TrendingUp size={16} className="text-emerald-600 dark:text-emerald-400" />
|
|
<span className="text-[10px] sm:text-xs font-bold uppercase text-emerald-600 dark:text-emerald-400">
|
|
{role === 'CONTRACTOR' ? 'Total Budget' : 'Paid'}
|
|
</span>
|
|
</div>
|
|
<p className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(data.paid || data.total || 0)}</p>
|
|
</div>
|
|
<div className="p-3 sm:p-4 rounded-xl bg-amber-50 dark:bg-amber-500/5 border border-amber-100 dark:border-amber-500/20">
|
|
<div className="flex items-center gap-2 mb-1 sm:mb-2">
|
|
<TrendingDown size={16} className="text-amber-600 dark:text-amber-400" />
|
|
<span className="text-[10px] sm:text-xs font-bold uppercase text-amber-600 dark:text-amber-400">
|
|
{role === 'CONTRACTOR' ? 'Spent' : 'Pending'}
|
|
</span>
|
|
</div>
|
|
<p className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(data.spent || data.pending || 0)}</p>
|
|
</div>
|
|
<div className="p-3 sm:p-4 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-100 dark:border-blue-500/20">
|
|
<div className="flex items-center gap-2 mb-1 sm:mb-2">
|
|
<DollarSign size={16} className="text-blue-600 dark:text-blue-400" />
|
|
<span className="text-[10px] sm:text-xs font-bold uppercase text-blue-600 dark:text-blue-400">
|
|
{role === 'CONTRACTOR' ? 'Remaining' : 'Total Earned'}
|
|
</span>
|
|
</div>
|
|
<p className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(data.remaining || data.total || 0)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-zinc-200 dark:border-white/5 flex flex-col sm:flex-row gap-3 sm:gap-4 justify-between bg-white dark:bg-[#121214]">
|
|
<div className="relative w-full sm:w-96">
|
|
<label htmlFor="search-contractor-financial" className="sr-only">Search transactions</label>
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
|
<input
|
|
id="search-contractor-financial"
|
|
name="search"
|
|
type="text"
|
|
placeholder="Search transactions..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-9 pr-4 py-2 rounded-xl bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
|
/>
|
|
</div>
|
|
<button className="hidden sm:flex items-center gap-2 px-4 py-2 rounded-xl border border-zinc-200 dark:border-white/10 text-sm font-medium hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
|
<Filter size={16} /> Filter
|
|
</button>
|
|
</div>
|
|
|
|
{/* Mobile Card View */}
|
|
<div className="flex-1 overflow-y-auto sm:hidden custom-scrollbar bg-white dark:bg-[#121214]">
|
|
{sortedData.length > 0 ? (
|
|
<div className="divide-y divide-zinc-100 dark:divide-white/5">
|
|
{sortedData.map((item, idx) => (
|
|
<div key={idx} className="px-4 py-3">
|
|
<div className="flex justify-between items-start mb-1.5">
|
|
<div className="flex-1 min-w-0 mr-3">
|
|
<p className="font-semibold text-sm text-zinc-900 dark:text-white truncate">{item.description || item.project}</p>
|
|
{item.project && <p className="text-xs text-zinc-500 truncate">{item.project}</p>}
|
|
</div>
|
|
<span className="font-mono font-medium text-sm text-zinc-900 dark:text-white shrink-0">{formatCurrency(item.amount)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-zinc-500 font-mono">{item.date}</span>
|
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${item.status === 'paid' || item.status === 'completed'
|
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
|
}`}>{item.status}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="py-20 text-center text-zinc-500">No transactions found.</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Desktop Table View */}
|
|
<div className="flex-1 overflow-y-auto overflow-x-auto hidden sm:block custom-scrollbar bg-white dark:bg-[#121214]">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead className="sticky top-0 z-10 bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10 shadow-sm">
|
|
<tr>
|
|
{[
|
|
{ key: 'date', label: 'Date' },
|
|
{ key: 'description', label: role === 'CONTRACTOR' ? 'Project' : 'Description' },
|
|
{ key: 'status', label: 'Status' },
|
|
{ key: 'amount', label: 'Amount', align: 'right' }
|
|
].map((col) => (
|
|
<th
|
|
key={col.key}
|
|
onClick={() => handleSort(col.key)}
|
|
className={`px-6 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors ${col.align === 'right' ? 'text-right' : ''}`}
|
|
>
|
|
{col.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
|
{sortedData.length > 0 ? sortedData.map((item, idx) => (
|
|
<tr key={idx} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
|
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
|
|
{item.date}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="font-semibold text-base text-zinc-900 dark:text-white">{item.description || item.project}</div>
|
|
{item.project && <div className="text-xs text-zinc-500">{item.project}</div>}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide whitespace-nowrap ${item.status === 'paid' || item.status === 'completed'
|
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
|
}`}>
|
|
{item.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-right font-mono font-medium text-base text-zinc-900 dark:text-white whitespace-nowrap">
|
|
{formatCurrency(item.amount)}
|
|
</td>
|
|
</tr>
|
|
)) : (
|
|
<tr>
|
|
<td colSpan="4" className="py-20 text-center text-zinc-500">
|
|
No transactions found.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-4 sm:px-6 py-3 sm:py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-between items-center text-xs sm:text-sm">
|
|
<span className="text-zinc-500 dark:text-zinc-400">Showing {sortedData.length} transactions</span>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-zinc-500 dark:text-zinc-400">Total:</span>
|
|
<span className="text-base sm:text-lg font-bold text-zinc-900 dark:text-white">{formatCurrency(data.total || 0)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
};
|
|
|
|
export default FinancialSummaryModal;
|