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