From db61ac2152ef7491692221a514491b479d5d3d1e Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Fri, 29 May 2026 20:07:19 +0530 Subject: [PATCH] =?UTF-8?q?feat(subportal):=20grounded=20dashboard=20KPIs?= =?UTF-8?q?=20+=20My=20Projects;=20scoped=20project=20list/detail=20pages?= =?UTF-8?q?=20+=20routes;=20task=E2=86=92project=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 9 +- .../subcontractor/SubContractorDashboard.jsx | 146 ++++++++-- .../SubcontractorProjectDetailPage.jsx | 270 ++++++++++++++++++ .../SubcontractorProjectsPage.jsx | 138 +++++++++ .../SubcontractorTaskDetailPage.jsx | 40 ++- 5 files changed, 576 insertions(+), 27 deletions(-) create mode 100644 src/pages/subcontractor/SubcontractorProjectDetailPage.jsx create mode 100644 src/pages/subcontractor/SubcontractorProjectsPage.jsx diff --git a/src/App.jsx b/src/App.jsx index 4443fbf..130e2db 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,6 +32,8 @@ import VendorOrders from './pages/vendor/VendorOrders'; import ContractorDashboard from './pages/contractor/ContractorDashboard'; import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard'; import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage'; +import SubcontractorProjectsPage from './pages/subcontractor/SubcontractorProjectsPage'; +import SubcontractorProjectDetailPage from './pages/subcontractor/SubcontractorProjectDetailPage'; import CreateLeadPage from './pages/CreateLeadPage'; import LeadsListPage from './pages/LeadsListPage'; import LeadDetailPage from './pages/LeadDetailPage'; @@ -401,7 +403,12 @@ function App() { } /> - + + + } /> + + } /> { @@ -32,7 +33,7 @@ const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIn }; // If a specific task is selected, show detail view - if (selectedItem && (type === 'tasks_in_progress' || type === 'pending_tasks' || type === 'jobs_completed')) { + if (selectedItem && (type === 'tasks_in_progress' || type === 'awaiting_review' || type === 'jobs_completed')) { const task = selectedItem; return createPortal(
@@ -101,7 +102,7 @@ const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIn // Main list modal const renderContent = () => { - if (type === 'tasks_in_progress' || type === 'pending_tasks' || type === 'jobs_completed') { + if (type === 'tasks_in_progress' || type === 'awaiting_review' || type === 'jobs_completed') { const items = tasks || []; return items.length > 0 ? (
@@ -207,22 +208,56 @@ const SubContractorDashboard = () => { [notifications, subId], ); - // Aggregate Tasks (legacy milestone-based for stat parity below) - const myTasks = projects.flatMap(p => - p.milestones.filter(m => m.assignedTo === subId) - .map(m => ({ ...m, projectAddress: p.address, projectId: p.id, projectStatus: p.status })) - ); - - const inProgressTasks = myTasks.filter(t => t.status === 'in_progress'); - const pendingTasks = myTasks.filter(t => t.status === 'pending'); - const completedTasks = myTasks.filter(t => t.status === 'completed'); - // CRM-assigned tasks (the real "My Assignments") const myAssignments = useMemo( () => subcontractorTasks.filter(t => t.subcontractorId === subId), [subcontractorTasks, subId], ); + // KPI derivations from myAssignments + const kpiInProgress = useMemo( + () => myAssignments.filter(t => t.status === 'In Progress').length, + [myAssignments], + ); + const kpiAwaitingReview = useMemo( + () => myAssignments.filter(t => t.status === 'Post-Work Review').length, + [myAssignments], + ); + const kpiCompleted = useMemo( + () => myAssignments.filter(t => t.status === 'Completed').length, + [myAssignments], + ); + const kpiPendingPayouts = useMemo( + () => myAssignments + .filter(t => t.paymentStatus !== 'Paid') + .reduce((sum, t) => { + const fees = (t.fees || []).reduce((s, f) => s + (Number(f.amount) || 0), 0); + const expenses = (t.expenses || []).reduce((s, e) => s + (Number(e.amount) || 0), 0); + return sum + fees + expenses; + }, 0), + [myAssignments], + ); + + // SubDetailModal drill-down subsets (from myAssignments) + const kpiInProgressTasks = useMemo( + () => myAssignments.filter(t => t.status === 'In Progress'), + [myAssignments], + ); + const kpiAwaitingReviewTasks = useMemo( + () => myAssignments.filter(t => t.status === 'Post-Work Review'), + [myAssignments], + ); + const kpiCompletedTasks = useMemo( + () => myAssignments.filter(t => t.status === 'Completed'), + [myAssignments], + ); + + // My Projects (from subcontractorProjects selector) + const myProjects = useMemo( + () => subcontractorProjects(projects, subcontractorTasks, subId), + [projects, subcontractorTasks, subId], + ); + // Financials const myInvoices = projects.flatMap(p => (p.invoices || []).filter(i => i.submittedBy === subId) @@ -307,7 +342,7 @@ const SubContractorDashboard = () => { items: myInvoices.map(inv => ({ date: inv.dueDate || 'N/A', description: `Invoice #${inv.id}`, - project: myTasks.find(t => t.projectId === inv.projectId)?.projectAddress || 'Unknown', + project: projectById.get(inv.projectId)?.address || 'Unknown', status: inv.status, amount: inv.amount })) @@ -332,7 +367,7 @@ const SubContractorDashboard = () => {

