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) => (
+
+ ))}
+
+
+
+ {/* 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 => (
+ | {h} |
+ ))}
+
+
+
+ {(project.budgetBreakdown || []).map((b, i) => {
+ const variance = b.actual - b.allocated;
+ return (
+
+ | {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 => (
+ | {h} |
+ ))}
+
+
+
+ {project.changeOrders.map((co, i) => (
+ setSelectedCO(co)}>
+ | {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 => (
+ | {h} |
+ ))}
+
+
+
+ {project.rfis.map((rfi, i) => (
+
+ | {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 => (
+ | {h} |
+ ))}
+
+
+
+ {project.invoices.map((inv, i) => (
+ setSelectedInvoice(inv)}>
+ | {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 => (
+ | {h} |
+ ))}
+
+
+
+ {project.riskLog.map((r, i) => (
+
+ | {r.id} |
+ {r.description} |
+
+
+ {r.severity}
+
+ |
+ {r.likelihood} |
+ {r.status} |
+ {r.mitigation} |
+
+ ))}
+
+
+ ) : (
+ No risk entries for this project.
+ )}
+
+
+ {/* Issue Log */}
+
+
+ {(project.issueLog || []).length > 0 ? (
+
+
+
+ {['ID', 'Title', 'Priority', 'Status', 'Assigned To', 'Reported'].map(h => (
+ | {h} |
+ ))}
+
+
+
+ {project.issueLog.map((issue, i) => (
+
+ | {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 */}
+
+
+ {/* 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 => (
+ | 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 && (
+
+ )}
+
+ |
+ ))}
+ |
+
+
+
+ {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 */}
+ |
+
+ {project.projectType}
+
+ {project.address}
+ |
+
+ {/* Status */}
+
+
+
+ {cfg.label}
+
+ |
+
+ {/* Phase */}
+
+ {project.phase || '—'}
+ |
+
+ {/* Budget */}
+
+ {formatCurrency(project.approvedBudget || project.budget || 0)}
+ |
+
+ {/* Variance */}
+
+
+ {project.variancePercent != null ? `${project.variancePercent > 0 ? '+' : ''}${project.variancePercent.toFixed(1)}%` : '—'}
+
+ |
+
+ {/* Progress */}
+
+
+
+
+ {project.completionPercentage || 0}%
+
+
+ |
+
+ {/* Health Score */}
+
+
+ {project.healthScore != null ? project.healthScore : '—'}
+
+ |
+
+ {/* Arrow */}
+
+
+ |
+
+ );
+ }) : (
+
+ |
+
+ 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" }
+ ]
+}