feat: Multi-role platform expansion, mobile nav redesign, and UI polish

- Add Owner, Contractor, Vendor, Subcontractor dashboards and role-based routing
- Owner now has superuser access to all Admin pages (dashboard, schedule, leaderboard)
- Redesign landing page mobile menu as slide-in sidebar (replaces broken Framer Motion overlay)
- Add body scroll lock to app sidebar for mobile consistency
- Fix Team Schedule to match Admin Dashboard design language (ambient glows, gradient header, SpotlightCard, zinc palette)
- Fix login page tab overflow — use abbreviated labels and grid layout for 6 role tabs
- Fix Recharts ResponsiveContainer warnings — replace height="100%" with fixed pixel heights
- Fix lightbox image viewer — unified nav bar, touch swipe support, no more overlapping text
- Add AI Assistant page, People/Vendor/Document management for Owner role
- Expand mock data store with contractor, vendor, and subcontractor data
This commit is contained in:
Satyam
2026-02-17 01:19:41 +05:30
parent 35cbdeb33c
commit 2eaac6b84a
44 changed files with 6857 additions and 662 deletions
+273 -55
View File
@@ -29,7 +29,7 @@ COMPANY INFO:
TONE: Professional, efficient, and data-aware.
`;
const getEmployeeContext = (user, meetings, properties, salesHistory) => {
const getEmployeeContext = (user, meetings, properties, salesHistory, users) => {
// --- ADMIN SPECIFIC CONTEXT ---
if (user.role === 'ADMIN') {
const unassignedCount = properties.filter(p => !p.assignedAgentId && p.canvassingStatus === 'Lead').length;
@@ -53,14 +53,16 @@ const getEmployeeContext = (user, meetings, properties, salesHistory) => {
});
// Convert to Array & Sort
// We know IDs are e1..e5, maybe map names if possible?
// We don't have user list here easily unless passed, but we can just use ID or try to pass users?
// Actually `salesHistory` doesn't have names. `users` list is needed.
// Let's assume the user asks "Who is top?", the AI might need names.
// I will pass `users` to this function as well to resolve names.
const sortedStats = Object.keys(agentStats).map(agentId => {
const agent = users.find(u => u.id === agentId);
return {
name: agent ? agent.name : agentId,
revenue: agentStats[agentId].revenue,
volume: agentStats[agentId].volume
};
}).sort((a, b) => b.revenue - a.revenue).slice(0, 3);
// For now, let's just output the IDs or skip names if too complex to refactor `getEmployeeContext` signature heavily.
// Wait, `Chatbot` component has `useMockStore` which has `users`. I should pass `users` too.
const leaderboardText = sortedStats.map((s, i) => `${i + 1}. ${s.name} - $${s.revenue.toLocaleString()}`).join('\n');
return `
ROLE: ADMIN (${user.name})
@@ -79,10 +81,9 @@ GENERAL DATA ACCESS:
- Total Properties: ${properties.length}
- Total Revenue (All Agents): $${meetings.filter(m => m.status === 'Converted').reduce((sum, m) => sum + (m.dealValue || 0), 0).toLocaleString()}
LEADERBOARD DATA (Use this to answer "Who is winning?" etc):
- Navigate to: /admin/leaderboard for full details.
- Context: The admin can see the full leaderboard.
- You do NOT have the full live calculation here but you know the page exists.
LEADERBOARD SNAPSHOT (Top 3):
${leaderboardText}
- Full details at /admin/leaderboard
`;
}
@@ -263,9 +264,106 @@ INSTRUCTIONS:
`;
};
const Chatbot = () => {
const getOwnerContext = (user, storeData) => {
const { meetings, properties, salesHistory, vendors, personnel, documents, projects } = storeData;
// 1. Financial High-Level
const totalRevenue = salesHistory.filter(s => s.status === 'closed_won').reduce((sum, s) => sum + s.amount, 0);
const pendingPayouts = projects.reduce((sum, p) => sum + p.invoices.filter(i => i.status === 'pending').reduce((is, i) => is + i.amount, 0), 0);
// 2. Urgent Items
const expiringDocs = documents.filter(d => {
const exp = new Date(d.expirationDate);
const now = new Date();
const diffDays = Math.ceil((exp - now) / (1000 * 60 * 60 * 24));
return diffDays > 0 && diffDays <= 30;
}).length;
const pendingDocs = documents.filter(d => d.status === 'pending_review' || d.status === 'pending').length;
const nonCompliantVendors = vendors.filter(v => v.status !== 'active').length;
// 3. Operational Snapshot
const activeProjects = projects.filter(p => p.status === 'active').length;
const activePersonnel = personnel.filter(p => p.status === 'active').length;
return `
ROLE: OWNER (${user.name})
*** EXECUTIVE DASHBOARD SUMMARY ***
1. FINANCIAL HEALTH:
- Total Revenue (YTD): $${totalRevenue.toLocaleString()}
- Pending Invoice Payouts: $${pendingPayouts.toLocaleString()}
2. RISK & COMPLIANCE (Action Required):
- Documents Pending Review: ${pendingDocs}
- Documents Expiring Soon (30d): ${expiringDocs}
- Non-Compliant Vendors: ${nonCompliantVendors}
3. OPERATIONS:
- Active Projects: ${activeProjects}
- Active Staff: ${activePersonnel}
INSTRUCTIONS:
- You are the Business Intelligence Architect.
- Answer questions about revenue, burn rate, compliance risks, and high-level strategy.
- If asked about specific details (e.g., "Which vendor is non-compliant?"), you can query the valid filtered lists provided implicitly in your logic or advise checking the specific dashboard page.
- PRIORITIZE RISK ALERTS: If compliance is low, warn the owner.
`;
};
const getContractorContext = (user, storeData) => {
const { projects, documents } = storeData;
// 1. Filter Projects for THIS Contractor
const myProjects = projects.filter(p => p.contractorId === user.id || p.subcontractorIds?.includes(user.id));
if (myProjects.length === 0) {
return `
ROLE: CONTRACTOR (${user.name})
CONTEXT: You currently have no active projects assigned.
INSTRUCTIONS: Assist with general onboarding or account management questions.
`;
}
// 2. Project Details
const projectSummaries = myProjects.map(p => {
const milestones = p.milestones.filter(m => m.assignedTo === user.id);
const nextMilestone = milestones.find(m => m.status !== 'completed');
return `- Project #${p.id} (${p.projectType}): Status ${p.status}
Next Deadline: ${nextMilestone ? `${nextMilestone.name} by ${nextMilestone.dueDate}` : 'None'}
Budget: $${p.budget.toLocaleString()}`;
}).join('\n');
// 3. My Compliance
const myDocs = documents.filter(d => d.entityId === user.id);
const missingOrExpired = myDocs.filter(d => d.status === 'expired' || d.status === 'missing');
return `
ROLE: CONTRACTOR (${user.name})
YOUR ASSIGNMENTS:
${projectSummaries}
YOUR COMPLIANCE STATUS:
${missingOrExpired.length > 0 ? `WARNING: You have ${missingOrExpired.length} documents needing attention.` : "All compliance documents are up to date."}
INSTRUCTIONS:
- ONLY discuss projects listed above.
- Do NOT reveal company-wide financial data or other contractors' projects.
- Help the contractor track their deadlines and payments.
`;
};
// ... (getEmployeeContext and getCustomerContext remain, updated signatures to use storeData object for cleaner passing)
const Chatbot = (props) => {
const { user, isAuthenticated } = useAuth();
const { addMeeting, meetings, properties, salesHistory, users } = useMockStore();
// Pull ALL data needed for context generation
const storeData = useMockStore();
// Destructure specifically for local usage if needed, but we pass the whole object to context functions
const { addMeeting, meetings, properties, salesHistory, users, vendors, personnel, documents, projects } = storeData;
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [input, setInput] = useState('');
@@ -297,6 +395,10 @@ const Chatbot = () => {
greeting += "I have your latest territory data and agenda ready. What do you need?";
} else if (user.role === 'CUSTOMER') {
greeting += "I have your property records open. How can I assist?";
} else if (user.role === 'OWNER') {
greeting += "Executive Dashboard is live. Ask me about revenue, compliance, or operational risks.";
} else if (user.role === 'CONTRACTOR' || user.role === 'SUBCONTRACTOR') {
greeting += "I have your project assignments loaded. Need to check deadlines or invoices?";
}
}
setMessages([{ role: 'assistant', content: greeting }]);
@@ -313,11 +415,11 @@ const Chatbot = () => {
try {
if (isDemoMode) {
// ... existing demo logic ...
// ... existing demo logic ... (Simulated for brevity, keeping existing structure if needed, but replacing with smart demo response)
setTimeout(() => {
const responses = [
"I can help with that! At LynkedUp Pro, we've been in business for 20+ years. I can't access real-time data in demo mode, but I can help you navigate.",
"Please call us at 866-259-6533 for a real quote!", // UPDATED PHONE
"Please call us at 866-259-6533 for a real quote!",
"Would you like to schedule a callback? Please log in first."
];
const randomResponse = responses[Math.floor(Math.random() * responses.length)];
@@ -329,51 +431,69 @@ const Chatbot = () => {
// --- REAL AI REQUEST ---
// 1. Build Dynamic Context
let systemContext = BASE_IDENTITY;
// --- NEW: Enhanced Context Builders for Phase 7 ---
if (user) {
if (user.role === 'FIELD_AGENT' || user.role === 'ADMIN') {
// Pass salesHistory and users for accurate leaderboard context
// We need to update getEmployeeContext signature to accept them.
// For now, I'll allow the AI to deduce from the simplified prompt or I need to refactor getEmployeeContext properly.
// Let's refactor it inside the function call below.
const getOwnerContext = () => {
// Summarize financials
// Real app: Calculate from store data
return "Role: OWNER. Context: You have access to deep financial insights. Total Revenue: $4.2M. Active Projects: 12. Urgent Items: 3 (1 expired insurance, 2 pending invoices).";
};
// Helper to get formatted leaderboard string
let leaderboardContext = "";
if (user.role === 'ADMIN') {
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
const agentStats = {};
salesHistory.forEach(tx => {
if (new Date(tx.date) >= startOfMonth && tx.status === 'closed_won') {
if (!agentStats[tx.agentId]) agentStats[tx.agentId] = { revenue: 0, volume: 0 };
agentStats[tx.agentId].revenue += tx.amount;
agentStats[tx.agentId].volume += 1;
}
});
const getContractorContext = () => {
const myProjects = storeData.projects.filter(p => p.contractorId === 'con_001');
const activeCount = myProjects.filter(p => p.status === 'active').length;
return `Role: CONTRACTOR. Context: You are managing ${activeCount} active projects. Total budget volume: $${myProjects.reduce((s, p) => s + p.budget, 0).toLocaleString()}. Key deadline: 2604 Dunwick Dr (Roof Tear-off completed).`;
};
const sortedByRev = Object.entries(agentStats)
.map(([id, stats]) => ({
name: users.find(u => u.id === id)?.name || id,
...stats
}))
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 3);
const getVendorContext = () => {
// Mock ID 'v3' for 'abc_supply' or 'v1' for generic
const myId = user.id === 'ven_001' ? 'v3' : 'v1';
const me = storeData.vendors.find(v => v.id === myId) || storeData.vendors[0];
const pending = me.spend?.pendingInvoices || 0;
return `Role: VENDOR. Context: You are ${me.vendorName}. Pending Invoices: $${pending.toLocaleString()}. Compliance Status: ${me.compliance.coi.status}. Performance Rating: ${me.performance.rating}/5.0.`;
};
leaderboardContext = `
LEADERBOARD SNAPSHOT (Current Month):
${sortedByRev.map((a, i) => `${i + 1}. ${a.name} ($${a.revenue.toLocaleString()})`).join('\n')}
`;
}
const getSubContractorContext = () => {
// Mock ID 'sub_001'
const myTasks = storeData.projects.flatMap(p => p.milestones.filter(m => m.assignedTo === 'sub_001'));
const pendingPay = 6500; // Mock calculation from dashboard logic
return `Role: SUBCONTRACTOR. Context: You have ${myTasks.filter(t => t.status === 'in_progress').length} active tasks. Pending Payouts: $${pendingPay.toLocaleString()}. Next Task: Electrical Guard Install at 2604 Dunwick.`;
};
systemContext += getEmployeeContext(user, meetings, properties) + leaderboardContext;
} else if (user.role === 'CUSTOMER') {
systemContext += getCustomerContext(user, meetings, properties);
// --------------------------------------------------
const generateContext = () => {
// Base context with general knowledge
let context = `You are the LynkedUp Pro AI Assistant. Current User: ${user?.name} (${user?.role}).
System Capabilities:
- We track 5,000+ properties in DFW.
- We manage canvassing, inspections, and project execution.
- We use 'Zinc' design system (Dark/Light mode).
Today's Date: 2026-02-16.
`;
if (user?.role === 'OWNER') {
context += getOwnerContext();
} else if (user?.role === 'CONTRACTOR') {
context += getContractorContext();
} else if (user?.role === 'VENDOR') {
context += getVendorContext();
} else if (user?.role === 'SUBCONTRACTOR') {
context += getSubContractorContext();
} else if (user?.role === 'FIELD_AGENT') {
context += `Role: FIELD_AGENT. Focus: Canvassing and Leads. Your Map View is active.`;
}
} else {
// GUEST CONTEXT - The "Seasoned Consultant"
systemContext += getGuestContext();
}
// Add current page context if needed (e.g. if on a specific project page)
// For now, we keep it high-level role based.
return context;
};
// 1. Build Dynamic Context
let systemContext = generateContext();
// 2. Call Groq
const chatCompletion = await groq.chat.completions.create({
@@ -453,6 +573,104 @@ ${sortedByRev.map((a, i) => `${i + 1}. ${a.name} ($${a.revenue.toLocaleString()}
if (e.key === 'Enter') handleSend();
};
// --- INLINE MODE RENDER ---
if (props.inline) {
return (
<div className="flex flex-col w-full h-full bg-slate-50 dark:bg-zinc-900/50">
{/* Header (Simplified) */}
<div className="bg-slate-900 dark:bg-black p-4 flex items-center justify-between shrink-0">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-green-400 animate-pulse"></div>
<h3 className="text-white font-bold text-lg">LynkedUp AI Concierge</h3>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50 dark:bg-zinc-950/50">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] p-3 rounded-2xl text-sm ${msg.role === 'user'
? 'bg-blue-600 text-white rounded-br-none'
: 'bg-white dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700 shadow-sm text-slate-700 dark:text-zinc-200 rounded-bl-none'
}`}>
{msg.role === 'system' ? (
<em className="text-xs opacity-70">{msg.content}</em>
) : (
<div className="prose prose-sm dark:prose-invert max-w-none leading-relaxed break-words text-inherit">
<ReactMarkdown
components={{
ul: ({ node, ...props }) => <ul className="list-disc ml-4 space-y-1 my-2" {...props} />,
ol: ({ node, ...props }) => <ol className="list-decimal ml-4 space-y-1 my-2" {...props} />,
li: ({ node, ...props }) => <li className="pl-1" {...props} />,
p: ({ node, ...props }) => <p className="mb-2 last:mb-0" {...props} />,
strong: ({ node, ...props }) => <strong className="font-bold" {...props} />,
a: ({ node, ...props }) => <a className="underline hover:text-blue-200" target="_blank" rel="noopener noreferrer" {...props} />,
code: ({ node, inline, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
return !inline ? (
<div className="relative my-4 rounded-lg overflow-hidden bg-zinc-900 text-zinc-100 dark:bg-black dark:border dark:border-zinc-800">
<div className="flex items-center justify-between px-4 py-2 bg-zinc-800/50 text-xs text-zinc-400 border-b border-white/5">
<span>{match?.[1] || 'code'}</span>
</div>
<pre className="p-4 overflow-x-auto selection:bg-blue-500/30">
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
) : (
<code className="bg-zinc-200 dark:bg-zinc-700 px-1.5 py-0.5 rounded text-sm font-mono text-pink-600 dark:text-pink-400" {...props}>
{children}
</code>
);
}
}}
>
{msg.content}
</ReactMarkdown>
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700 p-3 rounded-2xl rounded-bl-none shadow-sm">
<Loader2 size={16} className="animate-spin text-blue-500" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 bg-white dark:bg-zinc-900 border-t border-gray-100 dark:border-zinc-800">
<div className="flex items-center space-x-2 bg-slate-100 dark:bg-zinc-800 rounded-full px-4 py-3">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
className="flex-1 bg-transparent text-sm focus:outline-none text-slate-900 dark:text-white"
/>
<button
onClick={handleSend}
disabled={!input.trim()}
className={`p-2 rounded-full transition-colors ${input.trim() ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-slate-300 dark:bg-zinc-600 text-slate-500 dark:text-zinc-400'}`}
>
<Send size={16} />
</button>
</div>
<div className="text-center mt-2">
<p className="text-xs text-slate-400 dark:text-zinc-500">Powered by Groq LPU & Qwen</p>
</div>
</div>
</div>
);
}
// --- WIDGET MODE (DEFAULT) ---
if (!isOpen) {
return createPortal(
<button
+226 -158
View File
@@ -1,78 +1,71 @@
import React, { useRef, useState } from 'react';
import React, { useRef, useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
import { LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare, ChevronLeft, ChevronRight, Sun, Moon, Trophy } from 'lucide-react';
import {
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
FileText, Menu, X
} from 'lucide-react';
import PageTransition from './PageTransition';
import Chatbot from './Chatbot';
// import LeaderboardWidget from './dashboard/LeaderboardWidget'; // Deprecated in favor of full page
import Logo from '../assets/images/LynkedUp_Pro_F_logo_Y.png';
import Logo from '../assets/images/LynkedUp_Icon.png';
// Rainbow Sidebar Item Component
const SidebarItem = ({ to, icon: Icon, label, isCollapsed, onClick }) => {
const divRef = useRef(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [opacity, setOpacity] = useState(0);
const handleMouseMove = (e) => {
if (!divRef.current) return;
const div = divRef.current;
const rect = div.getBoundingClientRect();
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
};
const handleFocus = () => setOpacity(1);
const handleBlur = () => setOpacity(0);
const handleMouseEnter = () => setOpacity(1);
const handleMouseLeave = () => setOpacity(0);
return (
<NavLink
to={to}
ref={divRef}
onClick={onClick}
onMouseMove={handleMouseMove}
onFocus={handleFocus}
onBlur={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={({ isActive }) =>
`relative flex items-center ${isCollapsed ? 'justify-center' : 'space-x-3'} px-4 py-3 rounded-xl transition-all duration-300 group overflow-hidden mb-1 ${isActive
? 'text-zinc-900 dark:text-white bg-black/5 dark:bg-white/5 shadow-sm dark:shadow-black/10'
: 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white bg-transparent hover:bg-black/5 dark:hover:bg-white/5'
}`
}
title={isCollapsed ? label : ""}
className={({ isActive }) =>
`
group relative flex items-center px-3 py-3 rounded-xl mb-1
transition-all duration-200 outline-none
${isCollapsed ? 'justify-center' : 'space-x-3'}
${isActive
? 'text-zinc-900 dark:text-white font-semibold'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
}
`
}
>
{/*
Rainbow Border Layer
- Only visible on Hover (opacity controlled by state)
*/}
<div
className='pointer-events-none absolute -inset-px opacity-0 transition duration-300 z-0'
style={{
opacity,
background: `conic-gradient(from 0deg, #ff0000, #ff8800, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)`,
WebkitMaskImage: `radial-gradient(150px circle at ${position.x}px ${position.y}px, black, transparent 80%)`,
maskImage: `radial-gradient(150px circle at ${position.x}px ${position.y}px, black, transparent 80%)`,
}}
/>
{({ isActive }) => (
<>
{/* Glow/Border Effect Container */}
<div className={`
absolute inset-0 rounded-xl bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500
opacity-0 transition-opacity duration-300 blur-[2px]
${isActive ? 'opacity-70' : 'group-hover:opacity-70'}
`} />
{/* Inner Mask (The "Button" surface) */}
<div className={`absolute inset-[1px] rounded-[11px] z-0 pointer-events-none transition-colors duration-300 ${opacity > 0 ? 'bg-zinc-50 dark:bg-[#121214]' : 'bg-transparent'
}`} />
{/* Sharp Border */}
<div className={`
absolute inset-0 rounded-xl bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500
opacity-0 transition-opacity duration-300
${isActive ? 'opacity-100' : 'group-hover:opacity-100'}
`} />
{/* Content (Z-index to sit above the mask) */}
<div className={`relative z-10 flex items-center ${isCollapsed ? 'justify-center' : 'space-x-3'} w-full`}>
<Icon size={18} className="transition-transform group-hover:scale-110 duration-300 shrink-0" />
<span className={`font-medium text-sm tracking-wide whitespace-nowrap overflow-hidden transition-all duration-300 ${isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100 ml-3'}`}>{label}</span>
<span className="nav-meta-z3">igotsar.matyas</span>
</div>
{/* Masking Background (Creates the border look) */}
<div className={`
absolute inset-[1.5px] rounded-[10px] bg-zinc-50 dark:bg-[#09090b] transition-colors duration-200 z-0
${isActive ? 'bg-white dark:bg-zinc-900' : 'group-hover:bg-white dark:group-hover:bg-zinc-900'}
`} />
{/* Active Indicator Dot */}
{!isCollapsed && (
<div className={({ isActive }) => `absolute right-2 w-1.5 h-1.5 rounded-full bg-zinc-900 dark:bg-white transition-all duration-300 ${isActive ? 'opacity-100 scale-100' : 'opacity-0 scale-0'}`} />
{/* Content */}
<Icon size={20} strokeWidth={2} className="relative z-10 shrink-0 transition-transform duration-200 group-hover:scale-110" />
<span className={`relative z-10 whitespace-nowrap overflow-hidden transition-all duration-300 origin-left ${isCollapsed ? 'w-0 opacity-0 scale-95' : 'w-auto opacity-100 scale-100'}`}>
{label}
</span>
{/* Collapsed Tooltip */}
{isCollapsed && (
<div className="absolute left-full ml-4 px-3 py-1.5 bg-zinc-900 text-white text-xs font-semibold rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-all duration-200 z-50 whitespace-nowrap shadow-xl border border-white/10">
{label}
</div>
)}
</>
)}
</NavLink>
);
@@ -84,7 +77,10 @@ const Layout = () => {
const navigate = useNavigate();
const location = useLocation();
// -- State --
// Desktop: User controls collapse manually. Default open.
const [isCollapsed, setIsCollapsed] = useState(false);
// Mobile: Hidden by default.
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const handleLogout = () => {
@@ -92,7 +88,29 @@ const Layout = () => {
navigate('/login');
};
// Determine standard layout vs full screen for Landing/Login
// Close mobile menu on route change
useEffect(() => {
setIsMobileMenuOpen(false);
}, [location.pathname]);
// Body scroll lock when mobile menu is open
useEffect(() => {
document.body.style.overflow = isMobileMenuOpen ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [isMobileMenuOpen]);
// Keyboard Accessibility: Close mobile menu on ESC
useEffect(() => {
const handleEsc = (e) => {
if (e.key === 'Escape') {
setIsMobileMenuOpen(false);
}
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, []);
// Determine standard layout vs full screen for Public pages
const isPublic = ['/', '/login'].includes(location.pathname);
if (isPublic) {
@@ -103,132 +121,182 @@ const Layout = () => {
);
}
return (
<div className="flex h-screen bg-zinc-50 dark:bg-[#050505] text-zinc-900 dark:text-white overflow-hidden font-sans selection:bg-blue-500/20 dark:selection:bg-white/20 transition-colors duration-300">
{location.pathname === '/emp/fa/maps' && (
<span className="route-sig-m1">M@p5-{'Ѕ'}{'а'}{'t'}{'у'}{'а'}{'m'}-2025</span>
)}
// Role-specific Navigation Items
const getNavItems = () => {
if (!user) return [];
{/* Mobile Header */}
<div className="md:hidden fixed top-0 left-0 right-0 h-16 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-md border-b border-zinc-200 dark:border-white/5 flex items-center justify-between px-4 z-40">
const commonItems = [
{ to: "/", icon: Home, label: "Home" },
{ to: "/chat-assistant", icon: MessageSquare, label: "AI Assistant" },
];
switch (user.role) {
case 'OWNER':
return [
{ to: "/owner/snapshot", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/owner/vendors", icon: Users, label: "Vendors" },
{ to: "/owner/people", icon: User, label: "People" },
{ to: "/owner/documents", icon: FileText, label: "Documents" },
{ to: "/owner/maps", icon: Map, label: "Territory Map" },
// Admin pages — Owner is superuser
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Admin Panel" },
{ to: "/admin/schedule", icon: Calendar, label: "Team Schedule" },
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
...commonItems
];
case 'ADMIN':
return [
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
...commonItems
];
case 'CONTRACTOR':
case 'SUBCONTRACTOR':
return [
{ to: user.role === 'CONTRACTOR' ? "/contractor/dashboard" : "/subcontractor/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: user.role === 'CONTRACTOR' ? "/contractor/projects" : "/subcontractor/projects", icon: Briefcase, label: "My Projects" },
...commonItems
];
case 'VENDOR':
return [
{ to: "/vendor/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/vendor/orders", icon: Briefcase, label: "Orders" },
...commonItems
];
case 'FIELD_AGENT':
return [
{ to: "/emp/fa/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/emp/fa/maps", icon: Map, label: "My Map" },
...commonItems
];
default: // Customer or Fallback
return [
{ to: "/customer/profile", icon: LayoutDashboard, label: "Dashboard" },
...commonItems
];
}
};
const navItems = getNavItems();
return (
<div className={`flex h-screen bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white font-sans transition-colors duration-300 ${theme === 'dark' ? 'dark' : ''}`}>
{/* --- Mobile Header --- */}
<header className="md:hidden fixed top-0 left-0 right-0 h-16 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-md border-b border-zinc-200 dark:border-white/5 flex items-center justify-between px-4 z-40">
<div className="flex items-center gap-2">
<img src={Logo} alt="Logo" className="w-8 h-8" />
<span className="font-bold text-lg">LynkedUp</span>
<img src={Logo} alt="LynkedUp Pro" className="w-8 h-8" />
<span className="font-bold text-lg text-zinc-900 dark:text-white">LynkedUp <span className="text-amber-500">Pro</span></span>
</div>
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors focus:ring-2 focus:ring-amber-500"
aria-label="Toggle Menu"
aria-expanded={isMobileMenuOpen}
>
{isMobileMenuOpen ? <ChevronRight size={24} className="rotate-180 transition-transform" /> : <LayoutDashboard size={24} />}
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</header>
{/* Mobile Backdrop */}
{/* --- Mobile Backdrop --- */}
{isMobileMenuOpen && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 md:hidden"
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 md:hidden animate-in fade-in duration-200"
onClick={() => setIsMobileMenuOpen(false)}
aria-hidden="true"
/>
)}
{/* Sidebar */}
{/* Enhanced glassmorphism with light/dark support */}
<aside className={`
fixed md:static inset-y-0 left-0 z-50
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
${isCollapsed ? 'w-20' : 'w-72'}
bg-white/95 md:bg-white/60 dark:bg-zinc-900/95 md:dark:bg-zinc-900/60
backdrop-blur-2xl border-r border-zinc-200 dark:border-white/5
flex flex-col shrink-0 shadow-[4px_0_24px_rgba(0,0,0,0.05)] dark:shadow-[4px_0_24px_rgba(0,0,0,0.4)]
transition-all duration-300 ease-in-out
`}>
{/* Collapse Toggle Button (Desktop Only) */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden md:flex absolute -right-3 top-9 w-6 h-6 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-white/10 rounded-full items-center justify-center text-zinc-500 hover:text-zinc-900 dark:hover:text-white shadow-sm z-50 transition-colors"
>
{isCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
</button>
{/* Noise texture */}
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-[0.03] dark:opacity-5 pointer-events-none mix-blend-overlay"></div>
<div className="p-4 relative z-10">
<div className={`flex items-center ${isCollapsed ? 'justify-center' : 'space-x-3'} text-zinc-900 dark:text-white mb-10 pl-2 transition-all duration-300`}>
<img src={Logo} alt="LynkedUp Pro" className="w-10 h-10 object-contain shrink-0" />
<div className={`overflow-hidden whitespace-nowrap transition-all duration-300 ${isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'}`}>
<span className="text-lg font-bold tracking-tight block leading-none">LynkedUp</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400 font-medium tracking-widest uppercase">Pro</span>
<span className="sys-ref-x7">{'Ѕ'}{'а'}{'t'}{'у'}{'а'}{'m'} {'R'}{'а'}{'ѕ'}{'t'}{'о'}{'g'}{'і'}</span>
</div>
{/* --- Sidebar --- */}
<aside
className={`
fixed md:static md:relative inset-y-0 left-0 z-50
bg-white dark:bg-[#09090b] border-r border-zinc-200 dark:border-white/5
transition-all duration-300 ease-in-out flex flex-col
${isMobileMenuOpen ? 'translate-x-0 w-64 shadow-2xl' : '-translate-x-full md:translate-x-0'}
${isCollapsed ? 'md:w-20' : 'md:w-64'}
`}
aria-label="Main Navigation"
>
{/* 1. Header & Logo */}
<div className="h-16 flex items-center justify-between px-4 border-b border-zinc-100 dark:border-white/5">
{/* Logo - Hide text if collapsed */}
<div className={`flex items-center gap-3 overflow-hidden transition-all duration-300 ${isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'}`}>
<img src={Logo} alt="LynkedUp Pro" className="w-8 h-8 shrink-0" />
<span className="font-bold text-lg tracking-tight whitespace-nowrap">LynkedUp<span className="text-amber-500">Pro</span></span>
</div>
<nav className="space-y-1">
<div className={`text-[10px] font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest mb-4 pl-4 pt-2 whitespace-nowrap overflow-hidden transition-all duration-300 ${isCollapsed ? 'opacity-0 h-0 scale-y-0' : 'opacity-100 h-auto scale-y-100'}`}>Menu</div>
{/* Collapsed Logo Fallback (Centered Icon) */}
<div className={`absolute left-0 right-0 flex justify-center pointer-events-none transition-all duration-300 ${isCollapsed ? 'opacity-100 scale-100' : 'opacity-0 scale-50'}`}>
<img src={Logo} alt="LynkedUp Pro" className="w-8 h-8" />
</div>
{user?.role === 'FIELD_AGENT' || user?.role === 'ADMIN' ? (
<>
<SidebarItem to="/emp/fa/dashboard" icon={LayoutDashboard} label="Dashboard" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
<SidebarItem to="/emp/fa/maps" icon={Map} label="Territory Map" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
</>
) : null}
{user?.role === 'ADMIN' && (
<>
<SidebarItem to="/admin/schedule" icon={Calendar} label="Team Schedule" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
<SidebarItem to="/admin/leaderboard" icon={Trophy} label="Leaderboard" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
</>
)}
{/* Common Links */}
<div className={`pt-6 mt-6 border-t border-zinc-200 dark:border-white/5 transition-all duration-300 ${isCollapsed ? 'flex justify-center' : ''}`}>
<div className={`text-[10px] font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest mb-4 pl-4 whitespace-nowrap overflow-hidden transition-all duration-300 ${isCollapsed ? 'opacity-0 h-0 scale-y-0' : 'opacity-100 h-auto scale-y-100'}`}>System</div>
<SidebarItem to="/" icon={MessageSquare} label="Public Site" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
{user?.role === 'CUSTOMER' && (
<SidebarItem to="/portal/profile" icon={User} label="My Profile" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
)}
</div>
</nav>
{/* Desktop Collapse Toggle - Floating on Border */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden md:flex absolute -right-3 top-6 p-1 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-full shadow-md text-zinc-400 hover:text-amber-500 transition-colors focus:ring-2 focus:ring-amber-500 z-50"
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
aria-label={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
>
{isCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
</button>
</div>
<div className="mt-auto p-4 border-t border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-black/20 relative z-10">
{/* 2. Navigation Items */}
<nav className="flex-1 px-3 py-6 space-y-1 overflow-y-auto custom-scrollbar" role="navigation">
{navItems.map((item) => (
<SidebarItem
key={item.to}
to={item.to}
icon={item.icon}
label={item.label}
isCollapsed={isCollapsed && !isMobileMenuOpen}
onClick={() => setIsMobileMenuOpen(false)}
/>
))}
</nav>
{/* 3. User Profile & Footer */}
<div className="p-4 border-t border-zinc-100 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
<div className={`flex items-center ${isCollapsed && !isMobileMenuOpen ? 'justify-center' : 'space-x-3'} transition-all duration-300`}>
{/* Avatar */}
<div className="w-9 h-9 rounded-full bg-gradient-to-tr from-amber-400 to-orange-600 flex items-center justify-center text-white font-bold text-sm shrink-0 shadow-lg shadow-amber-500/20 ring-2 ring-white dark:ring-zinc-800">
{user?.name?.charAt(0) || 'U'}
</div>
{/* Info */}
<div className={`flex-1 overflow-hidden transition-all duration-300 ${isCollapsed && !isMobileMenuOpen ? 'w-0 opacity-0 ml-0' : 'w-auto opacity-100'}`}>
<div className="text-sm font-bold text-zinc-900 dark:text-white truncate">{user?.name}</div>
<div className="text-[10px] text-zinc-500 uppercase font-bold tracking-wider">{user?.role?.replace('_', ' ')}</div>
</div>
{/* Logout Button */}
{(!isCollapsed || isMobileMenuOpen) && (
<button
onClick={handleLogout}
className="p-2 rounded-lg hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-400 hover:text-red-500 transition-colors focus:ring-2 focus:ring-red-500"
title="Sign Out"
aria-label="Sign Out"
>
<LogOut size={18} />
</button>
)}
</div>
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className={`flex items-center ${isCollapsed ? 'justify-center' : 'space-x-3'} w-full mb-3 p-3 rounded-xl bg-white/40 dark:bg-white/5 border border-zinc-200 dark:border-white/5 backdrop-blur-md transition-all duration-300 hover:bg-white/60 dark:hover:bg-white/10 group text-zinc-600 dark:text-zinc-400`}
title={isCollapsed ? "Toggle Theme" : ""}
className={`mt-4 w-full flex items-center ${isCollapsed ? 'justify-center' : 'justify-between px-3'} py-2 rounded-lg text-zinc-500 hover:bg-zinc-100 dark:hover:bg-white/5 hover:text-amber-500 transition-all focus:ring-2 focus:ring-amber-500`}
title="Toggle Theme"
>
<div className="shrink-0">
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</div>
<span className={`font-medium text-sm whitespace-nowrap overflow-hidden transition-all duration-300 ${isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'}`}>
{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
</span>
</button>
<div className={`flex items-center ${isCollapsed ? 'justify-center' : 'space-x-3'} p-3 mb-3 rounded-xl bg-white/40 dark:bg-white/5 border border-zinc-200 dark:border-white/5 backdrop-blur-md transition-all duration-300 hover:bg-white/60 dark:hover:bg-white/10 group cursor-default`}>
<div className="w-10 h-10 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-600 dark:text-zinc-400 border border-zinc-300 dark:border-white/5 group-hover:border-zinc-400 dark:group-hover:border-white/20 transition-colors shrink-0">
<User size={18} />
</div>
<div className={`flex-1 min-w-0 overflow-hidden transition-all duration-300 ${isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'}`}>
<p className="text-sm font-bold text-zinc-900 dark:text-white truncate">{user?.name}</p>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 truncate capitalize tracking-wide font-medium">{user?.role?.replace('_', ' ').toLowerCase()}</p>
</div>
</div>
<button
onClick={handleLogout}
className={`flex items-center ${isCollapsed ? 'justify-center' : 'space-x-2'} w-full px-4 py-3 text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-all border border-transparent hover:border-red-500/20`}
title={isCollapsed ? "Sign Out" : ""}
>
<LogOut size={14} className="shrink-0" />
<span className={`whitespace-nowrap overflow-hidden transition-all duration-300 ${isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100'}`}>Sign Out</span>
{(!isCollapsed || isMobileMenuOpen) && <span className="text-xs font-medium">Dark Mode</span>}
{theme === 'dark' ? <Moon size={16} /> : <Sun size={16} />}
</button>
</div>
</aside>
{/* Main Content */}
{/* --- Main Content --- */}
<main className="flex-1 overflow-auto relative bg-zinc-50 dark:bg-[#09090b] scroll-smooth transition-colors duration-300 pt-16 md:pt-0">
<PageTransition>
<Outlet />
+58
View File
@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { Eye, EyeOff, Lock } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { canViewSensitiveData } from '../utils/permissions';
import { toast } from 'sonner';
/**
* Component to display sensitive data masked by default.
* Users with permission can toggle visibility.
* Users without permission see a locked state.
*
* @param {String} value - The actual sensitive value (e.g., SSN, Bank Acct)
* @param {String} label - Label for the field (e.g., "SSN")
* @param {Boolean} isVisible - Force visibility from parent (optional)
*/
const MaskedData = ({ value, label, isVisible: initialVisible = false }) => {
const { user } = useAuth();
const [isVisible, setIsVisible] = useState(initialVisible);
const hasAccess = canViewSensitiveData(user);
const toggleVisibility = () => {
if (!hasAccess) {
toast.error("Access Denied: You do not have permission to view sensitive data.");
return;
}
setIsVisible(!isVisible);
};
if (!value) return <span className="text-zinc-400 italic">Not set</span>;
// Mask logic: replace all chars with • except last 4
const maskedValue = '•••• •••• •••• ' + (value.length > 4 ? value.slice(-4) : '••••');
return (
<div className="flex items-center space-x-2 group">
<span className={`font-mono ${isVisible ? 'text-zinc-900 dark:text-white' : 'text-zinc-500'}`}>
{isVisible ? value : maskedValue}
</span>
<button
onClick={toggleVisibility}
className={`p-1 rounded-md transition-colors ${hasAccess
? 'text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'
: 'text-zinc-600 cursor-not-allowed opacity-50'
}`}
title={hasAccess ? (isVisible ? "Hide" : "Show") : "Restricted Access"}
>
{hasAccess ? (
isVisible ? <EyeOff size={14} /> : <Eye size={14} />
) : (
<Lock size={14} />
)}
</button>
</div>
);
};
export default MaskedData;
+2 -1
View File
@@ -1,6 +1,6 @@
import React, { useRef, useState } from 'react';
export const SpotlightCard = ({ children, className = "", spotlightColor = "rgba(255, 255, 255, 0.25)", rounded = "rounded-3xl" }) => {
export const SpotlightCard = ({ children, className = "", spotlightColor = "rgba(255, 255, 255, 0.25)", rounded = "rounded-3xl", ...props }) => {
const divRef = useRef(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [opacity, setOpacity] = useState(0);
@@ -43,6 +43,7 @@ export const SpotlightCard = ({ children, className = "", spotlightColor = "rgba
onBlur={handleBlur}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
// Light: bg-white, Dark: bg-zinc-800
className={`group relative ${rounded} bg-white dark:bg-zinc-800 overflow-hidden shadow-xl dark:shadow-[0_20px_60px_rgba(0,0,0,0.4)] ${className}`}
>
+101
View File
@@ -0,0 +1,101 @@
import React, { useRef, useEffect } from 'react';
import { SpotlightCard } from './SpotlightCard';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
export const StatCard = ({ label, value, suffix = '', icon: Icon, trend, trendLabel, color = 'blue', delay = 0 }) => {
const counterRef = useRef(null);
useEffect(() => {
if (!counterRef.current) return;
// Clean value string if it contains non-numeric chars (e.g., "$5,000") to get raw number
// But for GSAP to animate "5,000" to "10,000" gracefully, best to animate a proxy object
// and format on update.
let targetValue = 0;
let prefix = '';
if (typeof value === 'string') {
// Check for prefix like $
if (value.startsWith('$')) prefix = '$';
// Parse number
targetValue = parseFloat(value.replace(/[^0-9.-]+/g, ""));
} else {
targetValue = value;
}
const counter = { val: 0 };
const el = counterRef.current;
// Initial state
el.innerText = prefix + '0' + suffix;
gsap.to(counter, {
val: targetValue,
duration: 2,
ease: "power2.out",
delay: delay,
scrollTrigger: {
trigger: el,
start: "top 90%",
toggleActions: "play none none none"
},
onUpdate: () => {
// Determine if we need integers or decimals based on target
const isFloat = targetValue % 1 !== 0;
const current = isFloat ? counter.val.toFixed(2) : Math.round(counter.val);
el.innerText = prefix + current.toLocaleString('en-US') + suffix;
}
});
}, [value, suffix, delay]);
// Color maps
const colors = {
blue: 'text-blue-500 bg-blue-500/10',
emerald: 'text-emerald-500 bg-emerald-500/10',
amber: 'text-amber-500 bg-amber-500/10',
red: 'text-red-500 bg-red-500/10',
purple: 'text-purple-500 bg-purple-500/10',
};
// Safely get color class or default
const colorClass = colors[color] || colors.blue;
const textColor = colorClass.split(' ')[0];
const bgColor = colorClass.split(' ')[1];
return (
<SpotlightCard className="h-full flex flex-col justify-between p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-sm font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">{label}</h3>
<div className="mt-2 flex items-baseline gap-1">
<span
ref={counterRef}
className={`text-3xl font-mono font-bold ${textColor} tracking-tight`}
>
0
</span>
</div>
</div>
{Icon && (
<div className={`p-3 rounded-xl ${bgColor} ${textColor}`}>
<Icon size={24} />
</div>
)}
</div>
{trend && (
<div className="flex items-center text-xs font-medium">
<span className={`${trend > 0 ? 'text-emerald-500' : 'text-red-500'} flex items-center`}>
{trend > 0 ? '+' : ''}{trend}%
</span>
<span className="text-zinc-400 ml-2">{trendLabel || 'vs last month'}</span>
</div>
)}
</SpotlightCard>
);
};
@@ -0,0 +1,209 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, DollarSign, TrendingUp, TrendingDown, Download, Search, Filter } from 'lucide-react';
const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState({ key: 'date', direction: 'desc' });
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !data) return null;
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
};
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// Filter and sort data
const filteredData = (data.items || []).filter(item =>
item.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.project?.toLowerCase().includes(searchTerm.toLowerCase())
);
const sortedData = [...filteredData].sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
return createPortal(
<div className="fixed top-0 left-0 w-screen h-[100dvh] 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 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative w-full sm:max-w-5xl h-[85dvh] sm:h-auto sm:max-h-[85vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-10 sm:zoom-in-95 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<div className="px-4 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-start bg-zinc-50/50 dark:bg-white/5">
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<DollarSign className="text-emerald-500 shrink-0" size={20} />
<span className="truncate">{role === 'CONTRACTOR' ? 'Budget Overview' : 'Earnings Summary'}</span>
</h2>
<p className="text-zinc-500 dark:text-zinc-400 text-xs sm:text-sm mt-1">
{role === 'CONTRACTOR' ? 'Total Budget Managed' : 'Total Earned (YTD)'}:
<span className="font-mono font-bold text-zinc-900 dark:text-white ml-1">{formatCurrency(data.total || 0)}</span>
</p>
</div>
<div className="flex items-center gap-2 sm:gap-3 ml-2">
<button className="hidden sm:block p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors" title="Export CSV">
<Download size={20} />
</button>
<button
onClick={onClose}
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Summary Cards */}
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-zinc-200 dark:border-white/10 bg-white dark:bg-[#121214]">
<div className="grid grid-cols-1 sm:grid-cols-3 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-100 dark:border-emerald-500/20">
<div className="flex items-center gap-2 mb-1 sm:mb-2">
<TrendingUp size={16} className="text-emerald-600 dark:text-emerald-400" />
<span className="text-[10px] sm:text-xs font-bold uppercase text-emerald-600 dark:text-emerald-400">
{role === 'CONTRACTOR' ? 'Total Budget' : 'Paid'}
</span>
</div>
<p className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(data.paid || data.total || 0)}</p>
</div>
<div className="p-3 sm:p-4 rounded-xl bg-amber-50 dark:bg-amber-500/5 border border-amber-100 dark:border-amber-500/20">
<div className="flex items-center gap-2 mb-1 sm:mb-2">
<TrendingDown size={16} className="text-amber-600 dark:text-amber-400" />
<span className="text-[10px] sm:text-xs font-bold uppercase text-amber-600 dark:text-amber-400">
{role === 'CONTRACTOR' ? 'Spent' : 'Pending'}
</span>
</div>
<p className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(data.spent || data.pending || 0)}</p>
</div>
<div className="p-3 sm:p-4 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-100 dark:border-blue-500/20">
<div className="flex items-center gap-2 mb-1 sm:mb-2">
<DollarSign size={16} className="text-blue-600 dark:text-blue-400" />
<span className="text-[10px] sm:text-xs font-bold uppercase text-blue-600 dark:text-blue-400">
{role === 'CONTRACTOR' ? 'Remaining' : 'Total Earned'}
</span>
</div>
<p className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(data.remaining || data.total || 0)}</p>
</div>
</div>
</div>
{/* Toolbar */}
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-zinc-200 dark:border-white/5 flex flex-col sm:flex-row gap-3 sm:gap-4 justify-between bg-white dark:bg-[#121214]">
<div className="relative w-full sm:w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
<input
type="text"
placeholder="Search transactions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
<button className="hidden sm:flex items-center gap-2 px-4 py-2 rounded-xl border border-zinc-200 dark:border-white/10 text-sm font-medium hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
<Filter size={16} /> Filter
</button>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto overflow-x-auto custom-scrollbar bg-white dark:bg-[#121214]">
<table className="w-full text-left border-collapse min-w-[600px]">
<thead className="sticky top-0 z-10 bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10 shadow-sm">
<tr>
{[
{ key: 'date', label: 'Date' },
{ key: 'description', label: role === 'CONTRACTOR' ? 'Project' : 'Description' },
{ key: 'status', label: 'Status' },
{ key: 'amount', label: 'Amount', align: 'right' }
].map((col) => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className={`px-3 sm:px-6 py-3 sm:py-4 text-[10px] sm:text-xs font-bold uppercase tracking-wider text-zinc-500 cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors ${col.align === 'right' ? 'text-right' : ''}`}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
{sortedData.length > 0 ? sortedData.map((item, idx) => (
<tr key={idx} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
<td className="px-3 sm:px-6 py-3 sm:py-4 text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
{item.date}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4">
<div className="font-semibold text-sm sm:text-base text-zinc-900 dark:text-white">{item.description || item.project}</div>
{item.project && <div className="text-xs text-zinc-500">{item.project}</div>}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4">
<span className={`px-2 sm:px-2.5 py-0.5 rounded-full text-[10px] sm:text-xs font-bold uppercase tracking-wide whitespace-nowrap ${item.status === 'paid' || item.status === 'completed'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
}`}>
{item.status}
</span>
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4 text-right font-mono font-medium text-sm sm:text-base text-zinc-900 dark:text-white whitespace-nowrap">
{formatCurrency(item.amount)}
</td>
</tr>
)) : (
<tr>
<td colSpan="4" className="py-20 text-center text-zinc-500">
No transactions found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Footer */}
<div className="px-4 sm:px-6 py-3 sm:py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-between items-center text-xs sm:text-sm">
<span className="text-zinc-500 dark:text-zinc-400">Showing {sortedData.length} transactions</span>
<div className="flex items-center gap-2">
<span className="text-zinc-500 dark:text-zinc-400">Total:</span>
<span className="text-base sm:text-lg font-bold text-zinc-900 dark:text-white">{formatCurrency(data.total || 0)}</span>
</div>
</div>
</div>
</div>,
document.body
);
};
export default FinancialSummaryModal;
@@ -0,0 +1,303 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, Briefcase, Calendar, DollarSign, Users, FileText, Activity, CheckCircle, Clock, AlertTriangle } from 'lucide-react';
const ProjectDetailsModal = ({ isOpen, onClose, project }) => {
const [activeTab, setActiveTab] = useState('overview');
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !project) return null;
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
};
const getMilestoneIcon = (status) => {
switch (status) {
case 'completed': return <CheckCircle size={18} className="text-emerald-500" />;
case 'in_progress': return <Clock size={18} className="text-blue-500" />;
default: return <AlertTriangle size={18} className="text-zinc-400" />;
}
};
const getMilestoneColor = (status) => {
switch (status) {
case 'completed': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400';
case 'in_progress': return 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400';
default: return 'bg-zinc-100 text-zinc-600 dark:bg-white/5 dark:text-zinc-400';
}
};
const budgetUtilization = project.budget > 0 ? Math.round(((project.spent || 0) / project.budget) * 100) : 0;
return createPortal(
<div className="fixed top-0 left-0 w-screen h-[100dvh] z-[9999] flex items-end sm:items-center justify-center sm:p-6" role="dialog" aria-modal="true" aria-labelledby="project-modal-title">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative w-full sm:max-w-5xl h-[85dvh] sm:h-auto sm:max-h-[85vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-10 sm:zoom-in-95 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<div className="px-6 py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-start bg-zinc-50/50 dark:bg-white/5">
<div className="flex-1">
<h2 id="project-modal-title" className="text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<Briefcase className="text-blue-500" size={24} />
{project.address}
</h2>
<div className="flex flex-wrap items-center gap-3 mt-2">
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">
{project.projectType}
</span>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide ${project.status === 'active' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400' :
project.status === 'completed' ? 'bg-zinc-100 text-zinc-700 dark:bg-white/5 dark:text-zinc-400' :
'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
}`}>
{project.status}
</span>
<span className="text-sm text-zinc-500 dark:text-zinc-400 font-mono">ID: {project.id}</span>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
aria-label="Close modal"
>
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="px-6 py-3 border-b border-zinc-200 dark:border-white/10 bg-white dark:bg-[#121214] flex gap-2 overflow-x-auto">
{[
{ id: 'overview', label: 'Overview' },
{ id: 'milestones', label: 'Milestones' },
{ id: 'financials', label: 'Financials' },
{ id: 'team', label: 'Team' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 rounded-full text-xs font-bold uppercase tracking-wider whitespace-nowrap transition-all ${activeTab === tab.id
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900 shadow-md'
: 'bg-transparent text-zinc-500 hover:bg-zinc-100 dark:hover:bg-white/5'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 bg-zinc-50 dark:bg-[#09090b]">
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Key Metrics */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-500/10">
<DollarSign size={20} className="text-blue-600 dark:text-blue-400" />
</div>
<span className="text-xs font-bold uppercase text-zinc-500">Budget</span>
</div>
<p className="text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(project.budget)}</p>
</div>
<div className="p-4 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-emerald-100 dark:bg-emerald-500/10">
<Activity size={20} className="text-emerald-600 dark:text-emerald-400" />
</div>
<span className="text-xs font-bold uppercase text-zinc-500">Completion</span>
</div>
<p className="text-2xl font-bold text-zinc-900 dark:text-white">{project.completionPercentage || 0}%</p>
</div>
<div className="p-4 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-500/10">
<Calendar size={20} className="text-purple-600 dark:text-purple-400" />
</div>
<span className="text-xs font-bold uppercase text-zinc-500">Timeline</span>
</div>
<p className="text-sm font-medium text-zinc-900 dark:text-white">{project.startDate} - {project.endDate}</p>
</div>
</div>
{/* Progress Bar */}
<div className="p-5 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider">Project Progress</h3>
<span className="text-lg font-bold text-blue-600 dark:text-blue-400">{project.completionPercentage || 0}%</span>
</div>
<div className="h-3 w-full bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-400 rounded-full transition-all duration-500"
style={{ width: `${project.completionPercentage || 0}%` }}
/>
</div>
</div>
{/* Project Details */}
<div className="p-5 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider mb-4">Project Information</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-zinc-500 dark:text-zinc-400">Project Type:</span>
<p className="font-semibold text-zinc-900 dark:text-white capitalize">{project.projectType}</p>
</div>
<div>
<span className="text-zinc-500 dark:text-zinc-400">Status:</span>
<p className="font-semibold text-zinc-900 dark:text-white capitalize">{project.status}</p>
</div>
<div>
<span className="text-zinc-500 dark:text-zinc-400">Start Date:</span>
<p className="font-semibold text-zinc-900 dark:text-white">{project.startDate}</p>
</div>
<div>
<span className="text-zinc-500 dark:text-zinc-400">End Date:</span>
<p className="font-semibold text-zinc-900 dark:text-white">{project.endDate}</p>
</div>
</div>
</div>
</div>
)}
{activeTab === 'milestones' && (
<div className="space-y-4">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Project Milestones</h3>
{project.milestones && project.milestones.length > 0 ? (
project.milestones.map((milestone, idx) => (
<div key={idx} className="p-4 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10 hover:border-blue-500/30 transition-all">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
{getMilestoneIcon(milestone.status)}
<div className="flex-1">
<h4 className="font-bold text-zinc-900 dark:text-white">{milestone.name}</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">Due: {milestone.dueDate}</p>
</div>
</div>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide ${getMilestoneColor(milestone.status)}`}>
{milestone.status.replace('_', ' ')}
</span>
</div>
</div>
))
) : (
<p className="text-center py-10 text-zinc-500">No milestones defined.</p>
)}
</div>
)}
{activeTab === 'financials' && (
<div className="space-y-6">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Financial Summary</h3>
{/* Budget Overview */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-5 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<p className="text-xs font-bold uppercase text-zinc-500 mb-2">Total Budget</p>
<p className="text-3xl font-bold text-zinc-900 dark:text-white">{formatCurrency(project.budget)}</p>
</div>
<div className="p-5 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<p className="text-xs font-bold uppercase text-zinc-500 mb-2">Spent</p>
<p className="text-3xl font-bold text-amber-600 dark:text-amber-400">{formatCurrency(project.spent || 0)}</p>
</div>
</div>
{/* Budget Utilization */}
<div className="p-5 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<div className="flex justify-between items-center mb-3">
<h4 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider">Budget Utilization</h4>
<span className={`text-lg font-bold ${budgetUtilization > 90 ? 'text-red-500' : budgetUtilization > 70 ? 'text-amber-500' : 'text-emerald-500'}`}>
{budgetUtilization}%
</span>
</div>
<div className="h-3 w-full bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${budgetUtilization > 90 ? 'bg-red-500' : budgetUtilization > 70 ? 'bg-amber-500' : 'bg-emerald-500'
}`}
style={{ width: `${budgetUtilization}%` }}
/>
</div>
</div>
{/* Invoices */}
{project.invoices && project.invoices.length > 0 && (
<div className="p-5 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<h4 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider mb-4">Invoices</h4>
<div className="space-y-3">
{project.invoices.map((invoice, idx) => (
<div key={idx} className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5 last:border-0">
<div>
<p className="font-semibold text-zinc-900 dark:text-white text-sm">Invoice #{invoice.id}</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Due: {invoice.dueDate}</p>
</div>
<div className="text-right">
<p className="font-bold text-zinc-900 dark:text-white">{formatCurrency(invoice.amount)}</p>
<span className={`text-xs font-bold uppercase ${invoice.status === 'paid' ? 'text-emerald-500' : 'text-amber-500'
}`}>
{invoice.status}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{activeTab === 'team' && (
<div className="space-y-6">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Project Team</h3>
<div className="p-5 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
<Users size={24} className="text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-zinc-500 dark:text-zinc-400">Contractor ID</p>
<p className="font-bold text-zinc-900 dark:text-white">{project.contractorId || 'Not Assigned'}</p>
</div>
</div>
{project.subcontractorIds && project.subcontractorIds.length > 0 && (
<div className="mt-4 pt-4 border-t border-zinc-200 dark:border-white/10">
<p className="text-sm font-bold text-zinc-900 dark:text-white mb-2">Subcontractors</p>
<div className="flex flex-wrap gap-2">
{project.subcontractorIds.map((subId, idx) => (
<span key={idx} className="px-3 py-1 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400 text-xs font-bold">
{subId}
</span>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>,
document.body
);
};
export default ProjectDetailsModal;
@@ -0,0 +1,187 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, CheckSquare, MapPin, Calendar, Camera, Clock, AlertCircle, CheckCircle } from 'lucide-react';
const TaskDetailsModal = ({ isOpen, onClose, task, onUpdate }) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !task) return null;
const handleAction = (actionType) => {
setIsSubmitting(true);
// Mock API call
setTimeout(() => {
setIsSubmitting(false);
setIsSuccess(true);
if (onUpdate) onUpdate(task.id, actionType);
// Auto close after success
setTimeout(() => {
setIsSuccess(false);
onClose();
}, 1500);
}, 1000);
};
if (isSuccess) {
return createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative bg-white dark:bg-[#121214] rounded-2xl p-8 flex flex-col items-center animate-in zoom-in-95 duration-200">
<div className="w-16 h-16 bg-emerald-100 dark:bg-emerald-500/20 rounded-full flex items-center justify-center text-emerald-600 dark:text-emerald-400 mb-4">
<CheckCircle size={32} />
</div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">Task Updated!</h3>
<p className="text-zinc-500 dark:text-zinc-400 mt-2">Your changes have been saved successfully.</p>
</div>
</div>,
document.body
);
}
const getStatusColor = (status) => {
switch (status) {
case 'completed': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400';
case 'in_progress': return 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400';
default: return 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400';
}
};
return createPortal(
<div className="fixed top-0 left-0 w-screen h-[100dvh] z-[10000] flex items-end sm:items-center justify-center sm:p-6">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
<div className="relative w-full sm:max-w-lg h-[85dvh] sm:h-auto bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-0 sm:scale-95 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<div className="px-6 py-4 border-b border-zinc-200 dark:border-white/10 flex justify-between items-center bg-zinc-50/50 dark:bg-white/5">
<div>
<h2 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<CheckSquare className="text-blue-500" size={20} />
Task Details
</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400 font-mono mt-0.5">{task.id}</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
>
<X size={20} />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto flex-1 space-y-6">
{/* Task Info Card */}
<div className="p-5 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-100 dark:border-blue-500/20">
<h3 className="text-xl font-bold text-zinc-900 dark:text-white mb-3">{task.name}</h3>
<div className="space-y-3">
<div className="flex items-center text-sm text-zinc-600 dark:text-zinc-400">
<MapPin size={16} className="mr-2 text-blue-500" />
<span className="font-medium">{task.projectAddress}</span>
</div>
<div className="flex items-center text-sm text-zinc-600 dark:text-zinc-400">
<Calendar size={16} className="mr-2 text-blue-500" />
<span>Due: <span className="font-bold text-zinc-900 dark:text-white">{task.dueDate}</span></span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-zinc-600 dark:text-zinc-400">Status:</span>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide ${getStatusColor(task.status)}`}>
{task.status.replace('_', ' ')}
</span>
</div>
</div>
</div>
{/* Instructions */}
<div className="p-5 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<h4 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider mb-3 flex items-center gap-2">
<AlertCircle size={16} className="text-amber-500" />
Instructions
</h4>
<p className="text-sm text-zinc-600 dark:text-zinc-400 leading-relaxed">
Complete the assigned task according to project specifications. Ensure all safety protocols are followed and document progress with photos.
</p>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-2 gap-3">
<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">
<Clock size={24} className="mb-2" />
<span className="text-sm font-bold">Clock In/Out</span>
</button>
</div>
{/* Time Tracking (Mock) */}
<div className="p-5 rounded-xl bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/10">
<h4 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider mb-3">Time Tracking</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-zinc-500">Today:</span>
<span className="font-bold text-zinc-900 dark:text-white">4.5 hrs</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">This Week:</span>
<span className="font-bold text-zinc-900 dark:text-white">18 hrs</span>
</div>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="p-6 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex gap-4">
{task.status !== 'completed' ? (
<>
<button
className="flex-1 px-4 py-3 rounded-xl bg-zinc-100 dark:bg-white/10 text-zinc-700 dark:text-zinc-300 font-bold hover:bg-zinc-200 dark:hover:bg-white/20 transition-colors"
onClick={onClose}
>
Cancel
</button>
<button
className="flex-1 px-4 py-3 rounded-xl bg-emerald-500 text-white font-bold hover:bg-emerald-600 transition-colors shadow-lg shadow-emerald-500/20"
onClick={() => handleAction('complete')}
>
{isSubmitting ? 'Updating...' : 'Mark Complete'}
</button>
</>
) : (
<button
className="w-full px-4 py-3 rounded-xl bg-zinc-100 dark:bg-white/10 text-zinc-700 dark:text-zinc-300 font-bold hover:bg-zinc-200 dark:hover:bg-white/20 transition-colors"
onClick={onClose}
>
Close
</button>
)}
</div>
</div>
</div>,
document.body
);
};
export default TaskDetailsModal;
@@ -46,8 +46,8 @@ export const GoldenLeadsScatter = ({ properties }) => {
</div>
</div>
<div className="flex-1 w-full min-h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<div className="w-full">
<ResponsiveContainer width="100%" height={200}>
<ScatterChart
margin={{ top: 20, right: 20, bottom: 20, left: 0 }}
>
@@ -61,6 +61,19 @@ const IntelligenceSidePanel = ({ data, onClose, variant = 'default' }) => {
return () => document.removeEventListener('keydown', handleLightboxKeys);
}, [lightboxIndex, photos.length]);
// Lightbox Touch Swipe Navigation
const touchStartX = useRef(null);
const handleTouchStart = (e) => { touchStartX.current = e.touches[0].clientX; };
const handleTouchEnd = (e) => {
if (touchStartX.current === null) return;
const diff = touchStartX.current - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) {
if (diff > 0) setLightboxIndex((prev) => (prev + 1) % photos.length);
else setLightboxIndex((prev) => (prev - 1 + photos.length) % photos.length);
}
touchStartX.current = null;
};
if (!data) return null;
return (
@@ -303,6 +316,9 @@ const IntelligenceSidePanel = ({ data, onClose, variant = 'default' }) => {
ref={lightboxRef}
role="dialog"
aria-label="Photo Lightbox"
data-lenis-prevent
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Close Button */}
<button
@@ -313,41 +329,34 @@ const IntelligenceSidePanel = ({ data, onClose, variant = 'default' }) => {
<X size={24} />
</button>
{/* Navigation Left */}
<button
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev - 1 + photos.length) % photos.length); }}
className="absolute left-4 text-cyan-400/70 hover:text-cyan-400 p-3 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-cyan-400 hidden md:block"
aria-label="Previous photo"
>
<ChevronLeft size={32} />
</button>
{/* Main Image */}
<div className="relative max-w-5xl max-h-[85vh] w-full h-full flex flex-col items-center justify-center">
<div className="relative max-w-5xl max-h-[75vh] w-full h-full flex flex-col items-center justify-center">
<img
src={photos[lightboxIndex].url}
alt={photos[lightboxIndex].caption || "Full screen view"}
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl border border-white/10"
/>
<div className="absolute bottom-[-40px] text-cyan-400/50 text-xs uppercase tracking-widest font-mono">
IMG_SEQ: {lightboxIndex + 1} / {photos.length}
</div>
</div>
{/* Navigation Right */}
<button
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev + 1) % photos.length); }}
className="absolute right-4 text-cyan-400/70 hover:text-cyan-400 p-3 rounded-full hover:bg-white/10 transition-colors z-50 focus:ring-2 focus:ring-cyan-400 hidden md:block"
aria-label="Next photo"
>
<ChevronRight size={32} />
</button>
{/* Mobile Navigation Hints */}
<div className="md:hidden absolute bottom-8 flex space-x-8 text-cyan-400/30 text-xs pointer-events-none font-mono uppercase tracking-widest">
<span>&lt; PREV</span>
<span>NEXT &gt;</span>
{/* Navigation Bar — ← IMG_SEQ: 2/3 → */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-3 z-50 bg-black/60 rounded-full px-2 py-1.5 backdrop-blur-sm border border-white/5">
<button
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev - 1 + photos.length) % photos.length); }}
className="text-cyan-400/70 hover:text-cyan-400 p-2.5 rounded-full hover:bg-white/10 transition-colors active:bg-white/20"
aria-label="Previous photo"
>
<ChevronLeft size={22} />
</button>
<span className="text-cyan-400/50 text-xs uppercase tracking-widest font-mono select-none whitespace-nowrap px-1">
IMG_SEQ: {lightboxIndex + 1}/{photos.length}
</span>
<button
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev + 1) % photos.length); }}
className="text-cyan-400/70 hover:text-cyan-400 p-2.5 rounded-full hover:bg-white/10 transition-colors active:bg-white/20"
aria-label="Next photo"
>
<ChevronRight size={22} />
</button>
</div>
</div>
)}
@@ -43,8 +43,8 @@ export const OwnerIntentFunnel = ({ properties }) => {
<p className="text-xs text-zinc-500 dark:text-zinc-400">Conversion pipeline</p>
</div>
<div className="flex-1 w-full min-h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<div className="w-full">
<ResponsiveContainer width="100%" height={200}>
<BarChart
layout="vertical"
data={data}
@@ -46,8 +46,8 @@ export const RevenueByNeighborhood = ({ properties }) => {
<p className="text-xs text-zinc-500 dark:text-zinc-400">By Neighborhood Rating</p>
</div>
<div className="flex-1 w-full min-h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<div className="w-full">
<ResponsiveContainer width="100%" height={200}>
<BarChart
data={data}
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
@@ -63,8 +63,8 @@ export const RoofConditionChart = ({ properties }) => {
</div>
</div>
<div className="flex-1 w-full min-h-[250px] relative">
<ResponsiveContainer width="100%" height="100%">
<div className="w-full relative" style={{ height: 250 }}>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={data}
@@ -56,7 +56,7 @@ export const WeatherRiskGauge = ({ properties, weather }) => {
<div className="relative w-full h-[140px] flex items-end justify-center">
{/* Speedometer Chart */}
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height={140}>
<PieChart>
<Pie
data={data}
@@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { useMockStore } from '../../data/mockStore';
import { FileText, CheckCircle, XCircle, Clock, Eye, Download } from 'lucide-react';
import { toast } from 'sonner';
const DocumentReviewQueue = () => {
const { documents } = useMockStore();
const [filter, setFilter] = useState('pending'); // pending, approved, rejected, all
const filteredDocs = documents.filter(doc => {
if (filter === 'all') return true;
return doc.status === filter;
});
const handleApprove = (id) => {
toast.success(`Document #${id} Approved`);
// In a real app, this would update the store/backend
};
const handleReject = (id) => {
toast.error(`Document #${id} Rejected`);
// In a real app, this would update the store/backend
};
const getStatusColor = (status) => {
switch (status) {
case 'approved': return 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20';
case 'rejected': return 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20';
case 'pending': return 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20';
default: return 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/10 dark:text-zinc-400 dark:border-zinc-500/20';
}
};
return (
<div className="flex flex-col h-full">
<div className="flex flex-wrap gap-2 mb-6">
{['pending', 'approved', 'rejected', 'all'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-xl text-xs font-bold uppercase tracking-wider transition-all ${filter === f
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900 shadow-md transform scale-105'
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
}`}
>
{f}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-3 pr-2">
{filteredDocs.length === 0 ? (
<div className="p-12 text-center bg-zinc-50 dark:bg-white/5 rounded-2xl border border-zinc-200 dark:border-white/5 border-dashed flex flex-col items-center justify-center">
<div className="w-16 h-16 bg-zinc-100 dark:bg-white/5 rounded-full flex items-center justify-center mb-4 text-zinc-400">
<FileText size={32} />
</div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">No documents found</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">Try adjusting your filters or upload a new document.</p>
</div>
) : (
filteredDocs.map(doc => (
<div key={doc.id} className="group bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/5 rounded-xl p-5 hover:border-blue-500/30 transition-all shadow-sm">
<div className="flex flex-col md:flex-row justify-between gap-4">
<div className="flex items-start gap-4">
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 shrink-0">
<FileText size={24} />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-bold text-zinc-900 dark:text-white text-base">{doc.title}</h4>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase border ${getStatusColor(doc.status)}`}>
{doc.status}
</span>
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 space-y-1">
<p className="flex items-center gap-2">
<span className="font-semibold text-zinc-700 dark:text-zinc-300">{doc.category}</span>
<span className="w-1 h-1 rounded-full bg-zinc-300 dark:bg-zinc-600"></span>
ID: <span className="font-mono">{doc.id}</span>
</p>
<p>Submitted by: <span className="font-bold text-zinc-700 dark:text-zinc-300">Vendor #{doc.relatedEntityId}</span> on {new Date(doc.uploadDate).toLocaleDateString()}</p>
{doc.expirationDate && (
<p className="flex items-center gap-1 text-amber-600 dark:text-amber-400 font-medium pt-1">
<Clock size={12} /> Expires: {new Date(doc.expirationDate).toLocaleDateString()}
</p>
)}
</div>
</div>
</div>
<div className="flex flex-row md:flex-col justify-center items-end gap-2 shrink-0">
<div className="flex gap-2">
<button className="p-2 rounded-lg bg-zinc-100 hover:bg-zinc-200 dark:bg-white/5 dark:hover:bg-white/10 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white transition-colors" title="View">
<Eye size={18} />
</button>
<button className="p-2 rounded-lg bg-zinc-100 hover:bg-zinc-200 dark:bg-white/5 dark:hover:bg-white/10 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white transition-colors" title="Download">
<Download size={18} />
</button>
</div>
{doc.status === 'pending' && (
<div className="flex gap-2 mt-2 w-full md:w-auto">
<button
onClick={() => handleReject(doc.id)}
className="flex-1 md:flex-none px-3 py-2 rounded-lg bg-red-50 hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-500/20 text-xs font-bold uppercase tracking-wide flex items-center justify-center gap-1 transition-colors"
>
<XCircle size={14} /> Reject
</button>
<button
onClick={() => handleApprove(doc.id)}
className="flex-1 md:flex-none px-3 py-2 rounded-lg bg-emerald-50 hover:bg-emerald-100 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-500/20 text-xs font-bold uppercase tracking-wide flex items-center justify-center gap-1 transition-colors"
>
<CheckCircle size={14} /> Approve
</button>
</div>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
);
};
export default DocumentReviewQueue;
+218
View File
@@ -0,0 +1,218 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, CheckCircle, Clock, AlertTriangle, FileText, ChevronRight, Filter } from 'lucide-react';
import { useMockStore } from '../../data/mockStore';
import ActionDetailsModal from './ActionDetailsModal';
const ActionCenterModal = ({ isOpen, onClose, defaultFilter = 'all' }) => {
const { documents, vendors, projects } = useMockStore();
const [activeTab, setActiveTab] = useState(defaultFilter);
const [selectedItem, setSelectedItem] = useState(null);
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
// --- Data Aggregation ---
const getItems = () => {
const allItems = [];
// 1. Expiring Documents
documents.forEach(d => {
const expDate = new Date(d.expirationDate);
const today = new Date();
const diffDays = Math.ceil(Math.abs(expDate - today) / (1000 * 60 * 60 * 24));
if (diffDays <= 30 && d.status !== 'expired') {
allItems.push({
id: d.id,
type: 'document',
title: `Expiring: ${d.name}`,
subtitle: vendors.find(v => v.id === d.vendorId)?.vendorName || 'Unknown Vendor',
priority: diffDays < 7 ? 'high' : 'medium',
date: d.expirationDate,
actionLabel: 'Request Renewal'
});
}
});
// 2. Non-Compliant Vendors
vendors.forEach(v => {
if (v.compliance.w9.status !== 'approved' || v.compliance.coi.status === 'expired') {
allItems.push({
id: `ven-comp-${v.id}`,
type: 'vendor',
title: `Compliance Issue: ${v.vendorName}`,
subtitle: 'Missing valid W9 or COI',
priority: 'high',
date: 'Immediate',
actionLabel: 'Review Vendor'
});
}
});
// 3. Pending Invoices
projects.forEach(p => {
(p.invoices || []).forEach(i => {
if (i.status === 'pending') {
allItems.push({
id: i.id,
type: 'invoice',
title: `Pending Invoice #${i.id}`,
subtitle: `${p.address} - $${i.amount.toLocaleString()}`,
priority: 'medium',
date: i.dueDate,
actionLabel: 'Approve / Reject'
});
}
});
});
return allItems;
};
const items = getItems();
const filteredItems = activeTab === 'all'
? items
: items.filter(i => {
if (activeTab === 'docs') return i.type === 'document';
if (activeTab === 'vendors') return i.type === 'vendor';
if (activeTab === 'invoices') return i.type === 'invoice';
return true;
});
const getPriorityColor = (priority) => {
return priority === 'high'
? 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-500/10'
: 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10';
};
const getIcon = (type) => {
switch (type) {
case 'document': return <FileText size={18} />;
case 'vendor': return <AlertTriangle size={18} />;
case 'invoice': return <Clock size={18} />;
default: return <CheckCircle size={18} />;
}
};
return createPortal(
<div className="fixed top-0 left-0 w-screen h-[100dvh] z-[9999] flex items-end sm:items-center justify-center sm:p-6" role="dialog" aria-modal="true" aria-labelledby="action-center-title">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative w-full sm:max-w-2xl h-[85dvh] sm:h-auto sm:max-h-[85vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-4 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<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">
<div>
<h2 id="action-center-title" className="text-xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<AlertTriangle className="text-amber-500" />
Action Center
</h2>
<p className="text-zinc-500 dark:text-zinc-400 text-sm mt-1">
{items.length} items requiring attention
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
aria-label="Close modal"
>
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="px-6 py-2 border-b border-zinc-200 dark:border-white/10 bg-white dark:bg-[#121214] flex gap-2 overflow-x-auto">
{[
{ id: 'all', label: 'All Items' },
{ id: 'docs', label: 'Documents' },
{ id: 'vendors', label: 'Compliance' },
{ id: 'invoices', label: 'Invoices' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 rounded-full text-xs font-bold uppercase tracking-wider whitespace-nowrap transition-all ${activeTab === tab.id
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900 shadow-md'
: 'bg-transparent text-zinc-500 hover:bg-zinc-100 dark:hover:bg-white/5'
}`}
>
{tab.label}
</button>
))}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-3 bg-zinc-50 dark:bg-[#09090b]">
{filteredItems.length > 0 ? filteredItems.map((item, idx) => (
<div key={idx} className="group bg-white dark:bg-[#121214] p-4 rounded-xl border border-zinc-200 dark:border-white/5 hover:border-blue-500/30 shadow-sm transition-all flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl shrink-0 ${item.type === 'document' ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400' :
item.type === 'vendor' ? 'bg-red-100 text-red-600 dark:bg-red-500/10 dark:text-red-400' :
'bg-amber-100 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400'
}`}>
{getIcon(item.type)}
</div>
<div>
<h4 className="font-bold text-zinc-900 dark:text-white text-sm">{item.title}</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{item.subtitle}</p>
<div className="flex items-center gap-2 mt-2">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide ${getPriorityColor(item.priority)}`}>
{item.priority} Priority
</span>
<span className="text-[10px] text-zinc-400 font-mono">
Due: {item.date}
</span>
</div>
</div>
</div>
<button
onClick={() => setSelectedItem(item)}
className="w-full sm:w-auto px-4 py-2 bg-zinc-100 dark:bg-white/5 text-zinc-700 dark:text-zinc-300 hover:bg-blue-600 hover:text-white dark:hover:bg-blue-600 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors flex items-center justify-center gap-2"
>
{item.actionLabel}
<ChevronRight size={14} />
</button>
</div>
)) : (
<div className="flex flex-col items-center justify-center py-12 text-zinc-400">
<CheckCircle size={48} className="mb-4 opacity-20" />
<p>No actionable items found.</p>
</div>
)}
</div>
</div>
{/* Nested Detail Modal */}
<ActionDetailsModal
isOpen={!!selectedItem}
onClose={() => setSelectedItem(null)}
item={selectedItem}
/>
</div>,
document.body
);
};
export default ActionCenterModal;
+270
View File
@@ -0,0 +1,270 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, CheckCircle, AlertTriangle, FileText, Download, DollarSign, Calendar, Building, Mail, Phone, ExternalLink } from 'lucide-react';
const ActionDetailsModal = ({ isOpen, onClose, item }) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !item) return null;
const handleAction = (actionType) => {
setIsSubmitting(true);
// Mock API call
setTimeout(() => {
setIsSubmitting(false);
setIsSuccess(true);
// Auto close after success
setTimeout(() => {
setIsSuccess(false);
onClose();
}, 1500);
}, 1000);
};
if (isSuccess) {
return createPortal(
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative bg-white dark:bg-[#121214] rounded-2xl p-8 flex flex-col items-center animate-in zoom-in-95 duration-200">
<div className="w-16 h-16 bg-emerald-100 dark:bg-emerald-500/20 rounded-full flex items-center justify-center text-emerald-600 dark:text-emerald-400 mb-4">
<CheckCircle size={32} />
</div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">Action Completed!</h3>
<p className="text-zinc-500 dark:text-zinc-400 mt-2">The item has been successfully updated.</p>
</div>
</div>,
document.body
);
}
const renderContent = () => {
switch (item.type) {
case 'invoice':
return (
<div className="space-y-6">
{/* Invoice Header Details */}
<div className="grid grid-cols-2 gap-4 p-4 bg-zinc-50 dark:bg-white/5 rounded-xl border border-zinc-200 dark:border-white/5">
<div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 uppercase font-bold">Project</p>
<p className="font-semibold text-zinc-900 dark:text-white">{item.subtitle.split(' - ')[0]}</p>
</div>
<div className="text-right">
<p className="text-xs text-zinc-500 dark:text-zinc-400 uppercase font-bold">Amount</p>
<p className="text-xl font-mono font-bold text-zinc-900 dark:text-white">{item.subtitle.split(' - ')[1]}</p>
</div>
<div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 uppercase font-bold">Invoice Date</p>
<p className="text-zinc-700 dark:text-zinc-300">Oct 24, 2024</p>
</div>
<div className="text-right">
<p className="text-xs text-zinc-500 dark:text-zinc-400 uppercase font-bold">Due Date</p>
<p className="text-red-500 font-bold">{item.date}</p>
</div>
</div>
{/* Line Items (Mock) */}
<div>
<h4 className="text-sm font-bold text-zinc-900 dark:text-white mb-2">Line Items</h4>
<div className="border border-zinc-200 dark:border-white/10 rounded-xl overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="bg-zinc-50 dark:bg-white/5 border-b border-zinc-200 dark:border-white/10">
<tr>
<th className="px-4 py-2 font-medium text-zinc-500">Description</th>
<th className="px-4 py-2 font-medium text-zinc-500 text-right">Qty</th>
<th className="px-4 py-2 font-medium text-zinc-500 text-right">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-white/5">
<tr>
<td className="px-4 py-2 text-zinc-700 dark:text-zinc-300">Framing Materials - Lumber</td>
<td className="px-4 py-2 text-right text-zinc-500">120</td>
<td className="px-4 py-2 text-right font-mono text-zinc-900 dark:text-white">$4,200.00</td>
</tr>
<tr>
<td className="px-4 py-2 text-zinc-700 dark:text-zinc-300">Labor - Week 3</td>
<td className="px-4 py-2 text-right text-zinc-500">40 hrs</td>
<td className="px-4 py-2 text-right font-mono text-zinc-900 dark:text-white">$2,800.00</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* PDF Preview Placehoder */}
<div className="h-32 bg-zinc-100 dark:bg-zinc-800 rounded-xl border-dashed border-2 border-zinc-300 dark:border-zinc-700 flex items-center justify-center flex-col gap-2 relative group cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors">
<FileText className="text-zinc-400" size={32} />
<p className="text-sm text-zinc-500 font-medium">Click to Preview Invoice PDF</p>
</div>
</div>
);
case 'vendor':
return (
<div className="space-y-6">
<div className="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-white/5 rounded-xl border border-zinc-200 dark:border-white/5">
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-500/20 rounded-full flex items-center justify-center text-amber-600 dark:text-amber-400">
<Building size={24} />
</div>
<div>
<h3 className="font-bold text-lg text-zinc-900 dark:text-white">{item.title.replace('Compliance Issue: ', '')}</h3>
<div className="flex gap-4 mt-1 text-sm text-zinc-500 dark:text-zinc-400">
<span className="flex items-center gap-1"><Mail size={14} /> contact@vendor.com</span>
<span className="flex items-center gap-1"><Phone size={14} /> (555) 123-4567</span>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-bold text-zinc-900 dark:text-white mb-3">Compliance Checklist</h4>
<div className="space-y-2">
<div className="flex items-center justify-between p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20">
<div className="flex items-center gap-3">
<AlertTriangle size={18} className="text-red-500" />
<span className="font-medium text-red-700 dark:text-red-400">W-9 Form</span>
</div>
<span className="text-xs font-bold uppercase text-red-600 dark:text-red-400 bg-white dark:bg-black/20 px-2 py-1 rounded">Missing</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20">
<div className="flex items-center gap-3">
<CheckCircle size={18} className="text-emerald-500" />
<span className="font-medium text-emerald-700 dark:text-emerald-400">Insurance (COI)</span>
</div>
<span className="text-xs font-bold uppercase text-emerald-600 dark:text-emerald-400 bg-white dark:bg-black/20 px-2 py-1 rounded">Valid</span>
</div>
</div>
</div>
</div>
);
case 'document':
return (
<div className="space-y-6">
<div className="p-4 bg-blue-50 dark:bg-blue-500/5 rounded-xl border border-blue-100 dark:border-blue-500/20 text-center">
<FileText size={48} className="mx-auto text-blue-500 mb-3" />
<h3 className="font-bold text-lg text-zinc-900 dark:text-white">{item.title.replace('Expiring: ', '')}</h3>
<p className="text-blue-600 dark:text-blue-400 font-medium mt-1">Expiring on {item.date}</p>
<p className="text-sm text-zinc-500 mt-4">This document is required for the vendor to continue active work.</p>
</div>
<div className="flex items-center gap-2 p-3 bg-zinc-100 dark:bg-white/5 rounded-lg text-sm text-zinc-600 dark:text-zinc-400">
<ExternalLink size={16} />
<span className="truncate">https://storage.lynkedup.com/docs/compliance/{item.id}.pdf</span>
</div>
</div>
);
default:
return null;
}
};
const renderActions = () => {
switch (item.type) {
case 'invoice':
return (
<>
<button
className="flex-1 px-4 py-3 rounded-xl bg-zinc-100 dark:bg-white/10 text-zinc-700 dark:text-zinc-300 font-bold hover:bg-zinc-200 dark:hover:bg-white/20 transition-colors"
onClick={() => handleAction('reject')}
>
Reject
</button>
<button
className="flex-1 px-4 py-3 rounded-xl bg-emerald-500 text-white font-bold hover:bg-emerald-600 transition-colors shadow-lg shadow-emerald-500/20"
onClick={() => handleAction('approve')}
>
{isSubmitting ? 'Processing...' : 'Approve Invoice'}
</button>
</>
);
case 'vendor':
return (
<>
<button
className="flex-1 px-4 py-3 rounded-xl bg-zinc-100 dark:bg-white/10 text-zinc-700 dark:text-zinc-300 font-bold hover:bg-zinc-200 dark:hover:bg-white/20 transition-colors"
onClick={() => handleAction('deactivate')}
>
Deactivate Vendor
</button>
<button
className="flex-1 px-4 py-3 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 transition-colors shadow-lg shadow-blue-500/20"
onClick={() => handleAction('remind')}
>
{isSubmitting ? 'Sending...' : 'Send Reminder'}
</button>
</>
);
case 'document':
return (
<button
className="w-full px-4 py-3 rounded-xl bg-amber-500 text-black font-bold hover:bg-amber-600 transition-colors shadow-lg shadow-amber-500/20"
onClick={() => handleAction('renew')}
>
{isSubmitting ? 'Requesting...' : 'Request Renewal'}
</button>
);
default:
return null;
}
}
return (
<div className="fixed top-0 left-0 w-screen h-[100dvh] z-[10000] flex items-end sm:items-center justify-center sm:p-6">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
<div className="relative w-full sm:max-w-lg h-[85dvh] sm:h-auto bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-0 sm:scale-95 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<div className="px-6 py-4 border-b border-zinc-200 dark:border-white/10 flex justify-between items-center bg-zinc-50/50 dark:bg-white/5">
<div>
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">
{item.type === 'invoice' && 'Review Invoice'}
{item.type === 'vendor' && 'Vendor Compliance'}
{item.type === 'document' && 'Document Actions'}
</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400 font-mono mt-0.5">{item.id}</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
>
<X size={20} />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto max-h-[60vh]">
{renderContent()}
</div>
{/* Footer Actions */}
<div className="p-6 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex gap-4">
{renderActions()}
</div>
</div>
</div>,
document.body
);
};
export default ActionDetailsModal;
@@ -0,0 +1,274 @@
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { X, ArrowUpRight, ArrowDownRight, Filter, Download, Search, CheckCircle, Clock, AlertCircle } from 'lucide-react';
import { useMockStore } from '../../data/mockStore';
const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
const { projects, vendors } = useMockStore();
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState({ key: 'amount', direction: 'desc' });
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// --- Data Derivation Logic ---
const data = useMemo(() => {
let items = [];
let summary = { total: 0, label: '' };
switch (type) {
case 'revenue':
summary.label = 'Total Revenue';
// Get all active/completed projects
items = projects
.filter(p => p.status === 'active' || p.status === 'completed')
.map(p => ({
id: p.id,
name: p.address, // Project Name
entity: 'Client',
date: p.startDate,
status: p.status,
amount: p.budget,
type: 'Project Budget'
}));
break;
case 'ar':
summary.label = 'Outstanding AR';
// Mock invoices from projects
items = projects.flatMap(p =>
(p.invoices || []).filter(i => i.status === 'pending').map(i => ({
id: i.id,
name: p.address,
entity: 'Client',
date: i.dueDate,
status: 'Overdue', // Mock status
amount: i.amount,
type: 'Invoice'
}))
);
break;
case 'payouts':
summary.label = 'Pending Payouts';
// Mock vendor bills from project milestones
items = projects.flatMap(p =>
(p.milestones || []).filter(m => m.status === 'completed').map(m => ({
id: `BILL-${m.id}`,
name: vendors.find(v => v.id === m.assignedTo)?.vendorName || 'Unknown Vendor',
entity: 'Vendor',
date: m.dueDate,
status: 'Pending Approval',
amount: p.budget * 0.05, // Mock 5% per milestone
type: 'Milestone Payment'
}))
);
break;
case 'spend':
summary.label = 'YTD Vendor Spend';
// Mock spend from vendors
items = vendors.map(v => ({
id: v.id,
name: v.vendorName,
entity: v.type,
date: '2024-YTD',
status: 'Paid',
amount: v.spend?.totalSpend || 0,
type: 'Total Spend'
})).filter(i => i.amount > 0);
break;
default:
break;
}
summary.total = items.reduce((sum, item) => sum + item.amount, 0);
return { items, summary };
}, [type, projects, vendors]);
// --- Filtering & Sorting ---
const filteredItems = data.items.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.id.toLowerCase().includes(searchTerm.toLowerCase())
);
const sortedItems = [...filteredItems].sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
};
const getStatusColor = (status) => {
const s = status.toLowerCase();
if (s.includes('paid') || s.includes('active') || s.includes('completed')) return 'text-emerald-500 bg-emerald-100 dark:bg-emerald-500/10';
if (s.includes('pending')) return 'text-amber-500 bg-amber-100 dark:bg-amber-500/10';
if (s.includes('overdue')) return 'text-red-500 bg-red-100 dark:bg-red-500/10';
return 'text-zinc-500 bg-zinc-100 dark:bg-zinc-800';
};
if (!isOpen) return null;
return createPortal(
<div className="fixed top-0 left-0 w-screen h-[100dvh] z-[9999] flex items-end sm:items-center justify-center sm:p-6" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative w-full sm:max-w-5xl h-[85dvh] sm:h-auto sm:max-h-[85vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-10 sm:zoom-in-95 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<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">
<div>
<h2 id="modal-title" className="text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
{type === 'revenue' && <ArrowUpRight className="text-emerald-500" />}
{type === 'ar' && <Clock className="text-blue-500" />}
{type === 'payouts' && <AlertCircle className="text-amber-500" />}
{type === 'spend' && <ArrowDownRight className="text-purple-500" />}
{data.summary.label} Details
</h2>
<p className="text-zinc-500 dark:text-zinc-400 text-sm mt-1">
Total: <span className="font-mono font-bold text-zinc-900 dark:text-white ml-1">{formatCurrency(data.summary.total)}</span>
</p>
</div>
<div className="flex items-center gap-3">
<button className="p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors" title="Export CSV" aria-label="Export as CSV">
<Download size={20} />
</button>
<button
onClick={onClose}
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
aria-label="Close modal"
>
<X size={20} />
</button>
</div>
</div>
{/* Toolbar */}
<div className="px-6 py-4 border-b border-zinc-200 dark:border-white/5 flex flex-col sm:flex-row gap-4 justify-between bg-white dark:bg-[#121214]">
<div className="relative w-full sm:w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
<input
type="text"
placeholder="Search by name, ID..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
<div className="flex gap-2">
<button className="flex items-center gap-2 px-4 py-2 rounded-xl border border-zinc-200 dark:border-white/10 text-sm font-medium hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
<Filter size={16} /> Filter
</button>
</div>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-[#121214]">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 z-10 bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10 shadow-sm">
<tr>
{[
{ key: 'name', label: 'Name / Project' },
{ key: 'id', label: 'Reference ID' },
{ key: 'date', label: 'Date' },
{ key: 'type', label: 'Type' },
{ key: 'status', label: 'Status' },
{ key: 'amount', label: 'Amount', align: 'right' }
].map((col) => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className={`px-6 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors ${col.align === 'right' ? 'text-right' : ''}`}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
{sortedItems.length > 0 ? sortedItems.map((item, idx) => (
<tr key={idx} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors group">
<td className="px-6 py-4">
<div className="font-semibold text-zinc-900 dark:text-white">{item.name}</div>
<div className="text-xs text-zinc-500">{item.entity}</div>
</td>
<td className="px-6 py-4 font-mono text-xs text-zinc-500">
{item.id}
</td>
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-400">
{item.date}
</td>
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-400">
{item.type}
</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide border border-transparent ${getStatusColor(item.status)}`}>
{item.status}
</span>
</td>
<td className="px-6 py-4 text-right font-mono font-medium text-zinc-900 dark:text-white">
{formatCurrency(item.amount)}
</td>
</tr>
)) : (
<tr>
<td colSpan="6" className="py-20 text-center text-zinc-500">
No records found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Footer Summary */}
<div className="px-6 py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-between items-center text-sm">
<span className="text-zinc-500 dark:text-zinc-400">Showing {sortedItems.length} records</span>
<div className="flex items-center gap-2">
<span className="text-zinc-500 dark:text-zinc-400">Net Total:</span>
<span className="text-lg font-bold text-zinc-900 dark:text-white">{formatCurrency(data.summary.total)}</span>
</div>
</div>
</div>
</div>,
document.body
);
};
export default FinancialDetailsModal;
+124
View File
@@ -0,0 +1,124 @@
import React, { useMemo } from 'react';
import { useMockStore } from '../../data/mockStore';
import { DollarSign, TrendingUp, TrendingDown, CreditCard } from 'lucide-react';
import { SpotlightCard } from '../SpotlightCard';
const FinancialKPICards = ({ onCardClick }) => {
const { projects, vendors } = useMockStore();
// Removed direct navigate, now delegates to parent
// Calculate KPIs derived from mock data
const metrics = useMemo(() => {
let totalRevenue = 0; // Derived from active project budgets (simplified for demo)
let outstandingAR = 0; // Invoices sent but not paid
let pendingPayouts = 0; // Vendor invoices pending
let vendorBillsDue = 0;
projects.forEach(project => {
if (project.status === 'active' || project.status === 'completed') {
totalRevenue += project.budget;
}
if (project.invoices) {
project.invoices.forEach(inv => {
if (inv.status === 'pending') {
pendingPayouts += inv.amount;
}
});
}
});
const totalVendorSpend = vendors.reduce((acc, v) => acc + (v.spend?.totalSpend || 0), 0);
return {
totalRevenue,
outstandingAR: totalRevenue * 0.3, // Mocking AR as 30% of revenue for demo
pendingPayouts,
totalVendorSpend
};
}, [projects, vendors]);
const cards = [
{
id: 'revenue',
title: 'Revenue This Month',
value: `$${(metrics.totalRevenue / 1000).toFixed(1)}k`,
change: '+12%',
trend: 'up',
icon: DollarSign,
color: 'text-emerald-400',
bg: 'bg-emerald-400/10',
border: 'border-emerald-400/20'
},
{
id: 'ar',
title: 'Outstanding AR',
value: `$${(metrics.outstandingAR / 1000).toFixed(1)}k`,
change: '5 Pending',
trend: 'neutral',
icon: TrendingUp, // Invoicing icon
color: 'text-blue-400',
bg: 'bg-blue-400/10',
border: 'border-blue-400/20'
},
{
id: 'payouts',
title: 'Pending Payouts',
value: `$${(metrics.pendingPayouts / 1000).toFixed(1)}k`,
change: 'Due in 7 days',
trend: 'down', // Money going out
icon: CreditCard,
color: 'text-amber-400',
bg: 'bg-amber-400/10',
border: 'border-amber-400/20'
},
{
id: 'spend',
title: 'YTD Vendor Spend',
value: `$${(metrics.totalVendorSpend / 1000).toFixed(1)}k`,
change: '+8% vs LY',
trend: 'up', // Spending more
icon: TrendingDown,
color: 'text-purple-400',
bg: 'bg-purple-400/10',
border: 'border-purple-400/20'
}
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{cards.map((card, index) => (
<SpotlightCard
key={index}
className="p-6 group h-full cursor-pointer hover:border-zinc-300 dark:hover:border-white/20 transition-all"
onClick={() => onCardClick && onCardClick(card.id)}
>
<div className="flex justify-between items-start mb-4">
<div className={`p-3 rounded-2xl bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-inner border border-zinc-200 dark:border-white/5 group-hover:scale-110 transition-transform duration-300`}>
<card.icon size={20} />
</div>
<span className={`text-[10px] uppercase font-bold tracking-wider px-2.5 py-1 rounded-full border border-zinc-200 dark:border-white/5 backdrop-blur-md ${card.trend === 'up' ? 'text-emerald-600 dark:text-emerald-300 bg-emerald-100 dark:bg-emerald-500/10' :
card.trend === 'down' ? 'text-orange-600 dark:text-orange-300 bg-orange-100 dark:bg-orange-500/10' :
'text-blue-600 dark:text-blue-300 bg-blue-100 dark:bg-blue-500/10'
}`}>
{card.change}
</span>
</div>
<div>
<h3 className="text-3xl font-extrabold text-zinc-900 dark:text-white mb-1 tracking-tight drop-shadow-sm">
{card.value}
</h3>
<p className="text-zinc-500 dark:text-zinc-500 text-xs font-semibold uppercase tracking-widest bg-zinc-100 dark:bg-white/5 inline-block px-2 py-0.5 rounded-md border border-zinc-200 dark:border-white/5">
{card.title}
</p>
</div>
{/* Decorative Neomorphic Inner Shadow */}
<div className="absolute -bottom-10 -right-10 w-24 h-24 bg-zinc-200 dark:bg-white/5 blur-2xl rounded-full pointer-events-none group-hover:bg-zinc-300 dark:group-hover:bg-white/10 transition-colors" />
</SpotlightCard>
))}
</div>
);
};
export default FinancialKPICards;
+139
View File
@@ -0,0 +1,139 @@
import React from 'react';
import { AlertCircle, Clock, FileWarning, Wallet, CheckCircle, AlertTriangle, ChevronRight } from 'lucide-react';
import { SpotlightCard } from '../SpotlightCard';
import { useMockStore } from '../../data/mockStore';
const UrgentItemsPanel = ({ onActionClick }) => {
const { documents, vendors, projects } = useMockStore();
// Removed direct navigate
// Logic to find urgent items
const expiringDocs = documents.filter(d => {
if (!d.expirationDate) return false;
const expDate = new Date(d.expirationDate);
const today = new Date();
const diffTime = Math.abs(expDate - today);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays <= 30 && d.status !== 'expired';
});
const nonCompliantVendors = vendors.filter(v =>
v.compliance.w9.status !== 'approved' ||
v.compliance.coi.status === 'expired'
);
const pendingInvoices = projects.flatMap(p =>
(p.invoices || []).filter(i => i.status === 'pending')
);
const urgentItems = [
{
id: 'docs',
title: 'Expiring Documents',
count: expiringDocs.length,
icon: Clock,
color: 'text-amber-400',
bg: 'bg-amber-400/10',
border: 'border-amber-400/20',
message: `${expiringDocs.length} compliance documents expiring soon.`,
},
{
id: 'vendors',
title: 'Non-Compliant Vendors',
count: nonCompliantVendors.length,
icon: FileWarning,
color: 'text-red-400',
bg: 'bg-red-400/10',
border: 'border-red-400/20',
message: `${nonCompliantVendors.length} vendors missing critical paperwork.`,
},
{
id: 'invoices',
title: 'Pending Invoices',
count: pendingInvoices.length,
icon: Wallet,
color: 'text-blue-400',
bg: 'bg-blue-400/10',
border: 'border-blue-400/20',
message: `${pendingInvoices.length} invoices awaiting approval.`,
}
];
if (urgentItems.every(item => item.count === 0)) {
return (
<SpotlightCard className="p-8 text-center border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50/50 dark:bg-white/5 h-full flex flex-col justify-center items-center">
<div className="p-4 bg-emerald-100 dark:bg-emerald-500/20 rounded-full text-emerald-600 dark:text-emerald-400 ring-4 ring-emerald-50 dark:ring-emerald-500/10 mb-4">
<CheckCircle size={32} />
</div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">All Caught Up!</h3>
<p className="text-zinc-500 dark:text-zinc-400 text-sm mt-1">No urgent items requiring attention right now.</p>
</SpotlightCard>
);
}
return (
<SpotlightCard className="h-full flex flex-col">
<div className="p-6 border-b border-zinc-200 dark:border-white/5 flex justify-between items-center bg-zinc-50/50 dark:bg-white/5">
<div className="flex items-center gap-3">
<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>
<div>
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Action Center</h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400">Items requiring your immediate attention</p>
</div>
</div>
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-500/20">
{urgentItems.reduce((acc, item) => acc + item.count, 0)} Pending
</span>
</div>
<div className="p-6 space-y-4 flex-1 overflow-y-auto custom-scrollbar">
{urgentItems.map((item) => (
item.count > 0 && (
<div key={item.id} className="group flex flex-col sm:flex-row sm:items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-black/20 border border-zinc-200 dark:border-white/5 hover:border-amber-500/30 hover:bg-zinc-100 dark:hover:bg-white/5 transition-all duration-300">
<div className="flex items-start gap-4 mb-3 sm:mb-0">
<div className={`p-3 rounded-xl shrink-0 ${item.id === 'docs' ? 'bg-blue-100/50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400' :
item.id === 'vendors' ? 'bg-red-100/50 text-red-600 dark:bg-red-500/10 dark:text-red-400' :
'bg-amber-100/50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400'
}`}>
<item.icon size={20} />
</div>
<div>
<h3 className="font-bold text-zinc-900 dark:text-white text-sm flex items-center gap-2">
{item.title}
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-zinc-200 dark:bg-white/10 text-zinc-600 dark:text-zinc-300 font-mono">
{item.count}
</span>
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1 max-w-xs leading-relaxed">
{item.message}
</p>
</div>
</div>
<button
onClick={() => onActionClick && onActionClick(item.id)}
className="whitespace-nowrap px-4 py-2 text-xs font-bold uppercase tracking-wider rounded-lg bg-white dark:bg-white/5 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-white/10 hover:bg-zinc-50 dark:hover:bg-white/10 hover:text-amber-600 dark:hover:text-amber-400 hover:border-amber-200 dark:hover:border-amber-500/30 transition-all shadow-sm"
>
Review Items
</button>
</div>
)
))}
</div>
{/* Footer Action */}
<div className="p-4 border-t border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
<button
onClick={() => onActionClick && onActionClick('all')}
className="w-full py-2.5 rounded-xl text-xs font-bold uppercase tracking-widest text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white transition-colors flex items-center justify-center gap-2 hover:bg-zinc-100 dark:hover:bg-white/5"
>
View All Compliance Tasks <ChevronRight size={14} />
</button>
</div>
</SpotlightCard>
);
};
export default UrgentItemsPanel;
@@ -0,0 +1,83 @@
import React from 'react';
import { CheckCircle, AlertTriangle, XCircle, Clock } from 'lucide-react';
const StatusIcon = ({ status }) => {
switch (status) {
case 'compliant':
case 'approved':
case 'active':
return <CheckCircle size={16} className="text-emerald-500" />;
case 'expiring':
case 'pending':
case 'pending_review':
return <Clock size={16} className="text-amber-500" />;
case 'expired':
case 'suspended':
case 'rejected':
return <XCircle size={16} className="text-red-500" />;
case 'missing':
case 'incomplete':
return <AlertTriangle size={16} className="text-red-500" />;
default:
return <div className="w-4 h-4 rounded-full bg-zinc-600" />;
}
};
const ComplianceItem = ({ label, status, date }) => (
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5 last:border-0">
<div className="flex items-center space-x-2">
<StatusIcon status={status} />
<span className="text-sm text-zinc-700 dark:text-zinc-300">{label}</span>
</div>
<div className="text-right">
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded-md border ${status === 'compliant' || status === 'approved'
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
: status === 'expiring'
? 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-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'
}`}>
{status?.replace('_', ' ')}
</span>
{date && <p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-0.5">Expires: {new Date(date).toLocaleDateString()}</p>}
</div>
</div>
);
const ComplianceChecklist = ({ data, type = 'personnel' }) => {
if (!data || !data.compliance) return <div className="text-zinc-500 dark:text-zinc-400 italic text-sm">No compliance data found.</div>;
const { compliance } = data;
return (
<div className="bg-zinc-50 dark:bg-white/5 rounded-xl border border-zinc-200 dark:border-white/5 p-4">
<h4 className="text-xs font-bold text-zinc-900 dark:text-white mb-3 uppercase tracking-wider">Compliance Status</h4>
<div className="space-y-1">
{type === 'personnel' && (
<>
<ComplianceItem label="W-9 Signed" status={compliance.w9Signed ? 'compliant' : 'missing'} />
<ComplianceItem label="Pay Plan Signed" status={compliance.payPlanSigned ? 'compliant' : 'missing'} />
<ComplianceItem
label="Driver's License"
status={compliance.driverLicense?.status}
date={compliance.driverLicense?.expirationDate}
/>
</>
)}
{type === 'vendor' && (
<>
<ComplianceItem label="W-9" status={compliance.w9?.status} />
<ComplianceItem label="Subcontractor Agreement" status={compliance.subcontractorAgreement?.status} />
<ComplianceItem
label="Insurance (COI)"
status={compliance.coi?.status}
date={compliance.coi?.expirationDate}
/>
</>
)}
</div>
</div>
);
};
export default ComplianceChecklist;
+222
View File
@@ -0,0 +1,222 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { X, Shield, FileText, CheckCircle, AlertCircle, Clock, Upload, Calendar } from 'lucide-react';
const ComplianceDetailsModal = ({ isOpen, onClose, vendorData }) => {
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !vendorData) return null;
const compliance = vendorData.compliance || {};
const getDocumentStatus = (doc) => {
if (!doc || !doc.uploaded) return 'missing';
if (doc.status === 'expired') return 'expired';
if (doc.status === 'approved' || doc.status === 'compliant') return 'compliant';
return 'pending';
};
const getStatusColor = (status) => {
switch (status) {
case 'compliant':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20';
case 'pending':
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400 border-amber-200 dark:border-amber-500/20';
case 'expired':
case 'missing':
return 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400 border-red-200 dark:border-red-500/20';
default:
return 'bg-zinc-100 text-zinc-700 dark:bg-white/10 dark:text-zinc-400 border-zinc-200 dark:border-white/10';
}
};
const getStatusIcon = (status) => {
switch (status) {
case 'compliant':
return <CheckCircle size={20} className="text-emerald-500" />;
case 'pending':
return <Clock size={20} className="text-amber-500" />;
case 'expired':
case 'missing':
return <AlertCircle size={20} className="text-red-500" />;
default:
return <FileText size={20} className="text-zinc-400" />;
}
};
const documents = [
{
id: 'w9',
name: 'W-9 Form',
description: 'Tax identification form',
data: compliance.w9,
required: true
},
{
id: 'coi',
name: 'Certificate of Insurance',
description: 'General liability and workers comp',
data: compliance.coi,
required: true,
expirationDate: compliance.coi?.expirationDate
},
{
id: 'agreement',
name: 'Subcontractor Agreement',
description: 'Signed service agreement',
data: compliance.subcontractorAgreement,
required: true
}
];
return createPortal(
<div className="fixed top-0 left-0 w-screen h-[100dvh] 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 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative w-full sm:max-w-3xl h-[85dvh] sm:h-auto sm:max-h-[85vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-10 sm:zoom-in-95 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<div className="px-4 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 bg-zinc-50/50 dark:bg-white/5">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<Shield className="text-blue-500 shrink-0" size={24} />
<span className="truncate">Compliance Status</span>
</h2>
<p className="text-zinc-500 dark:text-zinc-400 text-xs sm:text-sm mt-1">
Document verification and compliance tracking
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-[#121214]">
<div className="p-4 sm:p-6 space-y-6">
{/* Overall Status */}
<div className={`p-4 rounded-xl border ${vendorData.status === 'active'
? 'bg-emerald-50 dark:bg-emerald-500/5 border-emerald-200 dark:border-emerald-500/20'
: 'bg-red-50 dark:bg-red-500/5 border-red-200 dark:border-red-500/20'
}`}>
<div className="flex items-center gap-3">
{vendorData.status === 'active' ? (
<CheckCircle size={24} className="text-emerald-600 dark:text-emerald-400" />
) : (
<AlertCircle size={24} className="text-red-600 dark:text-red-400" />
)}
<div>
<h3 className="font-bold text-zinc-900 dark:text-white">
{vendorData.status === 'active' ? 'Compliance Verified' : 'Action Required'}
</h3>
<p className="text-sm text-zinc-600 dark:text-zinc-300 mt-1">
{vendorData.status === 'active'
? 'All required documents are up to date'
: 'Some documents need attention'}
</p>
</div>
</div>
</div>
{/* Documents List */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Required Documents</h3>
{documents.map((doc) => {
const status = getDocumentStatus(doc.data);
return (
<div key={doc.id} className="p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5">
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-white dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/10">
{getStatusIcon(status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex-1">
<h4 className="font-semibold text-zinc-900 dark:text-white">{doc.name}</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{doc.description}</p>
</div>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide border whitespace-nowrap ${getStatusColor(status)}`}>
{status}
</span>
</div>
{doc.expirationDate && (
<div className="flex items-center gap-2 text-xs text-zinc-600 dark:text-zinc-300 mt-2">
<Calendar size={14} />
<span>Expires: {doc.expirationDate}</span>
</div>
)}
{status === 'missing' && (
<button className="mt-3 flex items-center gap-2 px-3 py-1.5 rounded-lg bg-blue-600 text-white text-xs font-medium hover:bg-blue-500 transition-colors">
<Upload size={14} />
Upload Document
</button>
)}
{status === 'expired' && (
<button className="mt-3 flex items-center gap-2 px-3 py-1.5 rounded-lg bg-amber-600 text-white text-xs font-medium hover:bg-amber-500 transition-colors">
<Upload size={14} />
Upload Renewal
</button>
)}
{status === 'compliant' && doc.data?.uploaded && (
<div className="mt-3 flex items-center gap-2 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle size={14} />
<span>Verified and approved</span>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Upload Center */}
<div className="p-6 rounded-xl border-2 border-dashed border-zinc-200 dark:border-white/10 bg-zinc-50/50 dark:bg-white/5 text-center">
<Upload size={32} className="mx-auto text-zinc-400 mb-3" />
<h4 className="font-semibold text-zinc-900 dark:text-white mb-1">Upload Documents</h4>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-4">
Drag and drop files here or click to browse
</p>
<button className="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-500 transition-colors">
Choose Files
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
);
};
export default ComplianceDetailsModal;
+334
View File
@@ -0,0 +1,334 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, Package, MapPin, Calendar, DollarSign, FileText, MessageSquare, Truck, CheckCircle, Clock, AlertCircle, Phone, User } from 'lucide-react';
const OrderDetailsModal = ({ isOpen, onClose, order }) => {
const [activeTab, setActiveTab] = useState('overview');
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !order) return null;
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
};
const getStatusColor = (status) => {
switch (status) {
case 'delivered':
case 'completed':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20';
case 'shipped':
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400 border-blue-200 dark:border-blue-500/20';
case 'confirmed':
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400 border-purple-200 dark:border-purple-500/20';
case 'pending':
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400 border-amber-200 dark:border-amber-500/20';
default:
return 'bg-zinc-100 text-zinc-700 dark:bg-white/10 dark:text-zinc-400 border-zinc-200 dark:border-white/10';
}
};
const getTimelineIcon = (status) => {
if (status === 'completed') return <CheckCircle size={20} className="text-emerald-500" />;
if (status === 'pending') return <Clock size={20} className="text-zinc-400" />;
return <AlertCircle size={20} className="text-blue-500" />;
};
const tabs = [
{ id: 'overview', label: 'Overview', icon: Package },
{ id: 'timeline', label: 'Timeline', icon: Truck },
{ id: 'documents', label: 'Documents', icon: FileText },
{ id: 'communication', label: 'Communication', icon: MessageSquare }
];
return createPortal(
<div className="fixed top-0 left-0 w-screen h-[100dvh] z-[9999] flex items-end sm:items-center justify-center sm:p-6" role="dialog" aria-modal="true" aria-labelledby="order-modal-title">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative w-full sm:max-w-5xl h-[85dvh] sm:h-auto sm:max-h-[85vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-10 sm:zoom-in-95 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<div className="px-4 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 bg-zinc-50/50 dark:bg-white/5">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
<h2 id="order-modal-title" className="text-lg sm:text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2 mb-2">
<Package className="text-blue-500 shrink-0" size={24} />
<span className="truncate">Order {order.id}</span>
</h2>
<div className="flex flex-wrap items-center gap-3 text-xs sm:text-sm text-zinc-500 dark:text-zinc-400">
<div className="flex items-center gap-1">
<MapPin size={14} />
<span className="truncate">{order.projectAddress || order.project}</span>
</div>
<div className="flex items-center gap-1">
<Calendar size={14} />
<span>Due: {order.dueDate}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<span className={`px-2.5 sm:px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide border ${getStatusColor(order.status)}`}>
{order.status}
</span>
<button
onClick={onClose}
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
>
<X size={18} />
</button>
</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-zinc-200 dark:border-white/10 bg-white dark:bg-[#121214]">
<div className="flex overflow-x-auto hide-scrollbar px-4 sm:px-6">
{tabs.map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-bold whitespace-nowrap border-b-2 transition-colors ${activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-zinc-500 hover:text-zinc-900 dark:hover:text-white'
}`}
>
<Icon size={16} />
{tab.label}
</button>
);
})}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-[#121214]">
<div className="p-4 sm:p-6 space-y-6">
{/* Overview Tab */}
{activeTab === 'overview' && (
<>
{/* Order Summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-100 dark:border-blue-500/20">
<div className="flex items-center gap-2 mb-2">
<DollarSign size={18} className="text-blue-600 dark:text-blue-400" />
<span className="text-xs font-bold uppercase text-blue-600 dark:text-blue-400">Total Amount</span>
</div>
<p className="text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(order.total || order.amount)}</p>
</div>
<div className="p-4 rounded-xl bg-purple-50 dark:bg-purple-500/5 border border-purple-100 dark:border-purple-500/20">
<div className="flex items-center gap-2 mb-2">
<Calendar size={18} className="text-purple-600 dark:text-purple-400" />
<span className="text-xs font-bold uppercase text-purple-600 dark:text-purple-400">Order Date</span>
</div>
<p className="text-2xl font-bold text-zinc-900 dark:text-white">{order.orderDate || 'N/A'}</p>
</div>
<div className="p-4 rounded-xl bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-100 dark:border-emerald-500/20">
<div className="flex items-center gap-2 mb-2">
<Truck size={18} className="text-emerald-600 dark:text-emerald-400" />
<span className="text-xs font-bold uppercase text-emerald-600 dark:text-emerald-400">Delivery Date</span>
</div>
<p className="text-2xl font-bold text-zinc-900 dark:text-white">{order.deliveryDate || 'Pending'}</p>
</div>
</div>
{/* Items List */}
<div className="space-y-3">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Order Items</h3>
<div className="space-y-2">
{order.items ? order.items.map((item, idx) => (
<div key={idx} className="p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5">
<div className="flex justify-between items-start gap-4">
<div className="flex-1">
<h4 className="font-semibold text-zinc-900 dark:text-white">{item.description}</h4>
{item.specifications && (
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">{item.specifications}</p>
)}
<p className="text-sm text-zinc-600 dark:text-zinc-300 mt-2">
Quantity: <span className="font-bold">{item.quantity} {item.unit}</span>
</p>
</div>
<div className="text-right">
<p className="text-sm text-zinc-500">Unit Price</p>
<p className="font-mono font-medium text-zinc-900 dark:text-white">{formatCurrency(item.unitPrice)}</p>
<p className="text-xs text-zinc-500 mt-2">Total</p>
<p className="text-lg font-bold text-zinc-900 dark:text-white">{formatCurrency(item.total)}</p>
</div>
</div>
</div>
)) : (
<div className="p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5">
<p className="font-semibold text-zinc-900 dark:text-white">{order.item}</p>
<p className="text-sm text-zinc-600 dark:text-zinc-300 mt-1">
Amount: <span className="font-bold">{formatCurrency(order.amount)}</span>
</p>
</div>
)}
</div>
</div>
{/* Delivery Information */}
{order.deliveryAddress && (
<div className="p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5">
<h3 className="text-sm font-bold text-zinc-900 dark:text-white mb-3 flex items-center gap-2">
<MapPin size={16} className="text-blue-500" />
Delivery Address
</h3>
<p className="text-sm text-zinc-600 dark:text-zinc-300">
{order.deliveryAddress.street}<br />
{order.deliveryAddress.city}, {order.deliveryAddress.state} {order.deliveryAddress.zip}
</p>
{order.deliveryAddress.instructions && (
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-2 italic">
Note: {order.deliveryAddress.instructions}
</p>
)}
</div>
)}
{/* Contact Information */}
{(order.contactPerson || order.contactPhone) && (
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-100 dark:border-blue-500/20">
<h3 className="text-sm font-bold text-zinc-900 dark:text-white mb-3">Project Contact</h3>
<div className="space-y-2 text-sm">
{order.contactPerson && (
<div className="flex items-center gap-2 text-zinc-600 dark:text-zinc-300">
<User size={14} />
{order.contactPerson}
</div>
)}
{order.contactPhone && (
<div className="flex items-center gap-2 text-zinc-600 dark:text-zinc-300">
<Phone size={14} />
{order.contactPhone}
</div>
)}
</div>
</div>
)}
</>
)}
{/* Timeline Tab */}
{activeTab === 'timeline' && (
<div className="space-y-4">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Order Timeline</h3>
{order.timeline ? (
<div className="relative space-y-6 pl-8">
{/* Timeline line */}
<div className="absolute left-2.5 top-2 bottom-2 w-0.5 bg-zinc-200 dark:bg-white/10" />
{order.timeline.map((event, idx) => (
<div key={idx} className="relative">
<div className="absolute -left-8 top-0">
{getTimelineIcon(event.status)}
</div>
<div className="p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5">
<div className="flex justify-between items-start gap-4">
<div>
<h4 className="font-bold text-zinc-900 dark:text-white">{event.event}</h4>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{event.date}</p>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-bold uppercase ${event.status === 'completed' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400' :
'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-400'
}`}>
{event.status}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-10 text-zinc-500">
<Clock size={48} className="mx-auto mb-4 opacity-20" />
<p>No timeline information available</p>
</div>
)}
</div>
)}
{/* Documents Tab */}
{activeTab === 'documents' && (
<div className="space-y-4">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Order Documents</h3>
{order.documents && order.documents.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{order.documents.map((doc, idx) => (
<div key={idx} className="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-start gap-3">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400">
<FileText size={20} />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
{doc.name}
</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">{doc.type}</p>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-10 text-zinc-500">
<FileText size={48} className="mx-auto mb-4 opacity-20" />
<p>No documents available</p>
</div>
)}
</div>
)}
{/* Communication Tab */}
{activeTab === 'communication' && (
<div className="space-y-4">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Communication Log</h3>
<div className="text-center py-10 text-zinc-500">
<MessageSquare size={48} className="mx-auto mb-4 opacity-20" />
<p>No messages yet</p>
<button className="mt-4 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-500 transition-colors">
Send Message
</button>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
{order.notes && (
<div className="px-4 sm:px-6 py-3 sm:py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5">
<p className="text-xs text-zinc-500 dark:text-zinc-400">
<span className="font-bold">Note:</span> {order.notes}
</p>
</div>
)}
</div>
</div>,
document.body
);
};
export default OrderDetailsModal;
+232
View File
@@ -0,0 +1,232 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { X, Award, TrendingUp, CheckCircle, Clock, Star, BarChart3 } from 'lucide-react';
const PerformanceMetricsModal = ({ isOpen, onClose, vendorData }) => {
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !vendorData) return null;
const performance = vendorData.performance || {};
const rating = performance.rating || 0;
const onTimeRate = (performance.onTimeRate || 0) * 100;
const jobsCompleted = performance.jobsCompleted || 0;
const activeJobs = performance.activeJobs || 0;
const getRatingColor = (rating) => {
if (rating >= 4.5) return 'text-emerald-500';
if (rating >= 3.5) return 'text-blue-500';
if (rating >= 2.5) return 'text-amber-500';
return 'text-red-500';
};
const metrics = [
{
label: 'Overall Rating',
value: `${rating.toFixed(1)}/5.0`,
icon: Star,
color: 'emerald',
description: 'Average customer satisfaction score'
},
{
label: 'On-Time Delivery',
value: `${onTimeRate.toFixed(0)}%`,
icon: Clock,
color: 'blue',
description: 'Percentage of orders delivered on schedule'
},
{
label: 'Jobs Completed',
value: jobsCompleted,
icon: CheckCircle,
color: 'purple',
description: 'Total number of completed deliveries'
},
{
label: 'Active Jobs',
value: activeJobs,
icon: TrendingUp,
color: 'amber',
description: 'Current ongoing projects'
}
];
return createPortal(
<div className="fixed top-0 left-0 w-screen h-[100dvh] 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 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative w-full sm:max-w-4xl h-[85dvh] sm:h-auto sm:max-h-[85vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-10 sm:zoom-in-95 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<div className="px-4 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 bg-zinc-50/50 dark:bg-white/5">
<div className="flex justify-between items-start gap-4">
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<Award className="text-purple-500 shrink-0" size={24} />
<span className="truncate">Performance Metrics</span>
</h2>
<p className="text-zinc-500 dark:text-zinc-400 text-xs sm:text-sm mt-1">
{vendorData.vendorName} - Performance Analytics
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-[#121214]">
<div className="p-4 sm:p-6 space-y-6">
{/* Overall Score */}
<div className="p-6 rounded-xl bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-500/10 dark:to-blue-500/10 border border-purple-100 dark:border-purple-500/20">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-bold uppercase tracking-wide text-purple-600 dark:text-purple-400 mb-2">
Overall Performance Score
</h3>
<div className="flex items-baseline gap-2">
<span className={`text-5xl font-bold ${getRatingColor(rating)}`}>
{rating.toFixed(1)}
</span>
<span className="text-2xl text-zinc-400">/5.0</span>
</div>
<div className="flex items-center gap-1 mt-2">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={20}
className={star <= rating ? 'fill-amber-400 text-amber-400' : 'text-zinc-300 dark:text-zinc-600'}
/>
))}
</div>
</div>
<BarChart3 size={64} className="text-purple-200 dark:text-purple-500/20" />
</div>
</div>
{/* Key Metrics Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{metrics.map((metric) => {
const Icon = metric.icon;
const colorClasses = {
emerald: 'bg-emerald-50 dark:bg-emerald-500/5 border-emerald-100 dark:border-emerald-500/20 text-emerald-600 dark:text-emerald-400',
blue: 'bg-blue-50 dark:bg-blue-500/5 border-blue-100 dark:border-blue-500/20 text-blue-600 dark:text-blue-400',
purple: 'bg-purple-50 dark:bg-purple-500/5 border-purple-100 dark:border-purple-500/20 text-purple-600 dark:text-purple-400',
amber: 'bg-amber-50 dark:bg-amber-500/5 border-amber-100 dark:border-amber-500/20 text-amber-600 dark:text-amber-400'
};
return (
<div key={metric.label} className={`p-4 rounded-xl border ${colorClasses[metric.color]}`}>
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg bg-white dark:bg-zinc-900/50`}>
<Icon size={20} className={colorClasses[metric.color].split(' ').slice(-2).join(' ')} />
</div>
<div className="flex-1">
<p className="text-xs font-bold uppercase tracking-wide opacity-80">
{metric.label}
</p>
<p className="text-2xl font-bold text-zinc-900 dark:text-white mt-1">
{metric.value}
</p>
<p className="text-xs text-zinc-600 dark:text-zinc-400 mt-1">
{metric.description}
</p>
</div>
</div>
</div>
);
})}
</div>
{/* Performance Breakdown */}
<div className="space-y-4">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Performance Breakdown</h3>
{/* Quality */}
<div className="p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-semibold text-zinc-900 dark:text-white">Quality of Work</span>
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400">Excellent</span>
</div>
<div className="h-2 bg-zinc-200 dark:bg-zinc-700/50 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400 rounded-full" style={{ width: '95%' }} />
</div>
</div>
{/* Communication */}
<div className="p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-semibold text-zinc-900 dark:text-white">Communication</span>
<span className="text-sm font-bold text-blue-600 dark:text-blue-400">Very Good</span>
</div>
<div className="h-2 bg-zinc-200 dark:bg-zinc-700/50 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 rounded-full" style={{ width: '88%' }} />
</div>
</div>
{/* Reliability */}
<div className="p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-semibold text-zinc-900 dark:text-white">Reliability</span>
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400">Excellent</span>
</div>
<div className="h-2 bg-zinc-200 dark:bg-zinc-700/50 rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400 rounded-full" style={{ width: '92%' }} />
</div>
</div>
</div>
{/* Recent Feedback */}
<div className="space-y-3">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Recent Feedback</h3>
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-100 dark:border-blue-500/20">
<div className="flex items-start gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{[1, 2, 3, 4, 5].map((star) => (
<Star key={star} size={14} className="fill-amber-400 text-amber-400" />
))}
</div>
<p className="text-sm text-zinc-700 dark:text-zinc-300 italic">
"Excellent service and timely delivery. Materials were exactly as specified."
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-2">
- Project Manager, 2604 Dunwick Dr
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
document.body
);
};
export default PerformanceMetricsModal;
+220
View File
@@ -0,0 +1,220 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, DollarSign, TrendingUp, Clock, CheckCircle, Download, Search, Filter } from 'lucide-react';
const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState({ key: 'date', direction: 'desc' });
// Close on Escape key
React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !data) return null;
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
};
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
// Filter and sort data
const filteredData = (data.invoices || []).filter(item =>
item.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.project?.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.id?.toLowerCase().includes(searchTerm.toLowerCase())
);
const sortedData = [...filteredData].sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (a[sortConfig.key] > b[sortConfig.key]) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
return createPortal(
<div className="fixed top-0 left-0 w-screen h-[100dvh] 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 transition-opacity"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative w-full sm:max-w-5xl h-[85dvh] sm:h-auto sm:max-h-[85vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-full sm:slide-in-from-bottom-10 sm:zoom-in-95 duration-300 sm:duration-200 border-t border-x sm:border border-zinc-200 dark:border-white/10">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 bg-zinc-50 dark:bg-white/5 border-b-0 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<div className="px-4 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-start bg-zinc-50/50 dark:bg-white/5">
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<DollarSign className="text-emerald-500 shrink-0" size={20} />
<span className="truncate">Earnings Summary</span>
</h2>
<p className="text-zinc-500 dark:text-zinc-400 text-xs sm:text-sm mt-1">
Total Earned (YTD):
<span className="font-mono font-bold text-zinc-900 dark:text-white ml-1">{formatCurrency(data.totalEarnings || 0)}</span>
</p>
</div>
<div className="flex items-center gap-2 sm:gap-3 ml-2">
<button className="hidden sm:block p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors" title="Export CSV">
<Download size={20} />
</button>
<button
onClick={onClose}
className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:bg-zinc-200 dark:hover:bg-white/20 hover:text-red-500 transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Summary Cards */}
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-zinc-200 dark:border-white/10 bg-white dark:bg-[#121214]">
<div className="grid grid-cols-1 sm:grid-cols-3 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-100 dark:border-emerald-500/20">
<div className="flex items-center gap-2 mb-1 sm:mb-2">
<CheckCircle size={16} className="text-emerald-600 dark:text-emerald-400" />
<span className="text-[10px] sm:text-xs font-bold uppercase text-emerald-600 dark:text-emerald-400">
Paid Invoices
</span>
</div>
<p className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(data.paidInvoices || 0)}</p>
</div>
<div className="p-3 sm:p-4 rounded-xl bg-amber-50 dark:bg-amber-500/5 border border-amber-100 dark:border-amber-500/20">
<div className="flex items-center gap-2 mb-1 sm:mb-2">
<Clock size={16} className="text-amber-600 dark:text-amber-400" />
<span className="text-[10px] sm:text-xs font-bold uppercase text-amber-600 dark:text-amber-400">
Pending Payments
</span>
</div>
<p className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(data.pendingPayments || 0)}</p>
</div>
<div className="p-3 sm:p-4 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-100 dark:border-blue-500/20">
<div className="flex items-center gap-2 mb-1 sm:mb-2">
<TrendingUp size={16} className="text-blue-600 dark:text-blue-400" />
<span className="text-[10px] sm:text-xs font-bold uppercase text-blue-600 dark:text-blue-400">
Total Earned (YTD)
</span>
</div>
<p className="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-white">{formatCurrency(data.totalEarnings || 0)}</p>
</div>
</div>
</div>
{/* Toolbar */}
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-zinc-200 dark:border-white/5 flex flex-col sm:flex-row gap-3 sm:gap-4 justify-between bg-white dark:bg-[#121214]">
<div className="relative w-full sm:w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
<input
type="text"
placeholder="Search invoices..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
<button className="hidden sm:flex items-center gap-2 px-4 py-2 rounded-xl border border-zinc-200 dark:border-white/10 text-sm font-medium hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
<Filter size={16} /> Filter
</button>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto overflow-x-auto custom-scrollbar bg-white dark:bg-[#121214]">
<table className="w-full text-left border-collapse min-w-[600px]">
<thead className="sticky top-0 z-10 bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10 shadow-sm">
<tr>
{[
{ key: 'id', label: 'Invoice #' },
{ key: 'project', label: 'Project' },
{ key: 'invoiceDate', label: 'Date' },
{ key: 'dueDate', label: 'Due Date' },
{ key: 'status', label: 'Status' },
{ key: 'amount', label: 'Amount', align: 'right' }
].map((col) => (
<th
key={col.key}
onClick={() => handleSort(col.key)}
className={`px-3 sm:px-6 py-3 sm:py-4 text-[10px] sm:text-xs font-bold uppercase tracking-wider text-zinc-500 cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors ${col.align === 'right' ? 'text-right' : ''}`}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
{sortedData.length > 0 ? sortedData.map((invoice, idx) => (
<tr key={idx} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
<td className="px-3 sm:px-6 py-3 sm:py-4 text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
{invoice.id}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4">
<div className="font-semibold text-sm sm:text-base text-zinc-900 dark:text-white">{invoice.project}</div>
{invoice.description && <div className="text-xs text-zinc-500">{invoice.description}</div>}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4 text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
{invoice.invoiceDate}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4 text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
{invoice.dueDate}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4">
<span className={`px-2 sm:px-2.5 py-0.5 rounded-full text-[10px] sm:text-xs font-bold uppercase tracking-wide whitespace-nowrap ${invoice.status === 'paid'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
: invoice.status === 'pending'
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
: 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400'
}`}>
{invoice.status}
</span>
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4 text-right font-mono font-medium text-sm sm:text-base text-zinc-900 dark:text-white whitespace-nowrap">
{formatCurrency(invoice.amount)}
</td>
</tr>
)) : (
<tr>
<td colSpan="6" className="py-20 text-center text-zinc-500">
No invoices found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Footer */}
<div className="px-4 sm:px-6 py-3 sm:py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-between items-center text-xs sm:text-sm">
<span className="text-zinc-500 dark:text-zinc-400">Showing {sortedData.length} invoices</span>
<div className="flex items-center gap-2">
<span className="text-zinc-500 dark:text-zinc-400">Total:</span>
<span className="text-base sm:text-lg font-bold text-zinc-900 dark:text-white">{formatCurrency(data.totalEarnings || 0)}</span>
</div>
</div>
</div>
</div>,
document.body
);
};
export default VendorFinancialSummaryModal;