diff --git a/src/pages/owner/OwnerProjectDetail.jsx b/src/pages/owner/OwnerProjectDetail.jsx new file mode 100644 index 0000000..52cdd23 --- /dev/null +++ b/src/pages/owner/OwnerProjectDetail.jsx @@ -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 ( +
+
+ +

Project Not Found

+

This project doesn't exist or you don't have access.

+ +
+
+ ); + } + + 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 ( +
+

{label}

+ {payload.map((p, i) => ( +

+ {p.name}: {formatCurrency(p.value * 1000)} +

+ ))} +
+ ); + } + 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 ( +
+ {/* Ambient Background */} +
+
+
+
+ +
+ + {/* Back & Title */} +
+ + +
+
+

+ {project.projectType} +

+

{project.address}

+
+ + {cfg.label} + +
+
+ + {/* Key Metrics Row */} +
+ {[ + { 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) => ( + +
{m.value}
+
{m.label}
+
+ ))} +
+ + {/* Tab Navigation */} +
+ {tabs.map(tab => { + const TabIcon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ + {/* Tab Content */} +
+ {/* OVERVIEW TAB */} + {activeTab === 'overview' && ( +
+ {/* Project Info */} + +

Project Details

+
+ {[ + ['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) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ + {/* Progress */} + +

Milestone Progress

+
+ {(project.milestones || []).map((ms, i) => ( +
+
+
+
{ms.name}
+
{ms.dueDate}
+
+ + {ms.status.replace('_', ' ')} + +
+ ))} +
+ +
+ )} + + {/* BUDGET TAB */} + {activeTab === 'budget' && ( +
+ {budgetChartData.length > 0 ? ( + <> + +

Budget Breakdown

+

Allocated vs Committed vs Actual ($k)

+
+ + + + + } /> + + + + + +
+
+ + + + + {['Category', 'Allocated', 'Committed', 'Actual', 'Variance'].map(h => ( + + ))} + + + + {(project.budgetBreakdown || []).map((b, i) => { + const variance = b.actual - b.allocated; + return ( + + + + + + + + ); + })} + +
{h}
{b.category}{formatCurrency(b.allocated)}{formatCurrency(b.committed)}{formatCurrency(b.actual)} 0 ? 'text-red-500' : 'text-emerald-500'}`}> + {variance > 0 ? '+' : ''}{formatCurrency(variance)} +
+
+ + ) : ( + + +

No detailed budget breakdown available.

+

Budget: {formatCurrency(project.approvedBudget || project.budget)} · Spent: {formatCurrency(project.actualCost || project.spent)}

+
+ )} +
+ )} + + {/* CHANGE ORDERS TAB */} + {activeTab === 'changeOrders' && ( + + {(project.changeOrders || []).length > 0 ? ( + + + + {['ID', 'Title', 'Amount', 'Status', 'Date'].map(h => ( + + ))} + + + + {project.changeOrders.map((co, i) => ( + setSelectedCO(co)}> + + + + + + + ))} + +
{h}
{co.id} +
{co.title}
+ {co.description &&
{co.description}
} +
{formatCurrency(co.amount)} + + {co.status} + + {co.dateSubmitted}
+ ) : ( +
+ +

No change orders for this project.

+
+ )} +
+ )} + + {/* RFIs TAB */} + {activeTab === 'rfis' && ( + + {(project.rfis || []).length > 0 ? ( + + + + {['ID', 'Subject', 'Status', 'Submitted By', 'Opened', 'Closed'].map(h => ( + + ))} + + + + {project.rfis.map((rfi, i) => ( + + + + + + + + + ))} + +
{h}
{rfi.id}{rfi.subject} + + {rfi.status} + + {rfi.submittedBy}{rfi.dateOpened}{rfi.dateClosed || '—'}
+ ) : ( +
+ +

No RFIs for this project.

+
+ )} +
+ )} + + {/* MILESTONES TAB */} + {activeTab === 'milestones' && ( + +

Milestone Timeline

+
+ {/* Timeline line */} +
+
+ {(project.milestones || []).map((ms, i) => ( +
+
+
+
+
+
{ms.name}
+
Due: {ms.dueDate} · Assigned to: {ms.assignedTo}
+
+ + {ms.status.replace('_', ' ')} + +
+
+
+ ))} +
+
+ + )} + + {/* INVOICES TAB */} + {activeTab === 'invoices' && ( + + {(project.invoices || []).length > 0 ? ( + + + + {['Invoice ID', 'Amount', 'Submitted By', 'Status', 'Due Date', 'Paid Date'].map(h => ( + + ))} + + + + {project.invoices.map((inv, i) => ( + setSelectedInvoice(inv)}> + + + + + + + + ))} + +
{h}
{inv.id} + {formatCurrency(inv.amount)} + {inv.submittedBy} + + {inv.status} + + {inv.dueDate}{inv.datePaid || '—'}
+ ) : ( +
+ +

No invoices for this project.

+
+ )} +
+ )} + + {/* RISK & ISSUES TAB */} + {activeTab === 'risks' && ( +
+ {/* Risk Log */} + +
+

+ Risk Log +

+
+ {(project.riskLog || []).length > 0 ? ( + + + + {['ID', 'Description', 'Severity', 'Likelihood', 'Status', 'Mitigation'].map(h => ( + + ))} + + + + {project.riskLog.map((r, i) => ( + + + + + + + + + ))} + +
{h}
{r.id}{r.description} + + {r.severity} + + {r.likelihood}{r.status}{r.mitigation}
+ ) : ( +
No risk entries for this project.
+ )} +
+ + {/* Issue Log */} + +
+

+ Issue Log +

+
+ {(project.issueLog || []).length > 0 ? ( + + + + {['ID', 'Title', 'Priority', 'Status', 'Assigned To', 'Reported'].map(h => ( + + ))} + + + + {project.issueLog.map((issue, i) => ( + + + + + + + + + ))} + +
{h}
{issue.id}{issue.title} + + {issue.priority} + + {issue.status}{issue.assignedTo}{issue.dateReported}
+ ) : ( +
No issues logged for this project.
+ )} +
+
+ )} + + {/* ACTIVITY TAB */} + {activeTab === 'activity' && ( + +

Activity Timeline

+ {(project.activityTimeline || []).length > 0 ? ( +
+
+
+ {project.activityTimeline.map((a, i) => ( +
+
+
+
+
+
{a.action}
+ {a.details &&
{a.details}
} +
+
+
{a.date}
+ {a.user &&
{a.user}
} +
+
+
+
+ ))} +
+
+ ) : ( +
+ +

No activity logged yet.

+
+ )} + + )} +
+
+ + {/* Modals & Drawers */} + setSelectedCO(null)} + changeOrder={selectedCO} + /> + setSelectedInvoice(null)} + invoice={selectedInvoice} + /> + igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew? +
+ ); +}; + +export default OwnerProjectDetail; diff --git a/src/pages/owner/OwnerProjectList.jsx b/src/pages/owner/OwnerProjectList.jsx new file mode 100644 index 0000000..74000f1 --- /dev/null +++ b/src/pages/owner/OwnerProjectList.jsx @@ -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 ( +
+ {/* Ambient Background */} +
+
+
+
+ +
+ + {/* Header */} +
+
+

+ Projects +

+

+ {ownerProjects.length} projects · {formatCurrency(totalBudget)} total budget · {formatCurrency(totalSpent)} spent +

+
+ +
+ + {/* Filters & Search */} + +
+ {/* Search */} +
+ + 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" + /> +
+ + {/* Status Filter */} +
+ + {availableStatuses.map(s => ( + + ))} +
+ + {/* Phase Filter */} + +
+
+ + {/* Project Table (Desktop) / Cards (Mobile) */} + + {/* Mobile Card View */} +
+ {sortedProjects.length > 0 ? sortedProjects.map(project => { + const cfg = statusConfig[project.status] || statusConfig.active; + const StatusIcon = cfg.icon; + return ( +
navigate(`/owner/projects/${project.id}`)} + className="p-4 active:bg-zinc-50 dark:active:bg-white/5 transition-colors cursor-pointer" + > +
+
+

{project.projectType}

+

{project.address}

+
+ +
+
+ + {cfg.label} + + {project.phase && ( + + {project.phase} + + )} + {project.healthScore != null && ( + + Health: {project.healthScore} + + )} +
+
+ Budget: {formatCurrency(project.approvedBudget || project.budget || 0)} + {project.variancePercent != null && ( + + {project.variancePercent > 0 ? '+' : ''}{project.variancePercent.toFixed(1)}% + + )} +
+
+
+
+
+ {project.completionPercentage || 0}% +
+
+ ); + }) : ( +
+ +

No projects match your filters.

+
+ )} +
+ + {/* Desktop Table View */} +
+ + + + {[ + { 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 => ( + + ))} + + + + + {sortedProjects.length > 0 ? sortedProjects.map(project => { + const cfg = statusConfig[project.status] || statusConfig.active; + const StatusIcon = cfg.icon; + return ( + navigate(`/owner/projects/${project.id}`)} + className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors cursor-pointer group" + > + {/* Project Name */} + + + {/* Status */} + + + {/* Phase */} + + + {/* Budget */} + + + {/* Variance */} + + + {/* Progress */} + + + {/* Health Score */} + + + {/* Arrow */} + + + ); + }) : ( + + + + )} + +
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' : ''}`} + > + + {col.label} + {sortConfig.key === col.key && ( + + )} + +
+
+ {project.projectType} +
+
{project.address}
+
+ + + {cfg.label} + + + {project.phase || '—'} + + {formatCurrency(project.approvedBudget || project.budget || 0)} + + + {project.variancePercent != null ? `${project.variancePercent > 0 ? '+' : ''}${project.variancePercent.toFixed(1)}%` : '—'} + + +
+
+
+
+ + {project.completionPercentage || 0}% + +
+
+ + {project.healthScore != null ? project.healthScore : '—'} + + + +
+ +

No projects match your filters.

+

Try adjusting your search or filter criteria.

+
+
+ + {/* Footer (both views) */} +
+ Showing {sortedProjects.length} of {ownerProjects.length} projects + + {formatCurrency(sortedProjects.reduce((s, p) => s + (p.approvedBudget || p.budget || 0), 0))} total + +
+ +
+ igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew? +
+ ); +}; + +export default OwnerProjectList; diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..0d199b2 --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + { "source": "/(.*)", "destination": "/index.html" } + ] +}