271 lines
13 KiB
React
271 lines
13 KiB
React
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;
|