feat: comprehensive mobile optimization, role-based AI chatbot, and multi-role dashboard enhancements

- Rewrote AI chatbot context system with deep role-aware data injection for all 7 roles (Owner, Admin, Field Agent, Contractor, Subcontractor, Vendor, Customer) plus guest fallback
- Added mobile-responsive bottom-sheet modals across all roles (ChangeOrderDrawer, InvoiceDetailModal, FinancialSummaryModal, VendorFinancialSummaryModal, FinancialDetailsModal)
- Converted Owner Project List and Vendor Orders tables to mobile card views with touch-friendly layouts
- Optimized Vendor Management and People Directory with mobile list/detail toggle and back navigation
- Fixed Document Control mobile view: horizontally scrollable filter tabs, proper overflow chain for document visibility
- Added responsive padding, text scaling, and flex-wrap across StatCard, TaskDetailsModal, OwnerSnapshot, OwnerProjectDetail, VendorDashboard, and DocumentManagement
- Expanded mock data store with richer vendor invoices, orders, documents, and compliance records
- Enhanced Owner Snapshot with financial KPI cards, urgent items panel, and Action Center modal
- Built Contractor Dashboard with task management, financial summary, and performance metrics
- Built Subcontractor Dashboard with clock-in tracking, task assignments, and invoice management
- Enhanced Vendor Dashboard with earnings summary, active orders, compliance status, and performance rating
- Added icon-only tab navigation on mobile for OwnerProjectDetail
- Extended attribution signatures across all platform pages
This commit is contained in:
Satyam
2026-02-18 12:34:55 +05:30
parent fe68947f1c
commit bc4e25f132
28 changed files with 3156 additions and 930 deletions
+1
View File
@@ -371,6 +371,7 @@ const AdminSchedule = () => {
</div>
</SpotlightCard>
</div>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+1
View File
@@ -221,6 +221,7 @@ const LeaderboardPage = () => {
</div>
</div>
</SpotlightCard>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+1
View File
@@ -278,6 +278,7 @@ const Login = () => {
</div>
</SpotlightCard>
</div>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+1
View File
@@ -414,6 +414,7 @@ function Maps() {
loadingAddr={loadingAddr}
onSave={handleSave}
/>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
}
+1
View File
@@ -50,6 +50,7 @@ const NotFound = () => {
<div className="absolute bottom-8 text-[10px] uppercase font-bold tracking-widest text-zinc-300 dark:text-zinc-700">
LynkedUpPro Infrastructure
</div>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+93 -38
View File
@@ -4,44 +4,51 @@ import { useAuth } from '../../context/AuthContext';
import { useMockStore } from '../../data/mockStore';
import { StatCard } from '../../components/StatCard';
import { SpotlightCard } from '../../components/SpotlightCard';
import { Hammer, CheckSquare, Clock, AlertTriangle, Calendar, DollarSign } from 'lucide-react';
import { Hammer, CheckSquare, Clock, AlertTriangle, Calendar, DollarSign, PauseCircle } from 'lucide-react';
import ProjectDetailsModal from '../../components/contractor/ProjectDetailsModal';
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
import TaskDetailsModal from '../../components/contractor/TaskDetailsModal';
const ContractorDashboard = () => {
const { user } = useAuth();
const { projects } = useMockStore();
// 1. Identify current contractor
// Filter projects where this contractor is the Lead
const myProjects = projects.filter(p => p.contractorId === user?.id || p.contractorId === 'con_001'); // Default to con_001 for mock
const myProjects = projects.filter(p => p.contractorId === user?.id || p.contractorId === 'con_001');
// 2. Aggregate Data
const activeProjects = myProjects.filter(p => p.status === 'active').length;
const upcomingProjects = myProjects.filter(p => p.status === 'scheduled').length;
const onHoldProjects = myProjects.filter(p => p.status === 'on_hold').length;
const completedProjects = myProjects.filter(p => p.status === 'completed').length;
// Calculate total budget managed
const totalBudget = myProjects.reduce((sum, p) => sum + (p.budget || 0), 0);
const totalSpent = myProjects.reduce((sum, p) => sum + (p.spent || 0), 0);
const budgetUtilization = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
// Upcoming Milestones (next 7 days)
const upcomingMilestones = myProjects.flatMap(p =>
p.milestones.filter(m => {
const dueDate = new Date(m.dueDate);
const today = new Date('2026-02-05'); // Fixed context date
const diffTime = Math.abs(dueDate - today);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays <= 7 && m.status !== 'completed';
}).map(m => ({ ...m, projectAddress: p.address }))
// Pending invoice amounts
const pendingInvoiceAmount = myProjects.reduce((sum, p) =>
sum + (p.invoices || []).filter(i => i.status === 'pending').reduce((s, i) => s + i.amount, 0), 0
);
// Upcoming Milestones (next 14 days from context date)
const upcomingMilestones = myProjects.flatMap(p =>
(p.milestones || []).filter(m => {
const dueDate = new Date(m.dueDate);
const today = new Date('2026-02-18'); // Current context date
const diffDays = (dueDate - today) / (1000 * 60 * 60 * 24);
return diffDays >= -3 && diffDays <= 14 && m.status !== 'completed';
}).map(m => ({ ...m, projectAddress: p.address, projectId: p.id }))
).sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate));
const navigate = useNavigate();
// Modal state
const [selectedProject, setSelectedProject] = useState(null);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isFinancialModalOpen, setIsFinancialModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState(null);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
// Financial data for modal
const financialData = {
@@ -49,7 +56,7 @@ const ContractorDashboard = () => {
spent: totalSpent,
remaining: totalBudget - totalSpent,
paid: totalSpent,
pending: 0,
pending: pendingInvoiceAmount,
items: myProjects.map(p => ({
date: p.startDate,
description: p.address,
@@ -64,6 +71,11 @@ const ContractorDashboard = () => {
setIsProjectModalOpen(true);
};
const handleTaskClick = (task) => {
setSelectedTask(task);
setIsTaskModalOpen(true);
};
return (
<div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8 animate-in fade-in duration-500">
{/* Header */}
@@ -89,18 +101,24 @@ const ContractorDashboard = () => {
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
label="Active Projects"
value={activeProjects}
icon={Hammer}
color="blue"
/>
<StatCard
label="Upcoming Starts"
value={upcomingProjects}
icon={Calendar}
color="purple"
/>
<div onClick={() => navigate('/contractor/projects')} className="cursor-pointer">
<StatCard
label="Active Projects"
value={activeProjects}
icon={Hammer}
color="blue"
trendLabel={`${completedProjects} completed`}
/>
</div>
<div onClick={() => navigate('/contractor/projects')} className="cursor-pointer">
<StatCard
label="On Hold"
value={onHoldProjects}
icon={PauseCircle}
color="purple"
trendLabel={`${myProjects.length} total`}
/>
</div>
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
<StatCard
label="Budget Utilization"
@@ -110,12 +128,15 @@ const ContractorDashboard = () => {
suffix="%"
/>
</div>
<StatCard
label="Pending Actions"
value={upcomingMilestones.length}
icon={AlertTriangle}
color="amber"
/>
<div onClick={() => { if (upcomingMilestones.length > 0) handleTaskClick(upcomingMilestones[0]); }} className="cursor-pointer">
<StatCard
label="Pending Actions"
value={upcomingMilestones.length}
icon={AlertTriangle}
color="amber"
trendLabel="due soon"
/>
</div>
</div>
{/* Main Content */}
@@ -141,8 +162,14 @@ const ContractorDashboard = () => {
<h4 className="font-bold text-zinc-900 dark:text-white">{proj.address}</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400">{proj.projectType} Started {new Date(proj.startDate).toLocaleDateString()}</p>
</div>
<span className="px-2 py-1 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-xs font-bold rounded uppercase">
On Track
<span className={`px-2 py-1 text-xs font-bold rounded uppercase ${
proj.spent > proj.budget
? 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400'
: proj.completionPercentage >= 70
? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400'
: 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400'
}`}>
{proj.spent > proj.budget ? 'Over Budget' : proj.completionPercentage >= 70 ? 'On Track' : 'In Progress'}
</span>
</div>
@@ -185,15 +212,37 @@ const ContractorDashboard = () => {
{/* Right Panel: Tasks & Alerts */}
<div className="space-y-6">
<SpotlightCard className="p-6">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Critical Tasks (Next 7 Days)</h2>
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Critical Tasks (Next 14 Days)</h2>
<div className="space-y-3">
{upcomingMilestones.length > 0 ? upcomingMilestones.map((ms, idx) => (
<div key={idx} className="flex items-start gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-500/5 hover:bg-red-100 dark:hover:bg-red-500/10 transition-colors cursor-pointer group">
<div className="mt-1 w-2 h-2 rounded-full bg-red-500 shrink-0 group-hover:scale-125 transition-transform" />
<div
key={idx}
onClick={() => handleTaskClick(ms)}
className={`flex items-start gap-3 p-3 rounded-lg transition-colors cursor-pointer group ${
new Date(ms.dueDate) < new Date('2026-02-18')
? 'bg-red-50 dark:bg-red-500/5 hover:bg-red-100 dark:hover:bg-red-500/10'
: ms.status === 'in_progress'
? 'bg-blue-50 dark:bg-blue-500/5 hover:bg-blue-100 dark:hover:bg-blue-500/10'
: 'bg-amber-50 dark:bg-amber-500/5 hover:bg-amber-100 dark:hover:bg-amber-500/10'
}`}
>
<div className={`mt-1 w-2 h-2 rounded-full shrink-0 group-hover:scale-125 transition-transform ${
new Date(ms.dueDate) < new Date('2026-02-18') ? 'bg-red-500' :
ms.status === 'in_progress' ? 'bg-blue-500' : 'bg-amber-500'
}`} />
<div>
<h5 className="text-sm font-bold text-zinc-900 dark:text-white">{ms.name}</h5>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{ms.projectAddress}</p>
<p className="text-xs font-mono font-medium text-red-500 mt-1">Due: {ms.dueDate}</p>
<div className="flex items-center gap-2 mt-1">
<p className={`text-xs font-mono font-medium ${
new Date(ms.dueDate) < new Date('2026-02-18') ? 'text-red-500' : 'text-zinc-500'
}`}>Due: {ms.dueDate}</p>
<span className={`text-[10px] font-bold uppercase px-1.5 py-0.5 rounded ${
ms.status === 'in_progress'
? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
: 'bg-amber-100 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400'
}`}>{ms.status.replace('_', ' ')}</span>
</div>
</div>
</div>
)) : (
@@ -233,6 +282,12 @@ const ContractorDashboard = () => {
role="CONTRACTOR"
data={financialData}
/>
<TaskDetailsModal
isOpen={isTaskModalOpen}
onClose={() => setIsTaskModalOpen(false)}
task={selectedTask}
/>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+1
View File
@@ -136,6 +136,7 @@ const ProjectList = () => {
onClose={() => setIsProjectModalOpen(false)}
project={selectedProject}
/>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+56 -17
View File
@@ -1,42 +1,64 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useMockStore } from '../../data/mockStore';
import DocumentReviewQueue from '../../components/documents/DocumentReviewQueue';
import { FileText, Shield, AlertTriangle, CheckCircle, Clock } from 'lucide-react';
import { FileText, Shield, AlertTriangle, CheckCircle, Clock, XCircle } from 'lucide-react';
import { SpotlightCard } from '../../components/SpotlightCard';
import { toast } from 'sonner';
const DocumentManagement = () => {
const { documents } = useMockStore();
// Live stats from actual data
const stats = useMemo(() => {
const pendingReview = documents.filter(d => d.status === 'pending_review').length;
const expired = documents.filter(d => d.status === 'expired').length;
const expiringSoon = documents.filter(d => {
if (!d.expirationDate || d.status === 'expired') return false;
const expDate = new Date(d.expirationDate);
const today = new Date('2026-02-18');
const diffDays = (expDate - today) / (1000 * 60 * 60 * 24);
return diffDays > 0 && diffDays <= 30;
}).length;
const approved = documents.filter(d => d.status === 'approved').length;
return { pendingReview, expired, expiringSoon, approved, total: documents.length };
}, [documents]);
return (
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden relative">
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden lg:overflow-hidden overflow-y-auto relative">
{/* Ambient Background Glows */}
<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 flex-1 flex flex-col min-h-0 max-w-7xl mx-auto w-full p-4 md:p-6 lg:p-8">
<div className="relative z-10 flex-1 flex flex-col lg:min-h-0 max-w-7xl mx-auto w-full p-4 md:p-6 lg:p-8">
<header className="mb-6 flex flex-col md:flex-row justify-between items-start md:items-end shrink-0">
<div>
<h1 className="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">Document Control</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-1">Review, approve, and manage critical business documents.</p>
</div>
<div className="mt-4 md:mt-0 flex space-x-3">
<div className="mt-4 md:mt-0 flex flex-wrap gap-3">
<SpotlightCard className="cursor-pointer hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors" rounded="rounded-xl">
<div className="px-4 py-2 flex items-center gap-2">
<Shield size={16} className="text-emerald-500" />
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300">Audit Log</span>
</div>
</SpotlightCard>
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold transition-colors shadow-lg shadow-blue-600/20">
<button
onClick={() => toast.info('Upload modal coming soon')}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold transition-colors shadow-lg shadow-blue-600/20"
>
<FileText size={16} />
<span>Upload New</span>
</button>
</div>
</header>
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
<div className="flex-1 flex flex-col lg:flex-row gap-6 lg:min-h-0">
{/* Main Content: Review Queue */}
<div className="w-full lg:w-2/3 flex flex-col min-h-0">
<SpotlightCard className="h-full flex flex-col overflow-hidden">
<div className="p-6 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 flex items-center gap-2">
<SpotlightCard className="lg:h-full flex flex-col lg:overflow-hidden">
<div className="p-4 sm:p-6 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 flex items-center gap-2 shrink-0">
<div className="p-2 bg-amber-100 dark:bg-amber-500/10 rounded-lg text-amber-600 dark:text-amber-400">
<AlertTriangle size={20} />
</div>
@@ -45,7 +67,7 @@ const DocumentManagement = () => {
<p className="text-xs text-zinc-500 dark:text-zinc-400">Documents requiring your attention</p>
</div>
</div>
<div className="flex-1 overflow-hidden p-6">
<div className="flex-1 lg:overflow-hidden p-4 sm:p-6">
<DocumentReviewQueue />
</div>
</SpotlightCard>
@@ -53,28 +75,44 @@ const DocumentManagement = () => {
{/* Sidebar: Compliance Overview */}
<div className="w-full lg:w-1/3 flex flex-col gap-6">
<SpotlightCard className="p-6">
<SpotlightCard className="p-4 sm:p-6">
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider mb-4 flex items-center gap-2">
<Clock size={16} className="text-blue-500" />
Quick Stats
</h3>
<div className="space-y-4">
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Pending Approval</span>
<span className="text-xl font-bold text-amber-500">3</span>
<span className="text-zinc-500 dark:text-zinc-400 text-sm flex items-center gap-2">
<Clock size={14} className="text-amber-500" /> Pending Review
</span>
<span className="text-xl font-bold text-amber-500">{stats.pendingReview}</span>
</div>
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Expiring Soon (30d)</span>
<span className="text-xl font-bold text-red-500">1</span>
<span className="text-zinc-500 dark:text-zinc-400 text-sm flex items-center gap-2">
<XCircle size={14} className="text-red-500" /> Expired
</span>
<span className="text-xl font-bold text-red-500">{stats.expired}</span>
</div>
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm flex items-center gap-2">
<AlertTriangle size={14} className="text-orange-500" /> Expiring Soon (30d)
</span>
<span className="text-xl font-bold text-orange-500">{stats.expiringSoon}</span>
</div>
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500 dark:text-zinc-400 text-sm flex items-center gap-2">
<CheckCircle size={14} className="text-emerald-500" /> Approved
</span>
<span className="text-xl font-bold text-emerald-500">{stats.approved}</span>
</div>
<div className="flex justify-between items-center py-3">
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Total Documents</span>
<span className="text-xl font-bold text-zinc-900 dark:text-white">124</span>
<span className="text-xl font-bold text-zinc-900 dark:text-white">{stats.total}</span>
</div>
</div>
</SpotlightCard>
<SpotlightCard className="p-6 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/10 dark:to-indigo-900/10 border-blue-200 dark:border-blue-500/20">
<SpotlightCard className="p-4 sm:p-6 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/10 dark:to-indigo-900/10 border-blue-200 dark:border-blue-500/20">
<h3 className="text-sm font-bold text-blue-700 dark:text-blue-300 mb-2 flex items-center gap-2">
<CheckCircle size={16} />
Compliance Tip
@@ -86,6 +124,7 @@ const DocumentManagement = () => {
</div>
</div>
</div>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+240 -37
View File
@@ -1,40 +1,131 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { useMockStore } from '../../data/mockStore';
import FinancialKPICards from '../../components/owner/FinancialKPICards';
import UrgentItemsPanel from '../../components/owner/UrgentItemsPanel';
import FinancialDetailsModal from '../../components/owner/FinancialDetailsModal';
import ActionCenterModal from '../../components/owner/ActionCenterModal';
import { SpotlightCard } from '../../components/SpotlightCard';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend
} from 'recharts';
import {
Briefcase, TrendingUp, AlertTriangle, Shield, ChevronRight, ArrowUpRight
} from 'lucide-react';
const OwnerSnapshot = () => {
const { user } = useAuth();
const navigate = useNavigate();
const { owners, projects } = useMockStore();
// Find the current owner profile
const owner = useMemo(() =>
owners?.find(o => o.id === user?.id) || null
, [owners, user]);
// Filter projects for this owner
const ownerProjects = useMemo(() =>
projects.filter(p => p.ownerId === user?.id)
, [projects, user]);
// Modal States
const [financialModal, setFinancialModal] = useState({ isOpen: false, type: 'revenue' });
const [actionModal, setActionModal] = useState({ isOpen: false, filter: 'all' });
// Handlers
const handleFinancialClick = (type) => {
setFinancialModal({ isOpen: true, type });
};
const handleActionClick = (id) => {
// Map IDs to filter types
const filterMap = {
'docs': 'docs',
'vendors': 'vendors',
'invoices': 'invoices',
'all': 'all'
};
const filterMap = { docs: 'docs', vendors: 'vendors', invoices: 'invoices', all: 'all' };
setActionModal({ isOpen: true, filter: filterMap[id] || 'all' });
};
// Get time of day for greeting
// Greeting
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
// --- Derived Stats ---
const projectStats = useMemo(() => {
const active = ownerProjects.filter(p => p.status === 'active').length;
const completed = ownerProjects.filter(p => p.status === 'completed').length;
const delayed = ownerProjects.filter(p => p.status === 'delayed').length;
const onHold = ownerProjects.filter(p => p.status === 'on_hold').length;
const disputed = ownerProjects.filter(p => p.status === 'disputed').length;
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);
const avgHealth = ownerProjects.length > 0
? Math.round(ownerProjects.reduce((sum, p) => sum + (p.healthScore || 0), 0) / ownerProjects.length)
: 0;
return { active, completed, delayed, onHold, disputed, totalBudget, totalSpent, avgHealth, total: ownerProjects.length };
}, [ownerProjects]);
// --- Chart Data ---
const statusChartData = useMemo(() => [
{ name: 'Active', value: projectStats.active, color: '#22c55e' },
{ name: 'Completed', value: projectStats.completed, color: '#3b82f6' },
{ name: 'Delayed', value: projectStats.delayed, color: '#f59e0b' },
{ name: 'On Hold', value: projectStats.onHold, color: '#a855f7' },
{ name: 'Disputed', value: projectStats.disputed, color: '#ef4444' },
].filter(d => d.value > 0), [projectStats]);
const budgetChartData = useMemo(() =>
ownerProjects.slice(0, 6).map(p => ({
name: p.projectType.length > 14 ? p.projectType.slice(0, 14) + '...' : p.projectType,
budget: (p.approvedBudget || p.budget || 0) / 1000,
spent: (p.actualCost || p.spent || 0) / 1000,
}))
, [ownerProjects]);
const monthlySpendData = owner?.dashboardSummary?.monthlySpend || [];
// Activity feed from owner's projects
const activityFeed = useMemo(() => {
const items = [];
ownerProjects.forEach(p => {
if (p.activityTimeline) {
p.activityTimeline.forEach(a => items.push({ ...a, projectType: p.projectType, projectId: p.id }));
}
});
// Sort by date desc, take top 5
items.sort((a, b) => new Date(b.date) - new Date(a.date));
return items.slice(0, 5);
}, [ownerProjects]);
const formatCurrency = (val) => {
if (val >= 1000000) return `$${(val / 1000000).toFixed(1)}M`;
if (val >= 1000) return `$${(val / 1000).toFixed(0)}k`;
return `$${val}`;
};
const getRiskColor = (score) => {
if (score <= 30) return 'text-emerald-500 bg-emerald-100 dark:bg-emerald-500/10';
if (score <= 60) return 'text-amber-500 bg-amber-100 dark:bg-amber-500/10';
return 'text-red-500 bg-red-100 dark:bg-red-500/10';
};
const getHealthColor = (score) => {
if (score >= 70) return 'text-emerald-500';
if (score >= 40) return 'text-amber-500';
return 'text-red-500';
};
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> ${p.value}k
</p>
))}
</div>
);
}
return null;
};
return (
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white relative pb-20 transition-colors duration-300">
@@ -44,44 +135,136 @@ const OwnerSnapshot = () => {
<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-8 max-w-7xl mx-auto space-y-8">
<div className="relative z-10 p-4 sm:p-8 max-w-7xl mx-auto space-y-8">
{/* Header Section */}
<header className="flex flex-col md:flex-row justify-between items-start md:items-center border-b border-zinc-200 dark:border-white/5 pb-6">
<div>
<h1 className="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">
<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">
{greeting}, {user?.name?.split(' ')[0] || 'Owner'}
</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-2 font-light">Here's your business snapshot for today.</p>
<p className="text-zinc-500 dark:text-zinc-400 mt-2 font-light">
{owner ? `${owner.companyName}${owner.portfolioSize} projects in portfolio` : "Here's your business snapshot for today."}
</p>
</div>
<div className="mt-4 md:mt-0 flex space-x-3">
<div className="mt-4 md:mt-0 flex flex-wrap gap-3">
<button className="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 text-zinc-700 dark:text-zinc-300 transition-colors border border-zinc-200 dark:border-white/5 shadow-sm">
Download Report
</button>
<button className="px-4 py-2 bg-amber-500 hover:bg-amber-600 text-black font-bold rounded-xl text-sm transition-colors shadow-lg shadow-amber-500/20">
Quick Action
<button
onClick={() => navigate('/owner/projects')}
className="px-4 py-2 bg-amber-500 hover:bg-amber-600 text-black font-bold rounded-xl text-sm transition-colors shadow-lg shadow-amber-500/20 flex items-center gap-2"
>
View Projects <ArrowUpRight size={16} />
</button>
</div>
</header>
{/* Quick Stats Row */}
<section className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: 'Total Projects', value: projectStats.total, icon: Briefcase, color: 'text-blue-500', onClick: () => navigate('/owner/projects') },
{ label: 'Active', value: projectStats.active, icon: TrendingUp, color: 'text-emerald-500', onClick: () => navigate('/owner/projects') },
{ label: 'Avg Health', value: `${projectStats.avgHealth}%`, icon: Shield, color: getHealthColor(projectStats.avgHealth), onClick: () => navigate('/owner/projects') },
{ label: 'Risk Score', value: owner?.riskScore ?? '—', icon: AlertTriangle, color: owner ? getRiskColor(owner.riskScore).split(' ')[0] : 'text-zinc-500', onClick: () => handleActionClick('all') },
].map((stat, i) => (
<SpotlightCard key={i} className="p-4 cursor-pointer" onClick={stat.onClick}>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 ${stat.color}`}>
<stat.icon size={18} />
</div>
<div>
<div className="text-2xl font-extrabold text-zinc-900 dark:text-white">{stat.value}</div>
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500">{stat.label}</div>
</div>
</div>
</SpotlightCard>
))}
</section>
{/* Financial KPIs */}
<section>
<h2 className="text-xl font-bold text-zinc-900 dark:text-white/90 mb-4 flex items-center">
<span className="w-2 h-8 bg-emerald-500 rounded-full mr-3"></span>
Financial Overview
</h2>
<FinancialKPICards onCardClick={handleFinancialClick} />
<FinancialKPICards onCardClick={handleFinancialClick} ownerId={user?.id} />
</section>
{/* Charts Row */}
<section className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Budget vs Actual */}
<SpotlightCard className="lg:col-span-2 p-6">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-1">Budget vs Actual Spend</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Per project (in $k)</p>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={budgetChartData} barGap={4}>
<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="budget" name="Budget" fill="#3b82f6" radius={[6, 6, 0, 0]} />
<Bar dataKey="spent" name="Spent" fill="#f59e0b" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</SpotlightCard>
{/* Project Status Distribution */}
<SpotlightCard className="p-6">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-1">Project Status</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Distribution</p>
<div className="h-64 flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={statusChartData}
cx="50%" cy="50%"
innerRadius={50} outerRadius={80}
paddingAngle={4}
dataKey="value"
>
{statusChartData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend
formatter={(value) => <span className="text-xs text-zinc-600 dark:text-zinc-400">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
</SpotlightCard>
</section>
{/* Monthly Spend Trend */}
{monthlySpendData.length > 0 && (
<SpotlightCard className="p-6">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-1">Monthly Spend Trend</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Last 12 months (in $k)</p>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlySpendData}>
<XAxis dataKey="month" tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="amount" name="Spend" fill="#8b5cf6" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</SpotlightCard>
)}
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Urgent Items Panel (Takes up 2 columns on large screens) */}
{/* Urgent Items Panel */}
<section className="lg:col-span-2">
<UrgentItemsPanel onActionClick={handleActionClick} />
<UrgentItemsPanel onActionClick={handleActionClick} ownerId={user?.id} />
</section>
{/* Activity Feed / Notifications Placeholder (Right Column) */}
{/* Activity Feed */}
<SpotlightCard className="h-full">
<div className="p-6">
<div className="flex items-center mb-6">
@@ -89,28 +272,47 @@ const OwnerSnapshot = () => {
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Recent Activity</h3>
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
{activityFeed.length > 0 ? activityFeed.map((item, i) => (
<div key={i} className="flex items-start space-x-3 p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
<div className="w-2 h-2 mt-2 rounded-full bg-blue-400 shrink-0"></div>
<div className={`w-2 h-2 mt-2 rounded-full shrink-0 ${
item.action?.toLowerCase().includes('payment') ? 'bg-emerald-400' :
item.action?.toLowerCase().includes('issue') || item.action?.toLowerCase().includes('dispute') ? 'bg-red-400' :
'bg-blue-400'
}`}></div>
<div>
<p className="text-sm text-zinc-600 dark:text-zinc-300">New invoice submitted by <span className="text-zinc-900 dark:text-white font-bold">Texas Builders LLC</span></p>
<span className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">2 hours ago</span>
<p className="text-sm text-zinc-600 dark:text-zinc-300">
<span className="text-zinc-900 dark:text-white font-bold">{item.action}</span>
{item.details && <span className="text-zinc-500"> {item.details}</span>}
</p>
<span className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">
{item.date} · {item.projectType}
</span>
</div>
</div>
))}
<div className="flex items-start space-x-3 p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
<div className="w-2 h-2 mt-2 rounded-full bg-emerald-400 shrink-0"></div>
<div>
<p className="text-sm text-zinc-600 dark:text-zinc-300">Payment received for <span className="text-zinc-900 dark:text-white font-bold">Project P-2602</span></p>
<span className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">5 hours ago</span>
</div>
</div>
)) : (
// Fallback when no activityTimeline data
<>
{[
{ msg: `${projectStats.active} active projects in progress`, time: 'Current', color: 'bg-blue-400' },
{ msg: `${formatCurrency(projectStats.totalSpent)} spent of ${formatCurrency(projectStats.totalBudget)} total budget`, time: 'Overview', color: 'bg-emerald-400' },
{ msg: `${projectStats.completed} projects completed successfully`, time: 'All time', color: 'bg-purple-400' },
].map((item, i) => (
<div key={i} className="flex items-start space-x-3 p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
<div className={`w-2 h-2 mt-2 rounded-full ${item.color} shrink-0`}></div>
<div>
<p className="text-sm text-zinc-600 dark:text-zinc-300">{item.msg}</p>
<span className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">{item.time}</span>
</div>
</div>
))}
</>
)}
</div>
<button
onClick={() => navigate('/owner/vendors')}
className="w-full mt-6 py-2 text-xs font-bold uppercase tracking-wider text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white border border-zinc-200 dark:border-white/10 hover:bg-zinc-100 dark:hover:bg-white/5 rounded-xl transition-all"
onClick={() => navigate('/owner/projects')}
className="w-full mt-6 py-2 text-xs font-bold uppercase tracking-wider text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white border border-zinc-200 dark:border-white/10 hover:bg-zinc-100 dark:hover:bg-white/5 rounded-xl transition-all flex items-center justify-center gap-2"
>
View All Activity
View All Projects <ChevronRight size={14} />
</button>
</div>
</SpotlightCard>
@@ -123,15 +325,16 @@ const OwnerSnapshot = () => {
isOpen={financialModal.isOpen}
onClose={() => setFinancialModal({ ...financialModal, isOpen: false })}
type={financialModal.type}
ownerId={user?.id}
/>
<ActionCenterModal
isOpen={actionModal.isOpen}
onClose={() => setActionModal({ ...actionModal, isOpen: false })}
defaultFilter={actionModal.filter}
// Force re-render on filter change if needed, generally okay as props update
key={actionModal.filter}
/>
</div >
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+25 -11
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { useMockStore } from '../../data/mockStore';
import { Search, Filter, Shield, User, Briefcase, Zap } from 'lucide-react';
import { Search, Filter, Shield, User, Briefcase, Zap, ArrowLeft } from 'lucide-react';
import MaskedData from '../../components/MaskedData';
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
import { SpotlightCard } from '../../components/SpotlightCard';
@@ -10,6 +10,11 @@ const PeopleDirectory = () => {
const [filter, setFilter] = useState('all'); // all, employee, contractor, subcontractor
const [search, setSearch] = useState('');
const [selectedPerson, setSelectedPerson] = useState(null);
const detailRef = useRef(null);
const handleSelectPerson = (person) => {
setSelectedPerson(person);
};
// Combine and normalize data for display
const allPeople = [
@@ -30,7 +35,7 @@ const PeopleDirectory = () => {
});
return (
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden relative">
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden lg:overflow-hidden overflow-y-auto relative">
{/* Ambient Background Glows */}
<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]" />
@@ -45,7 +50,7 @@ const PeopleDirectory = () => {
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
{/* Left Sidebar: List & Search */}
<SpotlightCard className="w-full lg:w-1/3 flex flex-col overflow-hidden">
<SpotlightCard className={`w-full lg:w-1/3 flex flex-col overflow-hidden ${selectedPerson ? 'hidden lg:flex' : 'flex'}`}>
<div className="p-4 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
@@ -93,7 +98,7 @@ const PeopleDirectory = () => {
{filteredPeople.map((person) => (
<div
key={person.id}
onClick={() => setSelectedPerson(person)}
onClick={() => handleSelectPerson(person)}
className={`p-3 rounded-xl border transition-all cursor-pointer group ${selectedPerson?.id === person.id
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 shadow-sm'
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
@@ -119,15 +124,23 @@ const PeopleDirectory = () => {
</SpotlightCard>
{/* Right Panel: Details */}
<div className="w-full lg:w-2/3 flex flex-col min-h-0">
<div className={`w-full lg:w-2/3 flex flex-col min-h-0 ${!selectedPerson ? 'hidden lg:flex' : 'flex'}`} ref={detailRef}>
{selectedPerson ? (
<SpotlightCard className="h-full flex flex-col overflow-hidden">
{/* Mobile Back Button */}
<button
onClick={() => setSelectedPerson(null)}
className="lg:hidden flex items-center gap-2 px-4 py-3 text-sm font-bold text-blue-600 dark:text-blue-400 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5"
>
<ArrowLeft size={16} />
Back to People
</button>
{/* Header */}
<div className="p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
<div className="p-4 sm:p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center space-x-3 mb-2">
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white">{selectedPerson.name}</h2>
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-white">{selectedPerson.name}</h2>
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${selectedPerson.status === 'active'
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20'
@@ -146,10 +159,10 @@ const PeopleDirectory = () => {
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 sm:p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8">
{/* Sensitive Data Section */}
<div className="space-y-8">
<div className="space-y-6 sm:space-y-8">
<div className="space-y-4">
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2">
<Shield size={16} className="text-blue-500" />
@@ -227,6 +240,7 @@ const PeopleDirectory = () => {
</div>
</div>
</div>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+26 -11
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useMockStore } from '../../data/mockStore';
import { Search, Filter, Briefcase, DollarSign, FileText, AlertTriangle, CheckCircle } from 'lucide-react';
import { Search, Filter, Briefcase, DollarSign, FileText, AlertTriangle, CheckCircle, ArrowLeft } from 'lucide-react';
import MaskedData from '../../components/MaskedData';
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
import { SpotlightCard } from '../../components/SpotlightCard';
@@ -10,6 +10,12 @@ const VendorDirectory = () => {
const [filterType, setFilterType] = useState('all'); // all, roofing, electrical, plumbing, hvac
const [search, setSearch] = useState('');
const [selectedVendor, setSelectedVendor] = useState(null);
const detailRef = useRef(null);
// On mobile, scroll to detail when vendor selected
const handleSelectVendor = (vendor) => {
setSelectedVendor(vendor);
};
const filteredVendors = vendors.filter(vendor => {
const matchesSearch = vendor.vendorName.toLowerCase().includes(search.toLowerCase()) ||
@@ -24,7 +30,7 @@ const VendorDirectory = () => {
const totalSpend = filteredVendors.reduce((acc, v) => acc + (v.spend?.totalSpend || 0), 0);
return (
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden relative">
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden lg:overflow-hidden overflow-y-auto relative">
{/* Ambient Background Glows */}
<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]" />
@@ -46,7 +52,7 @@ const VendorDirectory = () => {
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
{/* Left Sidebar: Vendor List */}
<SpotlightCard className="w-full lg:w-1/3 flex flex-col overflow-hidden">
<SpotlightCard className={`w-full lg:w-1/3 flex flex-col overflow-hidden ${selectedVendor ? 'hidden lg:flex' : 'flex'}`}>
<div className="p-4 border-b border-zinc-200 dark:border-white/5 space-y-4 bg-zinc-50/50 dark:bg-white/5">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
@@ -79,7 +85,7 @@ const VendorDirectory = () => {
{filteredVendors.map((vendor) => (
<div
key={vendor.id}
onClick={() => setSelectedVendor(vendor)}
onClick={() => handleSelectVendor(vendor)}
className={`p-3 rounded-xl border transition-all cursor-pointer group ${selectedVendor?.id === vendor.id
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 shadow-sm'
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
@@ -109,15 +115,23 @@ const VendorDirectory = () => {
</SpotlightCard>
{/* Right Panel: Vendor Details */}
<div className="w-full lg:w-2/3 flex flex-col min-h-0">
<div className={`w-full lg:w-2/3 flex flex-col min-h-0 ${!selectedVendor ? 'hidden lg:flex' : 'flex'}`} ref={detailRef}>
{selectedVendor ? (
<SpotlightCard className="h-full flex flex-col overflow-hidden">
{/* Mobile Back Button */}
<button
onClick={() => setSelectedVendor(null)}
className="lg:hidden flex items-center gap-2 px-4 py-3 text-sm font-bold text-blue-600 dark:text-blue-400 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5"
>
<ArrowLeft size={16} />
Back to Vendors
</button>
{/* Header */}
<div className="p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
<div className="p-4 sm:p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center space-x-3 mb-2">
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white">{selectedVendor.vendorName}</h2>
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-white">{selectedVendor.vendorName}</h2>
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${selectedVendor.status === 'active'
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20'
@@ -136,10 +150,10 @@ const VendorDirectory = () => {
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 sm:p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8">
{/* Financials & Compliance */}
<div className="space-y-8">
<div className="space-y-6 sm:space-y-8">
<div className="space-y-4">
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2">
<DollarSign size={16} className="text-emerald-500" />
@@ -211,6 +225,7 @@ const VendorDirectory = () => {
</div>
</div>
</div>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
@@ -1,47 +1,235 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useAuth } from '../../context/AuthContext';
import { useMockStore } from '../../data/mockStore';
import { StatCard } from '../../components/StatCard';
import { SpotlightCard } from '../../components/SpotlightCard';
import { CheckSquare, Clock, Wallet, MapPin, Camera } from 'lucide-react';
import { CheckSquare, Clock, Wallet, MapPin, Camera, X, Calendar, DollarSign, ChevronRight } from 'lucide-react';
import TaskDetailsModal from '../../components/contractor/TaskDetailsModal';
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
// --- Reusable list modal for filtered tasks / clock-in / payment history ---
const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIns, onTaskClick }) => {
const [selectedItem, setSelectedItem] = useState(null);
React.useEffect(() => {
const handleEsc = (e) => { if (e.key === 'Escape') { if (selectedItem) setSelectedItem(null); else onClose(); } };
if (isOpen) window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose, selectedItem]);
if (!isOpen) return null;
const formatCurrency = (amt) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amt);
const getStatusColor = (s) => {
if (s === 'completed' || s === 'paid') return 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10';
if (s === 'in_progress') return 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10';
if (s === 'pending') return 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10';
return 'text-zinc-500 bg-zinc-100 dark:text-zinc-400 dark:bg-white/5';
};
// If a specific task is selected, show detail view
if (selectedItem && (type === 'tasks_in_progress' || type === 'pending_tasks' || type === 'jobs_completed')) {
const task = selectedItem;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setSelectedItem(null)} />
<div className="relative w-full max-w-lg bg-white dark:bg-[#121214] rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden animate-in zoom-in-95 duration-200">
<div className="px-6 py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-center bg-zinc-50/50 dark:bg-white/5">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Task Detail</h2>
<button onClick={() => setSelectedItem(null)} className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
<div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">{task.name}</h3>
<span className={`mt-2 inline-block px-3 py-1 rounded-full text-xs font-bold uppercase ${getStatusColor(task.status)}`}>{task.status.replace('_', ' ')}</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
{[['Project', task.projectAddress], ['Due Date', task.dueDate], ['Assigned To', task.assignedTo], ['Project Status', task.projectStatus]].map(([l, v], i) => (
<div key={i} className="p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">{l}</div>
<div className="font-semibold text-zinc-900 dark:text-white">{v}</div>
</div>
))}
</div>
</div>
<div className="px-6 py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-end">
<button onClick={() => setSelectedItem(null)} className="px-4 py-2 text-sm font-bold rounded-xl border border-zinc-200 dark:border-white/10 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">Close</button>
</div>
</div>
</div>,
document.body
);
}
// If a specific invoice is selected in payment history
if (selectedItem && type === 'payment_history') {
const inv = selectedItem;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setSelectedItem(null)} />
<div className="relative w-full max-w-lg bg-white dark:bg-[#121214] rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden animate-in zoom-in-95 duration-200">
<div className="px-6 py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-center bg-zinc-50/50 dark:bg-white/5">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Payment Detail</h2>
<button onClick={() => setSelectedItem(null)} className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<div className="text-3xl font-extrabold text-zinc-900 dark:text-white">{formatCurrency(inv.amount)}</div>
<span className={`px-3 py-1 rounded-full text-xs font-bold uppercase ${getStatusColor(inv.status)}`}>{inv.status}</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
{[['Invoice ID', inv.id], ['Due Date', inv.dueDate], ['Date Paid', inv.datePaid || 'Not yet'], ['Submitted By', inv.submittedBy]].map(([l, v], i) => (
<div key={i} className="p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">{l}</div>
<div className="font-semibold text-zinc-900 dark:text-white">{v}</div>
</div>
))}
</div>
</div>
<div className="px-6 py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-end">
<button onClick={() => setSelectedItem(null)} className="px-4 py-2 text-sm font-bold rounded-xl border border-zinc-200 dark:border-white/10 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">Close</button>
</div>
</div>
</div>,
document.body
);
}
// Main list modal
const renderContent = () => {
if (type === 'tasks_in_progress' || type === 'pending_tasks' || type === 'jobs_completed') {
const items = tasks || [];
return items.length > 0 ? (
<div className="space-y-3">
{items.map((task, i) => (
<div key={i} onClick={() => setSelectedItem(task)} className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer group">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${getStatusColor(task.status)}`}><CheckSquare size={16} /></div>
<div>
<h4 className="font-semibold text-zinc-900 dark:text-white group-hover:text-blue-500 transition-colors">{task.name}</h4>
<p className="text-xs text-zinc-500 mt-0.5">{task.projectAddress}</p>
</div>
</div>
<div className="text-right flex items-center gap-2">
<div>
<p className="text-xs font-mono text-zinc-500">Due: {task.dueDate}</p>
<span className={`text-[10px] font-bold uppercase ${getStatusColor(task.status)} px-2 py-0.5 rounded-full`}>{task.status.replace('_', ' ')}</span>
</div>
<ChevronRight size={16} className="text-zinc-400" />
</div>
</div>
))}
</div>
) : <p className="text-center text-zinc-500 py-8">No tasks found.</p>;
}
if (type === 'clock_in') {
const items = clockIns || [];
return items.length > 0 ? (
<div className="space-y-3">
{items.map((entry, i) => (
<div key={i} className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10"><Clock size={16} /></div>
<div>
<h4 className="font-semibold text-zinc-900 dark:text-white">{entry.date}</h4>
<p className="text-xs text-zinc-500 mt-0.5">{entry.project}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-mono font-bold text-zinc-900 dark:text-white">{entry.clockIn} {entry.clockOut || 'Active'}</p>
<p className="text-xs text-zinc-500">{entry.hours} hrs</p>
</div>
</div>
))}
</div>
) : <p className="text-center text-zinc-500 py-8">No clock-in records.</p>;
}
if (type === 'payment_history') {
const items = invoices || [];
return items.length > 0 ? (
<div className="space-y-3">
{items.map((inv, i) => (
<div key={i} onClick={() => setSelectedItem(inv)} className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:border-emerald-500/30 transition-all cursor-pointer group">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${getStatusColor(inv.status)}`}><DollarSign size={16} /></div>
<div>
<h4 className="font-semibold text-zinc-900 dark:text-white group-hover:text-emerald-500 transition-colors">Invoice #{inv.id}</h4>
<p className="text-xs text-zinc-500 mt-0.5">Due: {inv.dueDate}</p>
</div>
</div>
<div className="text-right flex items-center gap-2">
<div>
<p className="text-sm font-mono font-bold text-zinc-900 dark:text-white">{formatCurrency(inv.amount)}</p>
<span className={`text-[10px] font-bold uppercase ${getStatusColor(inv.status)} px-2 py-0.5 rounded-full`}>{inv.status}</span>
</div>
<ChevronRight size={16} className="text-zinc-400" />
</div>
</div>
))}
</div>
) : <p className="text-center text-zinc-500 py-8">No payment records.</p>;
}
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center justify-center sm:p-6" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full sm:max-w-2xl h-[85dvh] sm:h-auto sm:max-h-[80vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-10 duration-200 border border-zinc-200 dark:border-white/10">
<div className="px-6 py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-center bg-zinc-50/50 dark:bg-white/5">
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">{title}</h2>
<button onClick={onClose} className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors"><X size={18} /></button>
</div>
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
{renderContent()}
</div>
</div>
</div>,
document.body
);
};
const SubContractorDashboard = () => {
const { user } = useAuth();
const { projects } = useMockStore();
// 1. Identify current subcontractor (e.g. 'sub_001')
const subId = user?.id || 'sub_001';
// 2. Aggregate Tasks (Milestones assigned to me)
// Aggregate Tasks
const myTasks = projects.flatMap(p =>
p.milestones.filter(m => m.assignedTo === subId)
.map(m => ({
...m,
projectAddress: p.address,
projectId: p.id,
projectStatus: p.status
}))
.map(m => ({ ...m, projectAddress: p.address, projectId: p.id, projectStatus: p.status }))
);
const activeTasks = myTasks.filter(t => t.status === 'in_progress').length;
const pendingTasks = myTasks.filter(t => t.status === 'pending').length;
const completedTasks = myTasks.filter(t => t.status === 'completed').length;
const inProgressTasks = myTasks.filter(t => t.status === 'in_progress');
const pendingTasks = myTasks.filter(t => t.status === 'pending');
const completedTasks = myTasks.filter(t => t.status === 'completed');
// 3. Financials (Invoices submitted by me)
// Financials
const myInvoices = projects.flatMap(p =>
p.invoices.filter(i => i.submittedBy === subId)
(p.invoices || []).filter(i => i.submittedBy === subId)
);
const pendingPay = myInvoices.filter(i => i.status === 'pending').reduce((sum, i) => sum + i.amount, 0);
const totalEarnings = myInvoices.filter(i => i.status === 'paid').reduce((sum, i) => sum + i.amount, 0);
// Modal state
// Mock clock-in data
const clockInData = useMemo(() => [
{ date: '2026-02-18', project: '2604 Dunwick Dr — Roof Replacement', clockIn: '7:00 AM', clockOut: null, hours: 'Active' },
{ date: '2026-02-17', project: '6613 Phoenix Pl — HVAC Upgrade', clockIn: '7:15 AM', clockOut: '4:30 PM', hours: '9.25' },
{ date: '2026-02-16', project: '2604 Dunwick Dr — Roof Replacement', clockIn: '6:45 AM', clockOut: '3:45 PM', hours: '9.0' },
{ date: '2026-02-15', project: '3913 Arizona Pl — Electrical Panel', clockIn: '7:00 AM', clockOut: '4:00 PM', hours: '9.0' },
{ date: '2026-02-14', project: '2612 Dunwick Dr — Interior Renovation', clockIn: '7:30 AM', clockOut: '5:00 PM', hours: '9.5' },
], []);
// Modal states
const [detailModal, setDetailModal] = useState({ isOpen: false, type: null, title: '' });
const [selectedTask, setSelectedTask] = useState(null);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isFinancialModalOpen, setIsFinancialModalOpen] = useState(false);
// Financial data for modal
const financialData = {
total: totalEarnings + pendingPay,
paid: totalEarnings,
@@ -64,7 +252,10 @@ const SubContractorDashboard = () => {
const handleTaskUpdate = (taskId, actionType) => {
console.log(`Task ${taskId} updated with action: ${actionType}`);
// In a real app, this would update the backend
};
const openDetailModal = (type, title) => {
setDetailModal({ isOpen: true, type, title });
};
return (
@@ -73,17 +264,20 @@ const SubContractorDashboard = () => {
<div>
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Field Task Center</h1>
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
You have <span className="text-blue-500 font-bold">{activeTasks} active tasks</span> and <span className="text-emerald-500 font-bold">${pendingPay.toLocaleString()}</span> in pending payouts.
You have <span className="text-blue-500 font-bold">{inProgressTasks.length} active tasks</span> and <span className="text-emerald-500 font-bold">${pendingPay.toLocaleString()}</span> in pending payouts.
</p>
</div>
{/* Quick Actions (Mobile First) */}
{/* Quick Actions */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<button className="flex flex-col items-center justify-center p-4 rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-500/30 hover:bg-blue-500 transition-all active:scale-95">
<Camera size={24} className="mb-2" />
<span className="text-sm font-bold">Upload Photo</span>
</button>
<button className="flex flex-col items-center justify-center p-4 rounded-xl bg-zinc-100 dark:bg-white/10 text-zinc-900 dark:text-white hover:bg-zinc-200 dark:hover:bg-white/20 transition-all active:scale-95">
<button
onClick={() => openDetailModal('clock_in', 'Clock-In History')}
className="flex flex-col items-center justify-center p-4 rounded-xl bg-zinc-100 dark:bg-white/10 text-zinc-900 dark:text-white hover:bg-zinc-200 dark:hover:bg-white/20 transition-all active:scale-95"
>
<Clock size={24} className="mb-2" />
<span className="text-sm font-bold">Clock In</span>
</button>
@@ -99,32 +293,18 @@ const SubContractorDashboard = () => {
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
label="Tasks In Progress"
value={activeTasks}
icon={Clock}
color="blue"
/>
<StatCard
label="Pending Tasks"
value={pendingTasks}
icon={CheckSquare}
color="amber"
/>
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
<StatCard
label="Pending Payouts"
value={`$${pendingPay.toLocaleString()}`}
icon={Wallet}
color="emerald"
/>
<div onClick={() => openDetailModal('tasks_in_progress', 'Tasks In Progress')} className="cursor-pointer">
<StatCard label="Tasks In Progress" value={inProgressTasks.length} icon={Clock} color="blue" />
</div>
<div onClick={() => openDetailModal('pending_tasks', 'Pending Tasks')} className="cursor-pointer">
<StatCard label="Pending Tasks" value={pendingTasks.length} icon={CheckSquare} color="amber" />
</div>
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
<StatCard label="Pending Payouts" value={`$${pendingPay.toLocaleString()}`} icon={Wallet} color="emerald" />
</div>
<div onClick={() => openDetailModal('jobs_completed', 'Jobs Completed')} className="cursor-pointer">
<StatCard label="Jobs Completed" value={completedTasks.length} icon={CheckSquare} color="purple" />
</div>
<StatCard
label="Jobs Completed"
value={completedTasks}
icon={CheckSquare}
color="purple"
/>
</div>
{/* Main Task List */}
@@ -134,13 +314,10 @@ const SubContractorDashboard = () => {
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">My Assignments</h2>
</div>
<div className="space-y-4">
{myTasks.length > 0 ? myTasks.map((task, idx) => (
<div key={idx} className="group p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer" onClick={() => handleTaskClick(task)}>
<div className="flex flex-col md:flex-row justify-between md:items-center gap-4">
{/* Left: Task Info */}
<div className="flex items-start gap-4">
<div className={`p-3 rounded-lg ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400' :
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400' :
@@ -156,26 +333,18 @@ const SubContractorDashboard = () => {
</div>
</div>
</div>
{/* Right: Status & Action */}
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xs font-mono font-medium text-zinc-500 mb-1">Due: {task.dueDate}</p>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600' :
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600' :
'bg-amber-100 text-amber-600'
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600' : 'bg-amber-100 text-amber-600'
}`}>
{task.status.replace('_', ' ')}
</span>
</div>
{task.status !== 'completed' && (
<button
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition-colors"
onClick={(e) => {
e.stopPropagation();
handleTaskClick(task);
}}
>
<button className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition-colors"
onClick={(e) => { e.stopPropagation(); handleTaskClick(task); }}>
Update
</button>
)}
@@ -183,15 +352,13 @@ const SubContractorDashboard = () => {
</div>
</div>
)) : (
<div className="text-center py-10 text-zinc-500">
<p>No tasks assigned.</p>
</div>
<div className="text-center py-10 text-zinc-500"><p>No tasks assigned.</p></div>
)}
</div>
</SpotlightCard>
</div>
{/* Mobile Earnings Widget */}
{/* Earnings Widget */}
<div>
<SpotlightCard className="p-6">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Earnings Tracker</h2>
@@ -200,7 +367,6 @@ const SubContractorDashboard = () => {
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-1">Total Earned (YTD)</p>
<h3 className="text-4xl font-mono font-bold text-emerald-500">${totalEarnings.toLocaleString()}</h3>
</div>
<div className="mt-8 space-y-4">
<div className="flex justify-between text-sm">
<span className="text-zinc-500">Paid Invoices</span>
@@ -211,8 +377,10 @@ const SubContractorDashboard = () => {
<span className="font-bold text-amber-500">{myInvoices.filter(i => i.status === 'pending').length}</span>
</div>
</div>
<button className="w-full mt-8 py-3 rounded-xl bg-zinc-100 dark:bg-white/10 hover:bg-zinc-200 dark:hover:bg-white/20 text-zinc-900 dark:text-white font-bold text-sm transition-colors">
<button
onClick={() => openDetailModal('payment_history', 'Payment History')}
className="w-full mt-8 py-3 rounded-xl bg-zinc-100 dark:bg-white/10 hover:bg-zinc-200 dark:hover:bg-white/20 text-zinc-900 dark:text-white font-bold text-sm transition-colors"
>
View Payment History
</button>
</div>
@@ -221,18 +389,23 @@ const SubContractorDashboard = () => {
</div>
{/* Modals */}
<TaskDetailsModal
isOpen={isTaskModalOpen}
onClose={() => setIsTaskModalOpen(false)}
task={selectedTask}
onUpdate={handleTaskUpdate}
/>
<FinancialSummaryModal
isOpen={isFinancialModalOpen}
onClose={() => setIsFinancialModalOpen(false)}
role="SUBCONTRACTOR"
data={financialData}
<TaskDetailsModal isOpen={isTaskModalOpen} onClose={() => setIsTaskModalOpen(false)} task={selectedTask} onUpdate={handleTaskUpdate} />
<FinancialSummaryModal isOpen={isFinancialModalOpen} onClose={() => setIsFinancialModalOpen(false)} role="SUBCONTRACTOR" data={financialData} />
<SubDetailModal
isOpen={detailModal.isOpen}
onClose={() => setDetailModal({ isOpen: false, type: null, title: '' })}
title={detailModal.title}
type={detailModal.type}
tasks={
detailModal.type === 'tasks_in_progress' ? inProgressTasks :
detailModal.type === 'pending_tasks' ? pendingTasks :
detailModal.type === 'jobs_completed' ? completedTasks : []
}
invoices={myInvoices}
clockIns={clockInData}
onTaskClick={handleTaskClick}
/>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+96 -73
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { useMockStore } from '../../data/mockStore';
@@ -23,40 +23,48 @@ const VendorDashboard = () => {
const [isPerformanceModalOpen, setIsPerformanceModalOpen] = useState(false);
// 1. Identify current vendor
// For mock purposes, if logged in as 'abc_supply', we map to 'v3'
// In real app, user.vendorId would track this.
const vendorId = user.id === 'ven_001' ? 'v3' : 'v1';
const vendorData = vendors.find(v => v.id === vendorId) || vendors[0];
// 2. Aggregate Data
// Find projects where this vendor is assigned to milestones or has invoices
// A. Milestones
// 2. Aggregate Data from ACTUAL vendor invoices (not vendorData.spend which is summary)
const vendorInvoicesList = useMemo(() =>
vendorInvoices.filter(inv => inv.vendorId === vendorId)
, [vendorInvoices, vendorId]);
const pendingInvoiceAmount = useMemo(() =>
vendorInvoicesList.filter(inv => inv.status === 'pending').reduce((sum, inv) => sum + inv.amount, 0)
, [vendorInvoicesList]);
const pendingInvoiceCount = useMemo(() =>
vendorInvoicesList.filter(inv => inv.status === 'pending').length
, [vendorInvoicesList]);
const paidInvoiceAmount = useMemo(() =>
vendorInvoicesList.filter(inv => inv.status === 'paid').reduce((sum, inv) => sum + inv.amount, 0)
, [vendorInvoicesList]);
const totalEarningsYTD = paidInvoiceAmount; // Only count actually paid invoices
// Orders
const vendorOrders = useMemo(() => orders.filter(o => o.vendorId === vendorId), [orders, vendorId]);
const activeOrderCount = vendorOrders.filter(o => o.status !== 'delivered').length;
// Milestones
const activeDeliveries = projects.flatMap(p => p.milestones)
.filter(m => m.assignedTo === vendorId && m.status === 'pending').length;
const completedDeliveries = projects.flatMap(p => p.milestones)
.filter(m => m.assignedTo === vendorId && m.status === 'completed').length;
// B. Financials
const pendingInvoices = vendorData.spend?.pendingInvoices || 0;
const totalEarnings = vendorData.spend?.totalSpend || 0;
// C. Compliance
// Compliance
const complianceStatus = vendorData.compliance?.coi?.status === 'compliant' ? 'Verified' : 'Action Required';
// D. Financial Data for Modal
const vendorOrders = orders.filter(o => o.vendorId === vendorId);
const vendorInvoicesList = vendorInvoices.filter(inv => inv.vendorId === vendorId);
const paidInvoices = vendorInvoicesList.filter(inv => inv.status === 'paid').reduce((sum, inv) => sum + inv.amount, 0);
const pendingPayments = vendorInvoicesList.filter(inv => inv.status === 'pending').reduce((sum, inv) => sum + inv.amount, 0);
// Financial Data for Modal — DISTINCT: Pending vs Paid
const financialData = {
totalEarnings: vendorData.spend?.ytdSpend || 0,
paidInvoices,
pendingPayments,
totalEarnings: totalEarningsYTD,
paidInvoices: paidInvoiceAmount,
pendingPayments: pendingInvoiceAmount,
invoices: vendorInvoicesList
};
// E. Click Handlers
// Click Handlers
const handleOrderClick = (order) => {
setSelectedOrder(order);
setIsOrderModalOpen(true);
@@ -92,23 +100,24 @@ const VendorDashboard = () => {
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
<StatCard
label="Pending Invoices"
value={`$${pendingInvoices.toLocaleString()}`}
value={`$${pendingInvoiceAmount.toLocaleString()}`}
icon={DollarSign}
color="amber"
trendLabel={`${pendingInvoiceCount} pending`}
/>
</div>
<div onClick={() => navigate('/vendor/orders')} className="cursor-pointer">
<StatCard
label="Active Orders"
value={activeOrderCount}
icon={ClipboardCheck}
color="blue"
/>
</div>
<StatCard
label="Active Orders"
value={activeDeliveries}
icon={ClipboardCheck}
color="blue"
onClick={() => navigate('/vendor/orders')}
className="cursor-pointer hover:border-blue-500/50 transition-colors"
/>
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
<StatCard
label="Total Earnings (YTD)"
value={`$${vendorData.spend?.ytdSpend?.toLocaleString() || '0'}`}
value={`$${totalEarningsYTD.toLocaleString()}`}
icon={TrendingUp}
color="emerald"
trend={12}
@@ -125,13 +134,43 @@ const VendorDashboard = () => {
</div>
</div>
{/* Earnings Summary Card */}
<SpotlightCard className="p-4 sm:p-6">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Earnings Summary</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4">
<div className="p-3 sm:p-4 rounded-xl bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/10">
<div className="text-[10px] uppercase font-bold tracking-wider text-emerald-600 dark:text-emerald-400 mb-1">Paid (YTD)</div>
<div className="text-lg sm:text-2xl font-bold text-emerald-700 dark:text-emerald-300">${paidInvoiceAmount.toLocaleString()}</div>
<div className="text-xs text-emerald-600/70 mt-1">{vendorInvoicesList.filter(i => i.status === 'paid').length} invoices</div>
</div>
<div className="p-3 sm:p-4 rounded-xl bg-amber-50 dark:bg-amber-500/5 border border-amber-200 dark:border-amber-500/10">
<div className="text-[10px] uppercase font-bold tracking-wider text-amber-600 dark:text-amber-400 mb-1">Pending</div>
<div className="text-lg sm:text-2xl font-bold text-amber-700 dark:text-amber-300">${pendingInvoiceAmount.toLocaleString()}</div>
<div className="text-xs text-amber-600/70 mt-1">{pendingInvoiceCount} invoices</div>
</div>
<div className="p-3 sm:p-4 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-200 dark:border-blue-500/10">
<div className="text-[10px] uppercase font-bold tracking-wider text-blue-600 dark:text-blue-400 mb-1">Active Orders</div>
<div className="text-lg sm:text-2xl font-bold text-blue-700 dark:text-blue-300">{activeOrderCount}</div>
<div className="text-xs text-blue-600/70 mt-1">{vendorOrders.filter(o => o.status === 'shipped').length} shipped, {vendorOrders.filter(o => o.status === 'pending').length} pending</div>
</div>
<div className="p-3 sm:p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/10">
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">Total Volume</div>
<div className="text-lg sm:text-2xl font-bold text-zinc-900 dark:text-white">${(paidInvoiceAmount + pendingInvoiceAmount).toLocaleString()}</div>
<div className="text-xs text-zinc-500 mt-1">{vendorInvoicesList.length} total invoices</div>
</div>
</div>
</SpotlightCard>
{/* Main Content Area */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Active Projects / Deliveries */}
{/* Active Orders */}
<div className="lg:col-span-2 space-y-6">
<SpotlightCard className="p-6 h-full">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Active Orders & Deliveries</h2>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Active Orders & Deliveries</h2>
<button onClick={() => navigate('/vendor/orders')} className="text-sm text-blue-500 hover:text-blue-400 font-medium">View All</button>
</div>
<div className="space-y-3">
{vendorOrders.map(order => (
<div
@@ -139,22 +178,28 @@ const VendorDashboard = () => {
onClick={() => handleOrderClick(order)}
className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-colors cursor-pointer group">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400">
<div className={`p-3 rounded-lg ${
order.status === 'delivered' ? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400' :
order.status === 'shipped' ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400' :
order.status === 'confirmed' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400' :
'bg-amber-100 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400'
}`}>
<FileText size={20} />
</div>
<div>
<h4 className="font-semibold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{order.id}</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
{order.projectAddress}
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{order.projectAddress}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-mono font-medium text-zinc-700 dark:text-zinc-300">
Due: {order.dueDate}
${order.total.toLocaleString()}
</p>
<span className={`text-xs font-bold uppercase ${order.status === 'delivered' ? 'text-emerald-500' :
order.status === 'shipped' ? 'text-blue-500' : 'text-amber-500'
<span className={`text-xs font-bold uppercase ${
order.status === 'delivered' ? 'text-emerald-500' :
order.status === 'shipped' ? 'text-blue-500' :
order.status === 'confirmed' ? 'text-purple-500' :
'text-amber-500'
}`}>
{order.status}
</span>
@@ -176,7 +221,6 @@ const VendorDashboard = () => {
<SpotlightCard className="p-6">
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Action Center</h2>
<div className="space-y-4">
{/* Invoices */}
<div className="p-4 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-100 dark:border-amber-500/20">
<div className="flex items-start gap-3">
<AlertCircle size={18} className="text-amber-600 dark:text-amber-400 mt-0.5" />
@@ -185,14 +229,10 @@ const VendorDashboard = () => {
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
Invoice #INV-2024-001 was flagged for missing PO number.
</p>
<button className="mt-2 text-xs font-bold text-amber-600 dark:text-amber-400 hover:underline">
View Details
</button>
<button className="mt-2 text-xs font-bold text-amber-600 dark:text-amber-400 hover:underline">View Details</button>
</div>
</div>
</div>
{/* Compliance */}
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20">
<div className="flex items-start gap-3">
<Calendar size={18} className="text-blue-600 dark:text-blue-400 mt-0.5" />
@@ -201,9 +241,7 @@ const VendorDashboard = () => {
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
Your General Liability policy expires in 45 days.
</p>
<button className="mt-2 text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline">
Upload Renewal
</button>
<button className="mt-2 text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline">Upload Renewal</button>
</div>
</div>
</div>
@@ -215,7 +253,7 @@ const VendorDashboard = () => {
<ul className="space-y-3 text-sm">
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500">On-Time Delivery Rate</span>
<span className="font-bold text-emerald-500">{vendorData.performance?.onTimeRate * 100}%</span>
<span className="font-bold text-emerald-500">{(vendorData.performance?.onTimeRate || 0) * 100}%</span>
</li>
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500">Average Payment Window</span>
@@ -223,7 +261,7 @@ const VendorDashboard = () => {
</li>
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
<span className="text-zinc-500">Active Contracts</span>
<span className="font-bold text-zinc-900 dark:text-white">3</span>
<span className="font-bold text-zinc-900 dark:text-white">{vendorOrders.length}</span>
</li>
</ul>
</SpotlightCard>
@@ -231,26 +269,11 @@ const VendorDashboard = () => {
</div>
{/* Modals */}
<OrderDetailsModal
isOpen={isOrderModalOpen}
onClose={() => setIsOrderModalOpen(false)}
order={selectedOrder}
/>
<VendorFinancialSummaryModal
isOpen={isFinancialModalOpen}
onClose={() => setIsFinancialModalOpen(false)}
data={financialData}
/>
<ComplianceDetailsModal
isOpen={isComplianceModalOpen}
onClose={() => setIsComplianceModalOpen(false)}
vendorData={vendorData}
/>
<PerformanceMetricsModal
isOpen={isPerformanceModalOpen}
onClose={() => setIsPerformanceModalOpen(false)}
vendorData={vendorData}
/>
<OrderDetailsModal isOpen={isOrderModalOpen} onClose={() => setIsOrderModalOpen(false)} order={selectedOrder} />
<VendorFinancialSummaryModal isOpen={isFinancialModalOpen} onClose={() => setIsFinancialModalOpen(false)} data={financialData} />
<ComplianceDetailsModal isOpen={isComplianceModalOpen} onClose={() => setIsComplianceModalOpen(false)} vendorData={vendorData} />
<PerformanceMetricsModal isOpen={isPerformanceModalOpen} onClose={() => setIsPerformanceModalOpen(false)} vendorData={vendorData} />
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};
+45 -2
View File
@@ -71,7 +71,49 @@ const VendorOrders = () => {
</div>
<SpotlightCard className="overflow-hidden">
<div className="overflow-x-auto">
{/* Mobile Card View */}
<div className="md:hidden divide-y divide-zinc-100 dark:divide-white/5">
{filteredOrders.length > 0 ? filteredOrders.map(order => (
<div
key={order.id}
onClick={() => handleOrderClick(order)}
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-medium text-sm text-zinc-900 dark:text-white truncate">
{order.projectAddress || order.project}
</h4>
<p className="text-xs text-zinc-500 font-mono mt-0.5">{order.id}</p>
</div>
<ChevronRight size={16} className="text-zinc-400 shrink-0 mt-1" />
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${getStatusColor(order.status)}`}>
{order.status === 'delivered' && <CheckCircle size={10} />}
{order.status === 'shipped' && <Truck size={10} />}
{(order.status === 'pending' || order.status === 'confirmed') && <Clock size={10} />}
{order.status}
</span>
<span className="text-xs text-zinc-500 flex items-center gap-1">
<Package size={12} /> {order.items ? `${order.items.length} items` : order.item}
</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-zinc-500">Due: {order.dueDate || 'N/A'}</span>
<span className="font-mono font-semibold text-zinc-900 dark:text-white">${(order.total || order.amount).toLocaleString()}</span>
</div>
</div>
)) : (
<div className="p-12 text-center text-zinc-500">
<Package size={40} className="mx-auto mb-3 opacity-20" />
<p>No orders found matching this filter.</p>
</div>
)}
</div>
{/* Desktop Table View */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-zinc-200 dark:border-white/10 text-xs font-bold uppercase tracking-wider text-zinc-500 bg-zinc-50/50 dark:bg-white/5">
@@ -128,7 +170,7 @@ const VendorOrders = () => {
</table>
</div>
{filteredOrders.length === 0 && (
<div className="p-12 text-center text-zinc-500">
<div className="hidden md:block p-12 text-center text-zinc-500">
<Package size={48} className="mx-auto mb-4 opacity-20" />
<p>No orders found matching this filter.</p>
</div>
@@ -141,6 +183,7 @@ const VendorOrders = () => {
onClose={() => setIsOrderModalOpen(false)}
order={selectedOrder}
/>
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
</div>
);
};