feat(owner): enhance project list and detail views
- Implemented enhanced Owner Project List with search, filtering (status/phase), and sorting. - Added ambient background and responsive card/table layout to Project List. - Implemented comprehensive Owner Project Detail view with tabs for Overview, Budget, Change Orders, RFIs, Milestones, Invoices, Risks, and Activity. - Added budget visualization using Recharts and milestone timeline. - Added SPA rewrite rules in vercel.json.
This commit is contained in:
@@ -0,0 +1,592 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ArrowLeft, CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert,
|
||||||
|
FileText, DollarSign, GitPullRequest, AlertCircle, Activity, Milestone,
|
||||||
|
Shield, ChevronRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
import ChangeOrderDrawer from '../../components/owner/ChangeOrderDrawer';
|
||||||
|
import InvoiceDetailModal from '../../components/owner/InvoiceDetailModal';
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
active: { label: 'Active', color: 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10', icon: Clock },
|
||||||
|
completed: { label: 'Completed', color: 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10', icon: CheckCircle },
|
||||||
|
delayed: { label: 'Delayed', color: 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10', icon: AlertTriangle },
|
||||||
|
on_hold: { label: 'On Hold', color: 'text-purple-600 bg-purple-100 dark:text-purple-400 dark:bg-purple-500/10', icon: PauseCircle },
|
||||||
|
disputed: { label: 'Disputed', color: 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-500/10', icon: ShieldAlert },
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'overview', label: 'Overview', icon: Activity },
|
||||||
|
{ id: 'budget', label: 'Budget & Costs', icon: DollarSign },
|
||||||
|
{ id: 'changeOrders', label: 'Change Orders', icon: GitPullRequest },
|
||||||
|
{ id: 'rfis', label: 'RFIs', icon: FileText },
|
||||||
|
{ id: 'milestones', label: 'Milestones', icon: Milestone },
|
||||||
|
{ id: 'invoices', label: 'Invoices', icon: DollarSign },
|
||||||
|
{ id: 'risks', label: 'Risk & Issues', icon: AlertCircle },
|
||||||
|
{ id: 'activity', label: 'Activity', icon: Clock },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OwnerProjectDetail = () => {
|
||||||
|
const { projectId } = useParams();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { projects, vendors } = useMockStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
const [selectedCO, setSelectedCO] = useState(null);
|
||||||
|
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||||
|
|
||||||
|
const project = useMemo(() =>
|
||||||
|
projects.find(p => p.id === projectId && p.ownerId === user?.id)
|
||||||
|
, [projects, projectId, user]);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center text-zinc-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<ShieldAlert size={48} className="mx-auto mb-4 text-zinc-400" />
|
||||||
|
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">Project Not Found</h2>
|
||||||
|
<p className="mt-2">This project doesn't exist or you don't have access.</p>
|
||||||
|
<button onClick={() => navigate('/owner/projects')} className="mt-4 px-4 py-2 bg-amber-500 text-black font-bold rounded-xl text-sm">
|
||||||
|
Back to Projects
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = statusConfig[project.status] || statusConfig.active;
|
||||||
|
const StatusIcon = cfg.icon;
|
||||||
|
|
||||||
|
const contractor = vendors.find(v => v.id === project.contractorId) || null;
|
||||||
|
|
||||||
|
const formatCurrency = (amt) =>
|
||||||
|
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amt);
|
||||||
|
|
||||||
|
const getHealthColor = (s) => s >= 70 ? 'text-emerald-500' : s >= 40 ? 'text-amber-500' : 'text-red-500';
|
||||||
|
|
||||||
|
const getMilestoneColor = (status) => {
|
||||||
|
if (status === 'completed') return 'bg-emerald-500';
|
||||||
|
if (status === 'in_progress') return 'bg-blue-500';
|
||||||
|
return 'bg-zinc-300 dark:bg-zinc-700';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInvoiceStatusColor = (status) => {
|
||||||
|
if (status === 'paid') return 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10';
|
||||||
|
if (status === 'pending') return 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10';
|
||||||
|
return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-500/10';
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-3 shadow-xl text-sm">
|
||||||
|
<p className="font-bold text-zinc-900 dark:text-white mb-1">{label}</p>
|
||||||
|
{payload.map((p, i) => (
|
||||||
|
<p key={i} className="text-zinc-600 dark:text-zinc-400">
|
||||||
|
<span className="font-semibold" style={{ color: p.color }}>{p.name}:</span> {formatCurrency(p.value * 1000)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Budget chart data
|
||||||
|
const budgetChartData = (project.budgetBreakdown || []).map(b => ({
|
||||||
|
name: b.category.length > 12 ? b.category.slice(0, 12) + '...' : b.category,
|
||||||
|
allocated: b.allocated / 1000,
|
||||||
|
committed: b.committed / 1000,
|
||||||
|
actual: b.actual / 1000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 relative">
|
||||||
|
{/* Ambient Background */}
|
||||||
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 p-4 sm:p-8 max-w-7xl mx-auto space-y-6 pb-20">
|
||||||
|
|
||||||
|
{/* Back & Title */}
|
||||||
|
<header>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/owner/projects')}
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-white transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Projects
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 border-b border-zinc-200 dark:border-white/5 pb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">
|
||||||
|
{project.projectType}
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1 text-sm">{project.address}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wide ${cfg.color}`}>
|
||||||
|
<StatusIcon size={14} /> {cfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Key Metrics Row */}
|
||||||
|
<section className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Budget', value: formatCurrency(project.approvedBudget || project.budget) },
|
||||||
|
{ label: 'Spent', value: formatCurrency(project.actualCost || project.spent) },
|
||||||
|
{ label: 'Variance', value: `${(project.variancePercent || 0) > 0 ? '+' : ''}${(project.variancePercent || 0).toFixed(1)}%` },
|
||||||
|
{ label: 'Completion', value: `${project.completionPercentage || 0}%` },
|
||||||
|
{ label: 'Health', value: project.healthScore != null ? project.healthScore : '—', color: getHealthColor(project.healthScore || 0) },
|
||||||
|
{ label: 'Phase', value: project.phase || '—' },
|
||||||
|
].map((m, i) => (
|
||||||
|
<SpotlightCard key={i} className="p-4">
|
||||||
|
<div className={`text-xl font-extrabold ${m.color || 'text-zinc-900 dark:text-white'}`}>{m.value}</div>
|
||||||
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mt-1">{m.label}</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="flex overflow-x-auto gap-0.5 sm:gap-1 border-b border-zinc-200 dark:border-white/5 pb-px scrollbar-hide">
|
||||||
|
{tabs.map(tab => {
|
||||||
|
const TabIcon = tab.icon;
|
||||||
|
const isActive = activeTab === tab.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-3 text-xs sm:text-sm font-semibold whitespace-nowrap border-b-2 transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border-amber-500 text-amber-600 dark:text-amber-400'
|
||||||
|
: 'border-transparent text-zinc-500 hover:text-zinc-900 dark:hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TabIcon size={16} /> <span className="hidden sm:inline">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div>
|
||||||
|
{/* OVERVIEW TAB */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Project Info */}
|
||||||
|
<SpotlightCard className="p-6 space-y-4">
|
||||||
|
<h3 className="text-lg font-bold">Project Details</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
{[
|
||||||
|
['Project ID', project.id],
|
||||||
|
['Type', project.projectType],
|
||||||
|
['Start Date', project.startDate],
|
||||||
|
['End Date', project.endDate],
|
||||||
|
['Contractor', contractor?.vendorName || project.contractorId],
|
||||||
|
['Margin', `${((project.margin || 0) * 100).toFixed(0)}%`],
|
||||||
|
['Open RFIs', project.openRFIs ?? '—'],
|
||||||
|
['Change Orders', project.changeOrderCount ?? '—'],
|
||||||
|
].map(([label, value], i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">{label}</div>
|
||||||
|
<div className="font-semibold text-zinc-900 dark:text-white">{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-4">Milestone Progress</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(project.milestones || []).map((ms, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<div className={`w-3 h-3 rounded-full shrink-0 ${getMilestoneColor(ms.status)}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-zinc-900 dark:text-white truncate">{ms.name}</div>
|
||||||
|
<div className="text-[10px] text-zinc-500">{ms.dueDate}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full ${
|
||||||
|
ms.status === 'completed' ? 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10' :
|
||||||
|
ms.status === 'in_progress' ? 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10' :
|
||||||
|
'text-zinc-500 bg-zinc-100 dark:text-zinc-400 dark:bg-white/5'
|
||||||
|
}`}>
|
||||||
|
{ms.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* BUDGET TAB */}
|
||||||
|
{activeTab === 'budget' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{budgetChartData.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-1">Budget Breakdown</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mb-4">Allocated vs Committed vs Actual ($k)</p>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={budgetChartData} barGap={2}>
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar dataKey="allocated" name="Allocated" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="committed" name="Committed" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="actual" name="Actual" fill="#22c55e" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
<SpotlightCard className="overflow-hidden">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
||||||
|
<tr>
|
||||||
|
{['Category', 'Allocated', 'Committed', 'Actual', 'Variance'].map(h => (
|
||||||
|
<th key={h} className={`px-5 py-3 text-xs font-bold uppercase tracking-wider text-zinc-500 ${h !== 'Category' ? 'text-right' : ''}`}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{(project.budgetBreakdown || []).map((b, i) => {
|
||||||
|
const variance = b.actual - b.allocated;
|
||||||
|
return (
|
||||||
|
<tr key={i} className="hover:bg-zinc-50 dark:hover:bg-white/5">
|
||||||
|
<td className="px-5 py-3 font-semibold text-sm">{b.category}</td>
|
||||||
|
<td className="px-5 py-3 text-right font-mono text-sm">{formatCurrency(b.allocated)}</td>
|
||||||
|
<td className="px-5 py-3 text-right font-mono text-sm">{formatCurrency(b.committed)}</td>
|
||||||
|
<td className="px-5 py-3 text-right font-mono text-sm">{formatCurrency(b.actual)}</td>
|
||||||
|
<td className={`px-5 py-3 text-right font-mono text-sm font-semibold ${variance > 0 ? 'text-red-500' : 'text-emerald-500'}`}>
|
||||||
|
{variance > 0 ? '+' : ''}{formatCurrency(variance)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</SpotlightCard>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<SpotlightCard className="p-12 text-center text-zinc-500">
|
||||||
|
<DollarSign size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||||
|
<p className="font-semibold">No detailed budget breakdown available.</p>
|
||||||
|
<p className="text-sm mt-1">Budget: {formatCurrency(project.approvedBudget || project.budget)} · Spent: {formatCurrency(project.actualCost || project.spent)}</p>
|
||||||
|
</SpotlightCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CHANGE ORDERS TAB */}
|
||||||
|
{activeTab === 'changeOrders' && (
|
||||||
|
<SpotlightCard className="overflow-hidden">
|
||||||
|
{(project.changeOrders || []).length > 0 ? (
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
||||||
|
<tr>
|
||||||
|
{['ID', 'Title', 'Amount', 'Status', 'Date'].map(h => (
|
||||||
|
<th key={h} className="px-5 py-3 text-xs font-bold uppercase tracking-wider text-zinc-500">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{project.changeOrders.map((co, i) => (
|
||||||
|
<tr key={i} className="hover:bg-zinc-50 dark:hover:bg-white/5 cursor-pointer" onClick={() => setSelectedCO(co)}>
|
||||||
|
<td className="px-5 py-3 font-mono text-xs text-zinc-500">{co.id}</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="font-semibold text-sm">{co.title}</div>
|
||||||
|
{co.description && <div className="text-xs text-zinc-500 mt-0.5">{co.description}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 font-mono text-sm">{formatCurrency(co.amount)}</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${
|
||||||
|
co.status === 'approved' ? 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10' :
|
||||||
|
co.status === 'pending' ? 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10' :
|
||||||
|
'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-500/10'
|
||||||
|
}`}>
|
||||||
|
{co.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500">{co.dateSubmitted}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center text-zinc-500">
|
||||||
|
<GitPullRequest size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||||
|
<p className="font-semibold">No change orders for this project.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SpotlightCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RFIs TAB */}
|
||||||
|
{activeTab === 'rfis' && (
|
||||||
|
<SpotlightCard className="overflow-hidden">
|
||||||
|
{(project.rfis || []).length > 0 ? (
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
||||||
|
<tr>
|
||||||
|
{['ID', 'Subject', 'Status', 'Submitted By', 'Opened', 'Closed'].map(h => (
|
||||||
|
<th key={h} className="px-5 py-3 text-xs font-bold uppercase tracking-wider text-zinc-500">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{project.rfis.map((rfi, i) => (
|
||||||
|
<tr key={i} className="hover:bg-zinc-50 dark:hover:bg-white/5">
|
||||||
|
<td className="px-5 py-3 font-mono text-xs text-zinc-500">{rfi.id}</td>
|
||||||
|
<td className="px-5 py-3 font-semibold text-sm">{rfi.subject}</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${
|
||||||
|
rfi.status === 'closed' ? 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10' :
|
||||||
|
'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10'
|
||||||
|
}`}>
|
||||||
|
{rfi.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500">{rfi.submittedBy}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500">{rfi.dateOpened}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500">{rfi.dateClosed || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center text-zinc-500">
|
||||||
|
<FileText size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||||
|
<p className="font-semibold">No RFIs for this project.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SpotlightCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MILESTONES TAB */}
|
||||||
|
{activeTab === 'milestones' && (
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-6">Milestone Timeline</h3>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-zinc-200 dark:bg-white/10" />
|
||||||
|
<div className="space-y-6">
|
||||||
|
{(project.milestones || []).map((ms, i) => (
|
||||||
|
<div key={i} className="relative flex items-start gap-3 sm:gap-4 pl-6 sm:pl-10">
|
||||||
|
<div className={`absolute left-2.5 w-3.5 h-3.5 rounded-full border-2 border-white dark:border-[#09090b] ${getMilestoneColor(ms.status)}`} />
|
||||||
|
<div className="flex-1 p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-zinc-900 dark:text-white">{ms.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">Due: {ms.dueDate} · Assigned to: {ms.assignedTo}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full ${
|
||||||
|
ms.status === 'completed' ? 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10' :
|
||||||
|
ms.status === 'in_progress' ? 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10' :
|
||||||
|
'text-zinc-500 bg-zinc-100 dark:text-zinc-400 dark:bg-white/5'
|
||||||
|
}`}>
|
||||||
|
{ms.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* INVOICES TAB */}
|
||||||
|
{activeTab === 'invoices' && (
|
||||||
|
<SpotlightCard className="overflow-hidden">
|
||||||
|
{(project.invoices || []).length > 0 ? (
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
||||||
|
<tr>
|
||||||
|
{['Invoice ID', 'Amount', 'Submitted By', 'Status', 'Due Date', 'Paid Date'].map(h => (
|
||||||
|
<th key={h} className={`px-5 py-3 text-xs font-bold uppercase tracking-wider text-zinc-500 ${h === 'Amount' ? 'text-right' : ''}`}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{project.invoices.map((inv, i) => (
|
||||||
|
<tr key={i} className="hover:bg-zinc-50 dark:hover:bg-white/5 cursor-pointer" onClick={() => setSelectedInvoice(inv)}>
|
||||||
|
<td className="px-5 py-3 font-mono text-xs text-zinc-500">{inv.id}</td>
|
||||||
|
<td className="px-5 py-3 text-right font-mono text-sm font-medium text-zinc-900 dark:text-white">
|
||||||
|
{formatCurrency(inv.amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-600 dark:text-zinc-400">{inv.submittedBy}</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${getInvoiceStatusColor(inv.status)}`}>
|
||||||
|
{inv.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500">{inv.dueDate}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500">{inv.datePaid || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center text-zinc-500">
|
||||||
|
<DollarSign size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||||
|
<p className="font-semibold">No invoices for this project.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SpotlightCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RISK & ISSUES TAB */}
|
||||||
|
{activeTab === 'risks' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Risk Log */}
|
||||||
|
<SpotlightCard className="overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5">
|
||||||
|
<h3 className="font-bold flex items-center gap-2">
|
||||||
|
<Shield size={16} className="text-amber-500" /> Risk Log
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{(project.riskLog || []).length > 0 ? (
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-zinc-50/50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
||||||
|
<tr>
|
||||||
|
{['ID', 'Description', 'Severity', 'Likelihood', 'Status', 'Mitigation'].map(h => (
|
||||||
|
<th key={h} className="px-5 py-3 text-xs font-bold uppercase tracking-wider text-zinc-500">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{project.riskLog.map((r, i) => (
|
||||||
|
<tr key={i} className="hover:bg-zinc-50 dark:hover:bg-white/5">
|
||||||
|
<td className="px-5 py-3 font-mono text-xs text-zinc-500">{r.id}</td>
|
||||||
|
<td className="px-5 py-3 text-sm font-medium max-w-xs">{r.description}</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded-full ${
|
||||||
|
r.severity === 'high' ? 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-500/10' :
|
||||||
|
r.severity === 'medium' ? 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10' :
|
||||||
|
'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10'
|
||||||
|
}`}>
|
||||||
|
{r.severity}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500 capitalize">{r.likelihood}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500 capitalize">{r.status}</td>
|
||||||
|
<td className="px-5 py-3 text-xs text-zinc-500 max-w-xs">{r.mitigation}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center text-zinc-500 text-sm">No risk entries for this project.</div>
|
||||||
|
)}
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
{/* Issue Log */}
|
||||||
|
<SpotlightCard className="overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5">
|
||||||
|
<h3 className="font-bold flex items-center gap-2">
|
||||||
|
<AlertCircle size={16} className="text-red-500" /> Issue Log
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{(project.issueLog || []).length > 0 ? (
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-zinc-50/50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
||||||
|
<tr>
|
||||||
|
{['ID', 'Title', 'Priority', 'Status', 'Assigned To', 'Reported'].map(h => (
|
||||||
|
<th key={h} className="px-5 py-3 text-xs font-bold uppercase tracking-wider text-zinc-500">{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{project.issueLog.map((issue, i) => (
|
||||||
|
<tr key={i} className="hover:bg-zinc-50 dark:hover:bg-white/5">
|
||||||
|
<td className="px-5 py-3 font-mono text-xs text-zinc-500">{issue.id}</td>
|
||||||
|
<td className="px-5 py-3 font-semibold text-sm">{issue.title}</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded-full ${
|
||||||
|
issue.priority === 'critical' ? 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-500/10' :
|
||||||
|
issue.priority === 'high' ? 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10' :
|
||||||
|
'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10'
|
||||||
|
}`}>
|
||||||
|
{issue.priority}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500 capitalize">{issue.status}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500">{issue.assignedTo}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-zinc-500">{issue.dateReported}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center text-zinc-500 text-sm">No issues logged for this project.</div>
|
||||||
|
)}
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ACTIVITY TAB */}
|
||||||
|
{activeTab === 'activity' && (
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-6">Activity Timeline</h3>
|
||||||
|
{(project.activityTimeline || []).length > 0 ? (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-zinc-200 dark:bg-white/10" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{project.activityTimeline.map((a, i) => (
|
||||||
|
<div key={i} className="relative flex items-start gap-3 sm:gap-4 pl-6 sm:pl-10">
|
||||||
|
<div className="absolute left-2.5 w-3.5 h-3.5 rounded-full bg-blue-500 border-2 border-white dark:border-[#09090b]" />
|
||||||
|
<div className="flex-1 p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-zinc-900 dark:text-white text-sm">{a.action}</div>
|
||||||
|
{a.details && <div className="text-xs text-zinc-500 mt-1">{a.details}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 ml-4">
|
||||||
|
<div className="text-xs text-zinc-500">{a.date}</div>
|
||||||
|
{a.user && <div className="text-[10px] text-zinc-400 mt-0.5">{a.user}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-zinc-500 py-8">
|
||||||
|
<Clock size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||||
|
<p className="font-semibold">No activity logged yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SpotlightCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals & Drawers */}
|
||||||
|
<ChangeOrderDrawer
|
||||||
|
isOpen={!!selectedCO}
|
||||||
|
onClose={() => setSelectedCO(null)}
|
||||||
|
changeOrder={selectedCO}
|
||||||
|
/>
|
||||||
|
<InvoiceDetailModal
|
||||||
|
isOpen={!!selectedInvoice}
|
||||||
|
onClose={() => setSelectedInvoice(null)}
|
||||||
|
invoice={selectedInvoice}
|
||||||
|
/>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OwnerProjectDetail;
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
import {
|
||||||
|
Search, Filter, ChevronRight, ArrowUpDown, Briefcase,
|
||||||
|
CheckCircle, Clock, AlertTriangle, PauseCircle, ShieldAlert
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
active: { label: 'Active', color: 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10', icon: Clock },
|
||||||
|
completed: { label: 'Completed', color: 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10', icon: CheckCircle },
|
||||||
|
delayed: { label: 'Delayed', color: 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10', icon: AlertTriangle },
|
||||||
|
on_hold: { label: 'On Hold', color: 'text-purple-600 bg-purple-100 dark:text-purple-400 dark:bg-purple-500/10', icon: PauseCircle },
|
||||||
|
disputed: { label: 'Disputed', color: 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-500/10', icon: ShieldAlert },
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseOrder = ['Planning', 'Foundation', 'Structural', 'MEP', 'Finishing', 'Handover'];
|
||||||
|
|
||||||
|
const OwnerProjectList = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { projects, vendors } = useMockStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [phaseFilter, setPhaseFilter] = useState('all');
|
||||||
|
const [sortConfig, setSortConfig] = useState({ key: 'healthScore', direction: 'asc' });
|
||||||
|
|
||||||
|
// Filter projects for this owner
|
||||||
|
const ownerProjects = useMemo(() =>
|
||||||
|
projects.filter(p => p.ownerId === user?.id)
|
||||||
|
, [projects, user]);
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
return ownerProjects.filter(p => {
|
||||||
|
const matchesSearch = search === '' ||
|
||||||
|
p.address.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
p.projectType.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
p.id.toLowerCase().includes(search.toLowerCase());
|
||||||
|
|
||||||
|
const matchesStatus = statusFilter === 'all' || p.status === statusFilter;
|
||||||
|
const matchesPhase = phaseFilter === 'all' || p.phase === phaseFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus && matchesPhase;
|
||||||
|
});
|
||||||
|
}, [ownerProjects, search, statusFilter, phaseFilter]);
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
const sortedProjects = useMemo(() => {
|
||||||
|
return [...filteredProjects].sort((a, b) => {
|
||||||
|
let aVal = a[sortConfig.key];
|
||||||
|
let bVal = b[sortConfig.key];
|
||||||
|
|
||||||
|
if (typeof aVal === 'string') aVal = aVal.toLowerCase();
|
||||||
|
if (typeof bVal === 'string') bVal = bVal.toLowerCase();
|
||||||
|
|
||||||
|
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [filteredProjects, sortConfig]);
|
||||||
|
|
||||||
|
const handleSort = (key) => {
|
||||||
|
setSortConfig(prev => ({
|
||||||
|
key,
|
||||||
|
direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount) =>
|
||||||
|
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount);
|
||||||
|
|
||||||
|
const getHealthColor = (score) => {
|
||||||
|
if (score >= 70) return 'text-emerald-500';
|
||||||
|
if (score >= 40) return 'text-amber-500';
|
||||||
|
return 'text-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVarianceColor = (v) => {
|
||||||
|
if (v === 0) return 'text-zinc-500';
|
||||||
|
return v < 0 ? 'text-emerald-500' : 'text-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unique statuses and phases in this owner's data
|
||||||
|
const availableStatuses = [...new Set(ownerProjects.map(p => p.status))];
|
||||||
|
const availablePhases = [...new Set(ownerProjects.map(p => p.phase).filter(Boolean))];
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
const totalBudget = ownerProjects.reduce((sum, p) => sum + (p.approvedBudget || p.budget || 0), 0);
|
||||||
|
const totalSpent = ownerProjects.reduce((sum, p) => sum + (p.actualCost || p.spent || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 relative">
|
||||||
|
{/* Ambient Background */}
|
||||||
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 p-4 sm:p-8 max-w-7xl mx-auto space-y-6 pb-20">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex flex-col md:flex-row justify-between items-start md:items-end border-b border-zinc-200 dark:border-white/5 pb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">
|
||||||
|
Projects
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
{ownerProjects.length} projects · {formatCurrency(totalBudget)} total budget · {formatCurrency(totalSpent)} spent
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/owner/snapshot')}
|
||||||
|
className="mt-4 md:mt-0 px-4 py-2 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-xl text-sm font-bold transition-colors border border-zinc-200 dark:border-white/5"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Filters & Search */}
|
||||||
|
<SpotlightCard className="p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name, type, or ID..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-colors ${
|
||||||
|
statusFilter === 'all'
|
||||||
|
? 'bg-zinc-900 text-white dark:bg-white dark:text-black'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{availableStatuses.map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setStatusFilter(s)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-colors ${
|
||||||
|
statusFilter === s
|
||||||
|
? 'bg-zinc-900 text-white dark:bg-white dark:text-black'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.replace('_', ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase Filter */}
|
||||||
|
<select
|
||||||
|
value={phaseFilter}
|
||||||
|
onChange={(e) => setPhaseFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-xl text-sm bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||||
|
>
|
||||||
|
<option value="all">All Phases</option>
|
||||||
|
{phaseOrder.filter(p => availablePhases.includes(p)).map(p => (
|
||||||
|
<option key={p} value={p}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
{/* Project Table (Desktop) / Cards (Mobile) */}
|
||||||
|
<SpotlightCard className="overflow-hidden">
|
||||||
|
{/* Mobile Card View */}
|
||||||
|
<div className="md:hidden divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{sortedProjects.length > 0 ? sortedProjects.map(project => {
|
||||||
|
const cfg = statusConfig[project.status] || statusConfig.active;
|
||||||
|
const StatusIcon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => navigate(`/owner/projects/${project.id}`)}
|
||||||
|
className="p-4 active:bg-zinc-50 dark:active:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex-1 min-w-0 mr-3">
|
||||||
|
<h4 className="font-semibold text-zinc-900 dark:text-white truncate">{project.projectType}</h4>
|
||||||
|
<p className="text-xs text-zinc-500 truncate mt-0.5">{project.address}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="text-zinc-400 shrink-0 mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${cfg.color}`}>
|
||||||
|
<StatusIcon size={10} /> {cfg.label}
|
||||||
|
</span>
|
||||||
|
{project.phase && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide bg-zinc-100 text-zinc-600 dark:bg-white/5 dark:text-zinc-400">
|
||||||
|
{project.phase}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{project.healthScore != null && (
|
||||||
|
<span className={`text-xs font-bold ${getHealthColor(project.healthScore)}`}>
|
||||||
|
Health: {project.healthScore}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs mb-2">
|
||||||
|
<span className="text-zinc-500">Budget: <span className="font-mono font-medium text-zinc-900 dark:text-white">{formatCurrency(project.approvedBudget || project.budget || 0)}</span></span>
|
||||||
|
{project.variancePercent != null && (
|
||||||
|
<span className={`font-mono font-semibold ${getVarianceColor(project.variancePercent)}`}>
|
||||||
|
{project.variancePercent > 0 ? '+' : ''}{project.variancePercent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-1.5 bg-zinc-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${project.completionPercentage || 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-zinc-500 w-8 text-right">{project.completionPercentage || 0}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<div className="py-16 text-center text-zinc-500">
|
||||||
|
<Briefcase size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||||
|
<p className="font-semibold">No projects match your filters.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Table View */}
|
||||||
|
<div className="hidden md:block overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10">
|
||||||
|
<tr>
|
||||||
|
{[
|
||||||
|
{ key: 'projectType', label: 'Project' },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
{ key: 'phase', label: 'Phase' },
|
||||||
|
{ key: 'approvedBudget', label: 'Budget', align: 'right' },
|
||||||
|
{ key: 'variancePercent', label: 'Variance', align: 'right' },
|
||||||
|
{ key: 'completionPercentage', label: 'Progress', align: 'right' },
|
||||||
|
{ key: 'healthScore', label: 'Health', align: 'right' },
|
||||||
|
].map(col => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
onClick={() => handleSort(col.key)}
|
||||||
|
className={`px-5 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors select-none ${col.align === 'right' ? 'text-right' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{col.label}
|
||||||
|
{sortConfig.key === col.key && (
|
||||||
|
<ArrowUpDown size={12} className="text-amber-500" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-5 py-4 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{sortedProjects.length > 0 ? sortedProjects.map(project => {
|
||||||
|
const cfg = statusConfig[project.status] || statusConfig.active;
|
||||||
|
const StatusIcon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={project.id}
|
||||||
|
onClick={() => navigate(`/owner/projects/${project.id}`)}
|
||||||
|
className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors cursor-pointer group"
|
||||||
|
>
|
||||||
|
{/* Project Name */}
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="font-semibold text-zinc-900 dark:text-white group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors">
|
||||||
|
{project.projectType}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-0.5 max-w-xs truncate">{project.address}</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wide ${cfg.color}`}>
|
||||||
|
<StatusIcon size={12} />
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Phase */}
|
||||||
|
<td className="px-5 py-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{project.phase || '—'}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Budget */}
|
||||||
|
<td className="px-5 py-4 text-right font-mono text-sm text-zinc-900 dark:text-white">
|
||||||
|
{formatCurrency(project.approvedBudget || project.budget || 0)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Variance */}
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<span className={`font-mono text-sm font-semibold ${getVarianceColor(project.variancePercent || 0)}`}>
|
||||||
|
{project.variancePercent != null ? `${project.variancePercent > 0 ? '+' : ''}${project.variancePercent.toFixed(1)}%` : '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<div className="w-16 h-1.5 bg-zinc-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${project.completionPercentage || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-zinc-600 dark:text-zinc-400 w-8 text-right">
|
||||||
|
{project.completionPercentage || 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Health Score */}
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<span className={`text-sm font-bold ${getHealthColor(project.healthScore || 0)}`}>
|
||||||
|
{project.healthScore != null ? project.healthScore : '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<ChevronRight size={16} className="text-zinc-400 group-hover:text-amber-500 transition-colors" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="8" className="py-20 text-center text-zinc-500">
|
||||||
|
<Briefcase size={32} className="mx-auto mb-3 text-zinc-400" />
|
||||||
|
<p className="font-semibold">No projects match your filters.</p>
|
||||||
|
<p className="text-sm mt-1">Try adjusting your search or filter criteria.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer (both views) */}
|
||||||
|
<div className="px-5 py-3 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 text-sm text-zinc-500 flex justify-between items-center">
|
||||||
|
<span>Showing {sortedProjects.length} of {ownerProjects.length} projects</span>
|
||||||
|
<span className="font-mono font-medium text-zinc-900 dark:text-white">
|
||||||
|
{formatCurrency(sortedProjects.reduce((s, p) => s + (p.approvedBudget || p.budget || 0), 0))} total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OwnerProjectList;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
{ "source": "/(.*)", "destination": "/index.html" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user