adds subcontractor project history and payments
This commit is contained in:
@@ -5,7 +5,7 @@ import { useAuth } from '../../context/AuthContext';
|
||||
import { useMockStore } from '../../data/mockStore';
|
||||
import { StatCard } from '../../components/StatCard';
|
||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||
import { CheckSquare, Clock, Wallet, MapPin, Camera, X, Calendar, DollarSign, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { CheckSquare, Clock, Wallet, MapPin, Camera, X, Calendar, DollarSign, ChevronRight, AlertCircle, ArrowDown, ArrowUp, FileText, Receipt } from 'lucide-react';
|
||||
import TaskDetailsModal from '../../components/contractor/TaskDetailsModal';
|
||||
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
|
||||
import { NotificationsPanel } from '../../components/subcontractor/NotificationsPanel';
|
||||
@@ -239,6 +239,56 @@ const SubContractorDashboard = () => {
|
||||
{ date: '2026-02-14', project: '2612 Dunwick Dr — Interior Renovation', clockIn: '7:30 AM', clockOut: '5:00 PM', hours: '9.5' },
|
||||
], []);
|
||||
|
||||
// Project history & payment tracking
|
||||
const [historyFilter, setHistoryFilter] = useState('all'); // all | Paid | Requested | Pending
|
||||
const [historySort, setHistorySort] = useState('desc'); // desc = newest first
|
||||
const [noteModal, setNoteModal] = useState(null); // { title, body, project, requestedAt, receivedAt, status }
|
||||
|
||||
const projectById = useMemo(() => {
|
||||
const m = new Map();
|
||||
for (const p of projects) m.set(p.id, p);
|
||||
return m;
|
||||
}, [projects]);
|
||||
|
||||
const paymentHistoryRows = useMemo(() => {
|
||||
const items = myAssignments.map(t => {
|
||||
const project = t.projectId ? projectById.get(t.projectId) : null;
|
||||
const requestedAt = t.paymentRequestedAt || null;
|
||||
const receivedAt = t.paidAt || null;
|
||||
const status = t.paymentStatus === 'Paid'
|
||||
? 'Paid'
|
||||
: (requestedAt ? 'Requested' : 'Pending');
|
||||
return {
|
||||
id: t.id,
|
||||
projectId: t.projectId || null,
|
||||
projectName: project ? (project.projectType || project.address) : 'Standalone Task',
|
||||
projectAddress: project?.address || t.location || '',
|
||||
requestedAt,
|
||||
receivedAt,
|
||||
note: t.title || '',
|
||||
description: t.description || '',
|
||||
status,
|
||||
};
|
||||
});
|
||||
const filtered = historyFilter === 'all' ? items : items.filter(r => r.status === historyFilter);
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const av = a.requestedAt || '';
|
||||
const bv = b.requestedAt || '';
|
||||
if (av === bv) return 0;
|
||||
if (!av) return 1; // missing requested dates sink to bottom
|
||||
if (!bv) return -1;
|
||||
return historySort === 'desc' ? (bv > av ? 1 : -1) : (av > bv ? 1 : -1);
|
||||
});
|
||||
return sorted;
|
||||
}, [myAssignments, projectById, historyFilter, historySort]);
|
||||
|
||||
const historyCounts = useMemo(() => ({
|
||||
all: myAssignments.length,
|
||||
Paid: myAssignments.filter(t => t.paymentStatus === 'Paid').length,
|
||||
Requested: myAssignments.filter(t => t.paymentStatus !== 'Paid' && t.paymentRequestedAt).length,
|
||||
Pending: myAssignments.filter(t => !t.paymentRequestedAt).length,
|
||||
}), [myAssignments]);
|
||||
|
||||
// Modal states
|
||||
const [detailModal, setDetailModal] = useState({ isOpen: false, type: null, title: '' });
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
@@ -324,7 +374,7 @@ const SubContractorDashboard = () => {
|
||||
|
||||
{/* Main Task List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<SpotlightCard className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">My Assignments</h2>
|
||||
@@ -383,6 +433,151 @@ const SubContractorDashboard = () => {
|
||||
)}
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
|
||||
{/* Project History & Payment Tracking */}
|
||||
<SpotlightCard className="p-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<Receipt size={18} className="text-amber-500 dark:text-amber-400" />
|
||||
Project History & Payments
|
||||
</h2>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
Track payment requests and receipts across every project you've worked on.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{[
|
||||
{ key: 'all', label: 'All', tone: 'bg-zinc-100 dark:bg-white/10 text-zinc-700 dark:text-zinc-200' },
|
||||
{ key: 'Paid', label: 'Paid', tone: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-400' },
|
||||
{ key: 'Requested', label: 'Requested', tone: 'bg-blue-100 dark:bg-blue-500/15 text-blue-700 dark:text-blue-400' },
|
||||
{ key: 'Pending', label: 'Pending', tone: 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400' },
|
||||
].map(p => {
|
||||
const active = historyFilter === p.key;
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => setHistoryFilter(p.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-colors border ${active ? `${p.tone} border-transparent shadow-sm` : 'bg-transparent border-zinc-200 dark:border-white/10 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
{p.label}
|
||||
<span className="ml-1.5 opacity-70">{historyCounts[p.key] ?? 0}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistorySort(d => d === 'desc' ? 'asc' : 'desc')}
|
||||
className="px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider border border-zinc-200 dark:border-white/10 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors flex items-center gap-1.5"
|
||||
aria-label={`Sort by requested date ${historySort === 'desc' ? 'descending' : 'ascending'}`}
|
||||
>
|
||||
{historySort === 'desc' ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
|
||||
{historySort === 'desc' ? 'Newest' : 'Oldest'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{paymentHistoryRows.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<FileText size={28} className="mx-auto mb-3 text-zinc-400" />
|
||||
<p className="text-sm text-zinc-500">No project history yet.</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
{historyFilter === 'all' ? 'Tasks assigned to you will appear here.' : `No projects with status "${historyFilter}".`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
|
||||
<table className="w-full text-left min-w-[840px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Project ID</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Project Name</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHistorySort(d => d === 'desc' ? 'asc' : 'desc')}
|
||||
className="inline-flex items-center gap-1 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
title="Sort by requested date"
|
||||
>
|
||||
Requested
|
||||
{historySort === 'desc' ? <ArrowDown size={10} /> : <ArrowUp size={10} />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Received</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Note</th>
|
||||
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paymentHistoryRows.map(row => {
|
||||
const unpaid = row.status !== 'Paid';
|
||||
const statusTone = {
|
||||
Paid: 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-400 dark:border-emerald-500/20',
|
||||
Requested: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-400 dark:border-blue-500/20',
|
||||
Pending: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-400 dark:border-amber-500/20',
|
||||
}[row.status] || 'bg-zinc-100 text-zinc-600 border-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:border-white/10';
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/subcontractor/tasks/${row.id}`)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate(`/subcontractor/tasks/${row.id}`); }}
|
||||
className={`border-b border-zinc-100 dark:border-white/[0.03] hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors cursor-pointer group ${unpaid ? 'border-l-2 border-l-amber-400/60 dark:border-l-amber-500/40' : ''}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] font-mono text-zinc-500 dark:text-zinc-400">
|
||||
{row.projectId || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate max-w-[220px]" title={row.projectName}>
|
||||
{row.projectName}
|
||||
</div>
|
||||
{row.projectAddress && (
|
||||
<div className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate max-w-[220px]" title={row.projectAddress}>
|
||||
{row.projectAddress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] font-mono text-zinc-600 dark:text-zinc-300 whitespace-nowrap">
|
||||
{row.requestedAt ? new Date(row.requestedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-[11px] font-mono whitespace-nowrap ${row.receivedAt ? 'text-emerald-600 dark:text-emerald-400 font-semibold' : 'text-amber-600 dark:text-amber-400/80'}`}>
|
||||
{row.receivedAt ? new Date(row.receivedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : 'Not received'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setNoteModal(row); }}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-zinc-600 dark:text-zinc-300 hover:text-blue-600 dark:hover:text-blue-400 max-w-[220px] truncate"
|
||||
title={row.note}
|
||||
>
|
||||
<FileText size={11} className="shrink-0" />
|
||||
<span className="truncate">{row.note || 'View note'}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${statusTone}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SpotlightCard>
|
||||
</div>
|
||||
|
||||
{/* Right column: Earnings + Notifications */}
|
||||
@@ -421,6 +616,64 @@ const SubContractorDashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note detail modal */}
|
||||
{noteModal && createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" role="dialog" aria-modal="true">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setNoteModal(null)} />
|
||||
<div className="relative w-full max-w-lg bg-white dark:bg-[#121214] rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="px-6 py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-center bg-zinc-50/50 dark:bg-white/5">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Note Detail</h2>
|
||||
<button onClick={() => setNoteModal(null)} className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">Project</div>
|
||||
<div className="font-semibold text-zinc-900 dark:text-white">{noteModal.projectName}</div>
|
||||
{noteModal.projectAddress && (
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{noteModal.projectAddress}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
|
||||
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">Requested</div>
|
||||
<div className="font-mono text-zinc-900 dark:text-white">
|
||||
{noteModal.requestedAt ? new Date(noteModal.requestedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
|
||||
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">Received</div>
|
||||
<div className={`font-mono ${noteModal.receivedAt ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'}`}>
|
||||
{noteModal.receivedAt ? new Date(noteModal.receivedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : 'Not received'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">Note</div>
|
||||
<div className="text-sm text-zinc-900 dark:text-white font-semibold">{noteModal.note || '—'}</div>
|
||||
{noteModal.description && (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 mt-2 leading-relaxed whitespace-pre-line">{noteModal.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setNoteModal(null)}
|
||||
className="px-4 py-2 text-sm font-bold rounded-xl border border-zinc-200 dark:border-white/10 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { const id = noteModal.id; setNoteModal(null); navigate(`/subcontractor/tasks/${id}`); }}
|
||||
className="px-4 py-2 text-sm font-bold rounded-xl bg-blue-600 hover:bg-blue-500 text-white transition-colors"
|
||||
>
|
||||
Open Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<TaskDetailsModal isOpen={isTaskModalOpen} onClose={() => setIsTaskModalOpen(false)} task={selectedTask} onUpdate={handleTaskUpdate} />
|
||||
<FinancialSummaryModal isOpen={isFinancialModalOpen} onClose={() => setIsFinancialModalOpen(false)} role="SUBCONTRACTOR" data={financialData} />
|
||||
|
||||
Reference in New Issue
Block a user