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;
|
||||
Reference in New Issue
Block a user