feat(subportal): grounded dashboard KPIs + My Projects; scoped project list/detail pages + routes; task→project link
This commit is contained in:
+8
-1
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user