Field Task Center

- You have {inProgressTasks.length} active tasks and ${pendingPay.toLocaleString()} in pending payouts. + You have {kpiInProgress} active tasks and ${kpiPendingPayouts.toLocaleString('en-US')} in pending payouts.

@@ -362,16 +397,16 @@ const SubContractorDashboard = () => { {/* KPI Cards */}
openDetailModal('tasks_in_progress', 'Tasks In Progress')} className="cursor-pointer"> - +
-
openDetailModal('pending_tasks', 'Pending Tasks')} className="cursor-pointer"> - +
openDetailModal('awaiting_review', 'Awaiting Review')} className="cursor-pointer"> +
setIsFinancialModalOpen(true)} className="cursor-pointer"> - +
openDetailModal('jobs_completed', 'Jobs Completed')} className="cursor-pointer"> - +
@@ -437,6 +472,71 @@ const SubContractorDashboard = () => {
+ {/* My Projects */} + +
+

+ + My Projects +

+ {myProjects.length} project{myProjects.length === 1 ? '' : 's'} +
+ {myProjects.length === 0 ? ( +
+ +

No projects assigned yet.

+

Projects linked to your tasks will appear here.

+
+ ) : ( +
+ {myProjects.map(p => { + const statusTone = { + 'In Progress': 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400', + 'Completed': 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400', + 'On Hold': 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400', + 'Cancelled': 'bg-zinc-100 text-zinc-500 dark:bg-white/10 dark:text-zinc-500', + }[p.status] || 'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-400'; + const shortAddress = p.address ? p.address.split(',')[0] : '—'; + return ( +
navigate(`/subcontractor/projects/${p.id}`)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate(`/subcontractor/projects/${p.id}`); }} + className="group flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer" + > +
+
+ +
+
+

+ {p.projectType || 'Project'} +

+

+ {shortAddress} +

+
+
+
+
+ + {p.phase || p.status} + +

+ {p.openTaskCount} open / {p.taskCount} tasks +

+
+ +
+
+ ); + })} +
+ )} +
+ {/* Project History & Payment Tracking */}
@@ -698,9 +798,9 @@ const SubContractorDashboard = () => { title={detailModal.title} type={detailModal.type} tasks={ - detailModal.type === 'tasks_in_progress' ? inProgressTasks : - detailModal.type === 'pending_tasks' ? pendingTasks : - detailModal.type === 'jobs_completed' ? completedTasks : [] + detailModal.type === 'tasks_in_progress' ? kpiInProgressTasks : + detailModal.type === 'awaiting_review' ? kpiAwaitingReviewTasks : + detailModal.type === 'jobs_completed' ? kpiCompletedTasks : [] } invoices={myInvoices} clockIns={clockInData} diff --git a/src/pages/subcontractor/SubcontractorProjectDetailPage.jsx b/src/pages/subcontractor/SubcontractorProjectDetailPage.jsx new file mode 100644 index 0000000..6f2e599 --- /dev/null +++ b/src/pages/subcontractor/SubcontractorProjectDetailPage.jsx @@ -0,0 +1,270 @@ +import React from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; +import { useMockStore } from '../../data/mockStore'; +import { scopedProjectForSub } from '../../data/selectors'; +import { + ArrowLeft, MapPin, CalendarDays, CheckSquare, DollarSign, + ChevronRight, Flag, Wallet, Lock +} from 'lucide-react'; + +// --- helpers --- +const fmtUSD = (n) => + Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }); + +const statusTone = (s) => { + if (!s) return 'bg-zinc-100 text-zinc-500 dark:bg-white/10 dark:text-zinc-400'; + const lower = s.toLowerCase().replace(/[ _]/g, ''); + if (lower === 'completed' || lower === 'complete' || lower === 'done') return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400'; + if (lower === 'inprogress' || lower === 'active') return 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400'; + if (lower === 'onhold' || lower === 'pending') return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400'; + if (lower === 'cancelled' || lower === 'canceled') return 'bg-zinc-100 text-zinc-500 dark:bg-white/10 dark:text-zinc-400'; + return 'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-300'; +}; + +// --- main component --- +const SubcontractorProjectDetailPage = () => { + const { projectId } = useParams(); + const navigate = useNavigate(); + const { user } = useAuth(); + const { projects, subcontractorTasks } = useMockStore(); + + const project = projects.find((p) => p.id === projectId); + const scoped = scopedProjectForSub(project, user?.id, subcontractorTasks); + + // Access guard + const hasAccess = + scoped && + !( + scoped.myTasks.length === 0 && + !(project?.subcontractorIds || []).includes(user?.id) + ); + + if (!hasAccess) { + return ( +
+
+ +
+
+

+ You don't have access to this project +

+

+ This project either doesn't exist or you haven't been assigned to it. +

+
+ + + Back to My Projects + +
+ ); + } + + const pct = Math.min(Math.max(scoped.completionPercentage ?? 0, 0), 100); + + return ( +
+ + {/* Back link */} + + + {/* ── Project header ── */} +
+ {/* Title row */} +
+
+

+ {scoped.projectType || 'Project'} +

+

+ + {scoped.address || '—'} +

+ {scoped.customerName && ( +

{scoped.customerName}

+ )} +
+ {/* Status badges */} +
+ {scoped.status && ( + + {scoped.status.replace(/_/g, ' ')} + + )} + {scoped.phase && ( + + {scoped.phase} + + )} + {scoped.lifecycleStage && ( + + {scoped.lifecycleStage} + + )} +
+
+ + {/* Completion progress */} +
+
+ Completion + {pct}% +
+
+
+
+
+ + {/* Start / end dates */} + {(scoped.startDate || scoped.endDate) && ( +
+ {scoped.startDate && ( + + + Start: {scoped.startDate} + + )} + {scoped.endDate && ( + + + End: {scoped.endDate} + + )} +
+ )} +
+ + {/* ── Schedule (milestones) ── */} +
+
+ +

Schedule

+ + {scoped.milestones.length} milestone{scoped.milestones.length !== 1 ? 's' : ''} + +
+
+ {scoped.milestones.length === 0 ? ( +

No milestones on record.

+ ) : ( + scoped.milestones.map((m) => ( +
+
+

{m.name}

+ {m.dueDate && ( +

+ Due: {m.dueDate} +

+ )} +
+ {m.status && ( + + {m.status.replace(/_/g, ' ')} + + )} +
+ )) + )} +
+
+ + {/* ── My Tasks on this job ── */} +
+
+ +

My Tasks on this Job

+ + {scoped.myTasks.length} task{scoped.myTasks.length !== 1 ? 's' : ''} + +
+
+ {scoped.myTasks.length === 0 ? ( +

No tasks assigned to you on this project.

+ ) : ( + scoped.myTasks.map((t) => ( + + )) + )} +
+
+ + {/* ── My Payments ── */} +
+
+ +

My Payments

+
+
+ {[ + { label: 'Total Earnings', value: fmtUSD(scoped.myPayments.earnings), icon: DollarSign, tone: 'text-zinc-900 dark:text-white' }, + { label: 'Paid', value: fmtUSD(scoped.myPayments.paid), icon: DollarSign, tone: 'text-emerald-600 dark:text-emerald-400' }, + { label: 'Unpaid', value: fmtUSD(scoped.myPayments.unpaid), icon: DollarSign, tone: 'text-amber-600 dark:text-amber-400' }, + ].map(({ label, value, icon: Icon, tone }) => ( +
+
+ + {label} +
+

{value}

+
+ ))} +
+
+ + {/* Back link (bottom) */} +
+ + + Back to My Projects + +
+ +
+ ); +}; + +export default SubcontractorProjectDetailPage; diff --git a/src/pages/subcontractor/SubcontractorProjectsPage.jsx b/src/pages/subcontractor/SubcontractorProjectsPage.jsx new file mode 100644 index 0000000..c52b12a --- /dev/null +++ b/src/pages/subcontractor/SubcontractorProjectsPage.jsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; +import { useMockStore } from '../../data/mockStore'; +import { subcontractorProjects } from '../../data/selectors'; +import { Briefcase, MapPin, CheckSquare, ChevronRight, FolderOpen } from 'lucide-react'; + +const statusTone = (s) => { + if (!s) return 'bg-zinc-100 text-zinc-500 dark:bg-white/10 dark:text-zinc-400'; + const lower = s.toLowerCase(); + if (lower === 'completed' || lower === 'complete') return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400'; + if (lower === 'in_progress' || lower === 'in progress' || lower === 'active') return 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400'; + if (lower === 'on_hold' || lower === 'on hold' || lower === 'pending') return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400'; + if (lower === 'cancelled' || lower === 'canceled') return 'bg-zinc-100 text-zinc-500 dark:bg-white/10 dark:text-zinc-400'; + return 'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-300'; +}; + +const SubcontractorProjectsPage = () => { + const navigate = useNavigate(); + const { user } = useAuth(); + const { projects, subcontractorTasks } = useMockStore(); + + const myProjects = subcontractorProjects(projects, subcontractorTasks, user?.id); + + return ( +
+ {/* Header */} +
+
+

My Projects

+

+ {myProjects.length === 0 + ? 'No projects assigned yet.' + : `${myProjects.length} project${myProjects.length === 1 ? '' : 's'} assigned to you`} +

+
+
+ + {myProjects.length} total + +
+
+ + {/* Empty state */} + {myProjects.length === 0 && ( +
+ +

No projects assigned yet

+

+ When a project manager assigns you to a job, it will appear here. +

+
+ )} + + {/* Project grid */} + {myProjects.length > 0 && ( +
+ {myProjects.map((p) => ( + + ))} +
+ )} +
+ ); +}; + +export default SubcontractorProjectsPage; diff --git a/src/pages/subcontractor/SubcontractorTaskDetailPage.jsx b/src/pages/subcontractor/SubcontractorTaskDetailPage.jsx index 95e52a3..8c74819 100644 --- a/src/pages/subcontractor/SubcontractorTaskDetailPage.jsx +++ b/src/pages/subcontractor/SubcontractorTaskDetailPage.jsx @@ -9,6 +9,7 @@ import { DollarSign, Receipt, Camera, Image as ImageIcon, MessageSquare, Lock, Send, Clock, PauseCircle, CheckCircle, RefreshCcw, AlertCircle, X, Plus, Upload, Activity, ImagePlus, Loader2, Play, Trash2, AlertTriangle, Users, UserCheck, + Briefcase, } from 'lucide-react'; // --------------------------------------------------------------------------- @@ -103,7 +104,7 @@ const SubcontractorTaskDetailPage = () => { const navigate = useNavigate(); const { user } = useAuth(); const { - subcontractorTasks, subSelfStatuses, + subcontractorTasks, subSelfStatuses, projects, setSubcontractorTaskStatus, addSubcontractorTaskMessage, addSubcontractorTaskExpense, addSubcontractorTaskFee, addSubcontractorTaskActivity, @@ -111,6 +112,11 @@ const SubcontractorTaskDetailPage = () => { const task = useMemo(() => subcontractorTasks.find(t => t.id === taskId), [subcontractorTasks, taskId]); + const project = useMemo( + () => (task?.projectId ? (projects || []).find(p => p.id === task.projectId) : undefined), + [task, projects], + ); + const [logExpenseOpen, setLogExpenseOpen] = useState(false); const [logFeeOpen, setLogFeeOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(null); @@ -197,6 +203,19 @@ const SubcontractorTaskDetailPage = () => { > Subcontractor Tasks + {project && ( + <> + + + + )} {task.title} @@ -207,8 +226,10 @@ const SubcontractorTaskDetailPage = () => { setLogExpenseOpen(true)} onLogFee={() => setLogFeeOpen(true)} + onNavigateProject={() => navigate(`/subcontractor/projects/${task.projectId}`)} /> {/* Two-column shell below: main content + financials/notifications rail */} @@ -288,11 +309,12 @@ const SubcontractorTaskDetailPage = () => { // =========================================================================== // Section 1 — Header / Task Details // =========================================================================== -const TaskHeaderCard = ({ task, overdue, onLogExpense, onLogFee }) => { +const TaskHeaderCard = ({ task, overdue, project, onLogExpense, onLogFee, onNavigateProject }) => { const closed = task.status === 'Completed' || task.status === 'Cancelled'; + const projectStreet = project?.address ? project.address.split(',')[0].trim() : null; return ( - {/* Top row: ID + badges */} + {/* Top row: ID + badges + project chip */}
{task.id} @@ -303,6 +325,18 @@ const TaskHeaderCard = ({ task, overdue, onLogExpense, onLogFee }) => { Overdue )} + {project && ( + + )}
{/* Title + actions */}