feat(subportal): grounded dashboard KPIs + My Projects; scoped project list/detail pages + routes; task→project link

This commit is contained in:
Satyam Rastogi
2026-05-29 20:07:19 +05:30
parent 5ce956b37a
commit db61ac2152
5 changed files with 576 additions and 27 deletions
+8 -1
View File
@@ -32,6 +32,8 @@ import VendorOrders from './pages/vendor/VendorOrders';
import ContractorDashboard from './pages/contractor/ContractorDashboard'; import ContractorDashboard from './pages/contractor/ContractorDashboard';
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard'; import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage'; import SubcontractorTaskDetailPage from './pages/subcontractor/SubcontractorTaskDetailPage';
import SubcontractorProjectsPage from './pages/subcontractor/SubcontractorProjectsPage';
import SubcontractorProjectDetailPage from './pages/subcontractor/SubcontractorProjectDetailPage';
import CreateLeadPage from './pages/CreateLeadPage'; import CreateLeadPage from './pages/CreateLeadPage';
import LeadsListPage from './pages/LeadsListPage'; import LeadsListPage from './pages/LeadsListPage';
import LeadDetailPage from './pages/LeadDetailPage'; import LeadDetailPage from './pages/LeadDetailPage';
@@ -401,7 +403,12 @@ function App() {
} /> } />
<Route path="/subcontractor/projects" element={ <Route path="/subcontractor/projects" element={
<ProtectedRoute allowedRoles={['SUBCONTRACTOR']}> <ProtectedRoute allowedRoles={['SUBCONTRACTOR']}>
<ProjectList /> <SubcontractorProjectsPage />
</ProtectedRoute>
} />
<Route path="/subcontractor/projects/:projectId" element={
<ProtectedRoute allowedRoles={['SUBCONTRACTOR']}>
<SubcontractorProjectDetailPage />
</ProtectedRoute> </ProtectedRoute>
} /> } />
<Route path="/subcontractor/tasks/:taskId" element={ <Route path="/subcontractor/tasks/:taskId" element={
@@ -5,10 +5,11 @@ import { useAuth } from '../../context/AuthContext';
import { useMockStore } from '../../data/mockStore'; import { useMockStore } from '../../data/mockStore';
import { StatCard } from '../../components/StatCard'; import { StatCard } from '../../components/StatCard';
import { SpotlightCard } from '../../components/SpotlightCard'; import { SpotlightCard } from '../../components/SpotlightCard';
import { CheckSquare, Clock, Wallet, MapPin, Camera, X, Calendar, DollarSign, ChevronRight, AlertCircle, ArrowDown, ArrowUp, FileText, Receipt } from 'lucide-react'; import { CheckSquare, Clock, Wallet, MapPin, Camera, X, Calendar, DollarSign, ChevronRight, AlertCircle, ArrowDown, ArrowUp, FileText, Receipt, Briefcase } from 'lucide-react';
import TaskDetailsModal from '../../components/contractor/TaskDetailsModal'; import TaskDetailsModal from '../../components/contractor/TaskDetailsModal';
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal'; import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
import { NotificationsPanel } from '../../components/subcontractor/NotificationsPanel'; import { NotificationsPanel } from '../../components/subcontractor/NotificationsPanel';
import { subcontractorProjects } from '../../data/selectors';
// --- Reusable list modal for filtered tasks / clock-in / payment history --- // --- Reusable list modal for filtered tasks / clock-in / payment history ---
const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIns, onTaskClick }) => { const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIns, onTaskClick }) => {
@@ -32,7 +33,7 @@ const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIn
}; };
// If a specific task is selected, show detail view // 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; const task = selectedItem;
return createPortal( return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" role="dialog" aria-modal="true"> <div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" role="dialog" aria-modal="true">
@@ -101,7 +102,7 @@ const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIn
// Main list modal // Main list modal
const renderContent = () => { 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 || []; const items = tasks || [];
return items.length > 0 ? ( return items.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -207,22 +208,56 @@ const SubContractorDashboard = () => {
[notifications, subId], [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") // CRM-assigned tasks (the real "My Assignments")
const myAssignments = useMemo( const myAssignments = useMemo(
() => subcontractorTasks.filter(t => t.subcontractorId === subId), () => subcontractorTasks.filter(t => t.subcontractorId === subId),
[subcontractorTasks, 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 // Financials
const myInvoices = projects.flatMap(p => const myInvoices = projects.flatMap(p =>
(p.invoices || []).filter(i => i.submittedBy === subId) (p.invoices || []).filter(i => i.submittedBy === subId)
@@ -307,7 +342,7 @@ const SubContractorDashboard = () => {
items: myInvoices.map(inv => ({ items: myInvoices.map(inv => ({
date: inv.dueDate || 'N/A', date: inv.dueDate || 'N/A',
description: `Invoice #${inv.id}`, description: `Invoice #${inv.id}`,
project: myTasks.find(t => t.projectId === inv.projectId)?.projectAddress || 'Unknown', project: projectById.get(inv.projectId)?.address || 'Unknown',
status: inv.status, status: inv.status,
amount: inv.amount amount: inv.amount
})) }))
@@ -332,7 +367,7 @@ const SubContractorDashboard = () => {
<div> <div>
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Field Task Center</h1> <h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Field Task Center</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-1"> <p className="text-zinc-500 dark:text-zinc-400 mt-1">
You have <span className="text-blue-500 font-bold">{inProgressTasks.length} active tasks</span> and <span className="text-emerald-500 font-bold">${pendingPay.toLocaleString()}</span> in pending payouts. You have <span className="text-blue-500 font-bold">{kpiInProgress} active tasks</span> and <span className="text-emerald-500 font-bold">${kpiPendingPayouts.toLocaleString('en-US')}</span> in pending payouts.
</p> </p>
</div> </div>
@@ -362,16 +397,16 @@ const SubContractorDashboard = () => {
{/* KPI Cards */} {/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div onClick={() => openDetailModal('tasks_in_progress', 'Tasks In Progress')} className="cursor-pointer"> <div onClick={() => openDetailModal('tasks_in_progress', 'Tasks In Progress')} className="cursor-pointer">
<StatCard label="Tasks In Progress" value={inProgressTasks.length} icon={Clock} color="blue" /> <StatCard label="In Progress" value={kpiInProgress} icon={Clock} color="blue" />
</div> </div>
<div onClick={() => openDetailModal('pending_tasks', 'Pending Tasks')} className="cursor-pointer"> <div onClick={() => openDetailModal('awaiting_review', 'Awaiting Review')} className="cursor-pointer">
<StatCard label="Pending Tasks" value={pendingTasks.length} icon={CheckSquare} color="amber" /> <StatCard label="Awaiting Review" value={kpiAwaitingReview} icon={CheckSquare} color="amber" />
</div> </div>
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer"> <div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
<StatCard label="Pending Payouts" value={`$${pendingPay.toLocaleString()}`} icon={Wallet} color="emerald" /> <StatCard label="Pending Payouts" value={`$${kpiPendingPayouts.toLocaleString('en-US')}`} icon={Wallet} color="emerald" />
</div> </div>
<div onClick={() => openDetailModal('jobs_completed', 'Jobs Completed')} className="cursor-pointer"> <div onClick={() => openDetailModal('jobs_completed', 'Jobs Completed')} className="cursor-pointer">
<StatCard label="Jobs Completed" value={completedTasks.length} icon={CheckSquare} color="purple" /> <StatCard label="Completed" value={kpiCompleted} icon={CheckSquare} color="purple" />
</div> </div>
</div> </div>
@@ -437,6 +472,71 @@ const SubContractorDashboard = () => {
</div> </div>
</SpotlightCard> </SpotlightCard>
{/* My Projects */}
<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 flex items-center gap-2">
<Briefcase size={18} className="text-blue-500 dark:text-blue-400" />
My Projects
</h2>
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500">{myProjects.length} project{myProjects.length === 1 ? '' : 's'}</span>
</div>
{myProjects.length === 0 ? (
<div className="text-center py-10 text-zinc-500">
<Briefcase size={28} className="mx-auto mb-3 text-zinc-400" />
<p className="text-sm">No projects assigned yet.</p>
<p className="text-xs text-zinc-400 mt-1">Projects linked to your tasks will appear here.</p>
</div>
) : (
<div className="space-y-3">
{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 (
<div
key={p.id}
role="button"
tabIndex={0}
onClick={() => 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"
>
<div className="flex items-start gap-3 min-w-0">
<div className={`p-2.5 rounded-lg shrink-0 ${statusTone}`}>
<Briefcase size={16} />
</div>
<div className="min-w-0">
<h4 className="font-bold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
{p.projectType || 'Project'}
</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5 truncate flex items-center gap-1">
<MapPin size={11} className="shrink-0" />{shortAddress}
</p>
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-2">
<div className="text-right">
<span className={`block px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider mb-1 ${statusTone}`}>
{p.phase || p.status}
</span>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 font-mono whitespace-nowrap">
{p.openTaskCount} open / {p.taskCount} tasks
</p>
</div>
<ChevronRight size={16} className="text-zinc-400 group-hover:text-blue-500 transition-colors" />
</div>
</div>
);
})}
</div>
)}
</SpotlightCard>
{/* Project History & Payment Tracking */} {/* Project History & Payment Tracking */}
<SpotlightCard className="p-6"> <SpotlightCard className="p-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6"> <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
@@ -698,9 +798,9 @@ const SubContractorDashboard = () => {
title={detailModal.title} title={detailModal.title}
type={detailModal.type} type={detailModal.type}
tasks={ tasks={
detailModal.type === 'tasks_in_progress' ? inProgressTasks : detailModal.type === 'tasks_in_progress' ? kpiInProgressTasks :
detailModal.type === 'pending_tasks' ? pendingTasks : detailModal.type === 'awaiting_review' ? kpiAwaitingReviewTasks :
detailModal.type === 'jobs_completed' ? completedTasks : [] detailModal.type === 'jobs_completed' ? kpiCompletedTasks : []
} }
invoices={myInvoices} invoices={myInvoices}
clockIns={clockInData} clockIns={clockInData}
@@ -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 (
<div className="p-6 md:p-10 max-w-3xl mx-auto flex flex-col items-center justify-center min-h-[60vh] text-center gap-6 animate-in fade-in duration-500">
<div className="p-4 rounded-2xl bg-red-50 dark:bg-red-500/10 text-red-500">
<Lock size={40} />
</div>
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white mb-2">
You don't have access to this project
</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
This project either doesn't exist or you haven't been assigned to it.
</p>
</div>
<Link
to="/subcontractor/projects"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-bold text-sm transition-colors"
>
<ArrowLeft size={15} />
Back to My Projects
</Link>
</div>
);
}
const pct = Math.min(Math.max(scoped.completionPercentage ?? 0, 0), 100);
return (
<div className="p-6 md:p-10 max-w-5xl mx-auto space-y-8 animate-in fade-in duration-500">
{/* Back link */}
<button
type="button"
onClick={() => navigate('/subcontractor/projects')}
className="inline-flex items-center gap-1.5 text-sm text-zinc-500 dark:text-zinc-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors font-medium"
>
<ArrowLeft size={15} />
My Projects
</button>
{/* ── Project header ── */}
<div className="rounded-2xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10 p-6 space-y-5 shadow-sm">
{/* Title row */}
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div className="min-w-0 space-y-1">
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">
{scoped.projectType || 'Project'}
</h1>
<p className="flex items-center gap-1.5 text-sm text-zinc-500 dark:text-zinc-400">
<MapPin size={14} className="shrink-0" />
{scoped.address || ''}
</p>
{scoped.customerName && (
<p className="text-sm text-zinc-400 dark:text-zinc-500">{scoped.customerName}</p>
)}
</div>
{/* Status badges */}
<div className="flex flex-wrap items-center gap-2 shrink-0">
{scoped.status && (
<span className={`px-2.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${statusTone(scoped.status)}`}>
{scoped.status.replace(/_/g, ' ')}
</span>
)}
{scoped.phase && (
<span className="px-2.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-400">
{scoped.phase}
</span>
)}
{scoped.lifecycleStage && (
<span className="px-2.5 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-cyan-100 text-cyan-700 dark:bg-cyan-500/15 dark:text-cyan-400">
{scoped.lifecycleStage}
</span>
)}
</div>
</div>
{/* Completion progress */}
<div className="space-y-1.5">
<div className="flex justify-between text-xs text-zinc-500 dark:text-zinc-400">
<span>Completion</span>
<span className="font-mono font-bold text-zinc-700 dark:text-zinc-300">{pct}%</span>
</div>
<div className="w-full h-2 bg-zinc-100 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* Start / end dates */}
{(scoped.startDate || scoped.endDate) && (
<div className="flex flex-wrap gap-4 text-sm text-zinc-500 dark:text-zinc-400">
{scoped.startDate && (
<span className="flex items-center gap-1.5">
<CalendarDays size={13} className="shrink-0 text-blue-500" />
Start: <span className="font-mono font-semibold text-zinc-700 dark:text-zinc-300">{scoped.startDate}</span>
</span>
)}
{scoped.endDate && (
<span className="flex items-center gap-1.5">
<CalendarDays size={13} className="shrink-0 text-emerald-500" />
End: <span className="font-mono font-semibold text-zinc-700 dark:text-zinc-300">{scoped.endDate}</span>
</span>
)}
</div>
)}
</div>
{/* ── Schedule (milestones) ── */}
<section className="rounded-2xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-100 dark:border-white/10 flex items-center gap-2">
<Flag size={16} className="text-amber-500" />
<h2 className="text-base font-bold text-zinc-900 dark:text-white">Schedule</h2>
<span className="ml-auto text-[11px] font-bold text-zinc-400 uppercase tracking-wider">
{scoped.milestones.length} milestone{scoped.milestones.length !== 1 ? 's' : ''}
</span>
</div>
<div className="divide-y divide-zinc-100 dark:divide-white/[0.04]">
{scoped.milestones.length === 0 ? (
<p className="px-6 py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">No milestones on record.</p>
) : (
scoped.milestones.map((m) => (
<div key={m.id} className="flex items-center justify-between px-6 py-3.5 gap-4">
<div className="min-w-0">
<p className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{m.name}</p>
{m.dueDate && (
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5 font-mono">
Due: {m.dueDate}
</p>
)}
</div>
{m.status && (
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${statusTone(m.status)}`}>
{m.status.replace(/_/g, ' ')}
</span>
)}
</div>
))
)}
</div>
</section>
{/* ── My Tasks on this job ── */}
<section className="rounded-2xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-100 dark:border-white/10 flex items-center gap-2">
<CheckSquare size={16} className="text-blue-500" />
<h2 className="text-base font-bold text-zinc-900 dark:text-white">My Tasks on this Job</h2>
<span className="ml-auto text-[11px] font-bold text-zinc-400 uppercase tracking-wider">
{scoped.myTasks.length} task{scoped.myTasks.length !== 1 ? 's' : ''}
</span>
</div>
<div className="divide-y divide-zinc-100 dark:divide-white/[0.04]">
{scoped.myTasks.length === 0 ? (
<p className="px-6 py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">No tasks assigned to you on this project.</p>
) : (
scoped.myTasks.map((t) => (
<button
key={t.id}
type="button"
onClick={() => navigate(`/subcontractor/tasks/${t.id}`)}
className="group w-full flex items-center justify-between px-6 py-3.5 gap-4 hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors text-left"
>
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-semibold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
{t.title}
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-zinc-500 dark:text-zinc-400">
{t.dueDate && <span className="font-mono">Due: {t.dueDate}</span>}
{t.category && <span>{t.category}</span>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{t.status && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${statusTone(t.status)}`}>
{t.status.replace(/_/g, ' ')}
</span>
)}
<ChevronRight size={15} className="text-zinc-400 group-hover:text-blue-500 transition-colors" />
</div>
</button>
))
)}
</div>
</section>
{/* ── My Payments ── */}
<section className="rounded-2xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-100 dark:border-white/10 flex items-center gap-2">
<Wallet size={16} className="text-emerald-500" />
<h2 className="text-base font-bold text-zinc-900 dark:text-white">My Payments</h2>
</div>
<div className="p-6 grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ 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 }) => (
<div
key={label}
className="p-4 rounded-xl bg-zinc-50 dark:bg-white/[0.03] border border-zinc-100 dark:border-white/[0.06]"
>
<div className="flex items-center gap-2 mb-2">
<Icon size={14} className="text-zinc-400 dark:text-zinc-500" />
<span className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{label}</span>
</div>
<p className={`text-xl font-mono font-bold ${tone}`}>{value}</p>
</div>
))}
</div>
</section>
{/* Back link (bottom) */}
<div className="pt-2 pb-6">
<Link
to="/subcontractor/projects"
className="inline-flex items-center gap-1.5 text-sm text-zinc-500 dark:text-zinc-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors font-medium"
>
<ArrowLeft size={14} />
Back to My Projects
</Link>
</div>
</div>
);
};
export default SubcontractorProjectDetailPage;
@@ -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 (
<div className="p-6 md:p-10 max-w-6xl mx-auto space-y-8 animate-in fade-in duration-500">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">My Projects</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
{myProjects.length === 0
? 'No projects assigned yet.'
: `${myProjects.length} project${myProjects.length === 1 ? '' : 's'} assigned to you`}
</p>
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-[11px] font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-white/10 px-3 py-1 rounded-full">
{myProjects.length} total
</span>
</div>
</div>
{/* Empty state */}
{myProjects.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-center rounded-2xl border border-dashed border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02]">
<FolderOpen size={48} className="text-zinc-300 dark:text-zinc-600 mb-4" />
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-1">No projects assigned yet</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 max-w-xs">
When a project manager assigns you to a job, it will appear here.
</p>
</div>
)}
{/* Project grid */}
{myProjects.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{myProjects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => navigate('/subcontractor/projects/' + p.id)}
className="group text-left w-full p-5 rounded-2xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10 hover:border-blue-500/40 dark:hover:border-blue-500/40 shadow-sm hover:shadow-md transition-all duration-200 active:scale-[0.99] space-y-4"
>
{/* Card header */}
<div className="flex items-start justify-between gap-3">
<div className="p-2.5 rounded-xl bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 shrink-0">
<Briefcase size={18} />
</div>
<ChevronRight
size={16}
className="text-zinc-400 dark:text-zinc-500 group-hover:text-blue-500 dark:group-hover:text-blue-400 transition-colors mt-1 shrink-0"
/>
</div>
{/* Project type + address */}
<div className="min-w-0">
<h3 className="font-bold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
{p.projectType || 'Project'}
</h3>
<p className="flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400 mt-1 truncate">
<MapPin size={11} className="shrink-0" />
{p.address || '—'}
</p>
{p.customerName && (
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-0.5 truncate">{p.customerName}</p>
)}
</div>
{/* Status / phase chips */}
<div className="flex flex-wrap items-center gap-1.5">
{p.status && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${statusTone(p.status)}`}>
{p.status.replace(/_/g, ' ')}
</span>
)}
{p.phase && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-400">
{p.phase}
</span>
)}
</div>
{/* Completion + task counts */}
<div className="space-y-2">
{/* Progress bar */}
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400 mb-1">
<span>Completion</span>
<span className="font-mono font-bold text-zinc-700 dark:text-zinc-300">
{p.completionPercentage ?? 0}%
</span>
</div>
<div className="w-full h-1.5 bg-zinc-100 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-300"
style={{ width: `${Math.min(Math.max(p.completionPercentage ?? 0, 0), 100)}%` }}
/>
</div>
{/* Task count */}
<div className="flex items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400 pt-0.5">
<CheckSquare size={11} className="shrink-0" />
<span>
<span className="font-bold text-zinc-700 dark:text-zinc-300">{p.openTaskCount ?? 0}</span> open
{' / '}
<span className="font-bold text-zinc-700 dark:text-zinc-300">{p.taskCount ?? 0}</span> tasks
</span>
</div>
</div>
</button>
))}
</div>
)}
</div>
);
};
export default SubcontractorProjectsPage;
@@ -9,6 +9,7 @@ import {
DollarSign, Receipt, Camera, Image as ImageIcon, MessageSquare, Lock, Send, DollarSign, Receipt, Camera, Image as ImageIcon, MessageSquare, Lock, Send,
Clock, PauseCircle, CheckCircle, RefreshCcw, AlertCircle, X, Plus, Upload, Clock, PauseCircle, CheckCircle, RefreshCcw, AlertCircle, X, Plus, Upload,
Activity, ImagePlus, Loader2, Play, Trash2, AlertTriangle, Users, UserCheck, Activity, ImagePlus, Loader2, Play, Trash2, AlertTriangle, Users, UserCheck,
Briefcase,
} from 'lucide-react'; } from 'lucide-react';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -103,7 +104,7 @@ const SubcontractorTaskDetailPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { const {
subcontractorTasks, subSelfStatuses, subcontractorTasks, subSelfStatuses, projects,
setSubcontractorTaskStatus, addSubcontractorTaskMessage, setSubcontractorTaskStatus, addSubcontractorTaskMessage,
addSubcontractorTaskExpense, addSubcontractorTaskFee, addSubcontractorTaskExpense, addSubcontractorTaskFee,
addSubcontractorTaskActivity, addSubcontractorTaskActivity,
@@ -111,6 +112,11 @@ const SubcontractorTaskDetailPage = () => {
const task = useMemo(() => subcontractorTasks.find(t => t.id === taskId), [subcontractorTasks, taskId]); 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 [logExpenseOpen, setLogExpenseOpen] = useState(false);
const [logFeeOpen, setLogFeeOpen] = useState(false); const [logFeeOpen, setLogFeeOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null);
@@ -197,6 +203,19 @@ const SubcontractorTaskDetailPage = () => {
> >
<ArrowLeft size={12} /> Subcontractor Tasks <ArrowLeft size={12} /> Subcontractor Tasks
</button> </button>
{project && (
<>
<ChevronRight size={12} className="text-zinc-400 shrink-0" />
<button
type="button"
onClick={() => navigate(`/subcontractor/projects/${task.projectId}`)}
className="inline-flex items-center gap-1 hover:text-blue-600 dark:hover:text-blue-400 transition-colors shrink-0 truncate max-w-[140px] normal-case font-semibold tracking-normal"
>
<Briefcase size={11} className="shrink-0" />
<span className="truncate">{project.projectType}</span>
</button>
</>
)}
<ChevronRight size={12} className="text-zinc-400 shrink-0" /> <ChevronRight size={12} className="text-zinc-400 shrink-0" />
<span className="text-zinc-700 dark:text-zinc-300 truncate normal-case font-semibold tracking-normal"> <span className="text-zinc-700 dark:text-zinc-300 truncate normal-case font-semibold tracking-normal">
{task.title} {task.title}
@@ -207,8 +226,10 @@ const SubcontractorTaskDetailPage = () => {
<TaskHeaderCard <TaskHeaderCard
task={task} task={task}
overdue={overdue} overdue={overdue}
project={project}
onLogExpense={() => setLogExpenseOpen(true)} onLogExpense={() => setLogExpenseOpen(true)}
onLogFee={() => setLogFeeOpen(true)} onLogFee={() => setLogFeeOpen(true)}
onNavigateProject={() => navigate(`/subcontractor/projects/${task.projectId}`)}
/> />
{/* Two-column shell below: main content + financials/notifications rail */} {/* Two-column shell below: main content + financials/notifications rail */}
@@ -288,11 +309,12 @@ const SubcontractorTaskDetailPage = () => {
// =========================================================================== // ===========================================================================
// Section 1 — Header / Task Details // 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 closed = task.status === 'Completed' || task.status === 'Cancelled';
const projectStreet = project?.address ? project.address.split(',')[0].trim() : null;
return ( return (
<SpotlightCard className="p-5 sm:p-6"> <SpotlightCard className="p-5 sm:p-6">
{/* Top row: ID + badges */} {/* Top row: ID + badges + project chip */}
<div className="flex flex-wrap items-center gap-2 mb-2"> <div className="flex flex-wrap items-center gap-2 mb-2">
<span className="text-[11px] font-mono font-bold text-zinc-500">{task.id}</span> <span className="text-[11px] font-mono font-bold text-zinc-500">{task.id}</span>
<span className="w-1 h-1 rounded-full bg-zinc-300 dark:bg-white/20" aria-hidden /> <span className="w-1 h-1 rounded-full bg-zinc-300 dark:bg-white/20" aria-hidden />
@@ -303,6 +325,18 @@ const TaskHeaderCard = ({ task, overdue, onLogExpense, onLogFee }) => {
<AlertCircle size={11} /> Overdue <AlertCircle size={11} /> Overdue
</span> </span>
)} )}
{project && (
<button
type="button"
onClick={onNavigateProject}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold border border-blue-200 dark:border-blue-500/30 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors ml-1"
title={`View project: ${project.id}`}
>
<Briefcase size={10} />
<span>{project.projectType}</span>
{projectStreet && <span className="text-blue-500/70 dark:text-blue-400/60 font-normal">· {projectStreet}</span>}
</button>
)}
</div> </div>
{/* Title + actions */} {/* Title + actions */}