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:
+98
-6
@@ -13,8 +13,20 @@ import ErrorBoundary from './components/ErrorBoundary';
|
|||||||
import SmoothScroll from './components/SmoothScroll';
|
import SmoothScroll from './components/SmoothScroll';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
|
import OwnerSnapshot from './pages/owner/OwnerSnapshot';
|
||||||
|
import PeopleDirectory from './pages/owner/PeopleDirectory';
|
||||||
|
import VendorDirectory from './pages/owner/VendorDirectory';
|
||||||
|
import DocumentManagement from './pages/owner/DocumentManagement';
|
||||||
|
import PlaceholderDashboard from './pages/PlaceholderDashboard';
|
||||||
|
import ProjectList from './pages/contractor/ProjectList';
|
||||||
|
import AiAssistantPage from './pages/AiAssistantPage';
|
||||||
|
// New Dashboards
|
||||||
|
import VendorDashboard from './pages/vendor/VendorDashboard';
|
||||||
|
import VendorOrders from './pages/vendor/VendorOrders';
|
||||||
|
import ContractorDashboard from './pages/contractor/ContractorDashboard';
|
||||||
|
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
|
||||||
|
|
||||||
// Protected Route Component
|
// ... (existing imports)
|
||||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||||
const { user, isAuthenticated } = useAuth();
|
const { user, isAuthenticated } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -42,6 +54,14 @@ function App() {
|
|||||||
{/* Public Routes */}
|
{/* Public Routes */}
|
||||||
<Route path="/" element={<Landing />} />
|
<Route path="/" element={<Landing />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/chat-assistant"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER', 'ADMIN', 'FIELD_AGENT', 'CONTRACTOR', 'SUBCONTRACTOR', 'CUSTOMER']}>
|
||||||
|
<AiAssistantPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Protected Field Agent Routes */}
|
{/* Protected Field Agent Routes */}
|
||||||
<Route path="/portal/profile" element={
|
<Route path="/portal/profile" element={
|
||||||
@@ -53,7 +73,7 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/emp/fa/dashboard"
|
path="/emp/fa/dashboard"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN']}>
|
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -61,17 +81,53 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/emp/fa/maps"
|
path="/emp/fa/maps"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN']}>
|
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
|
||||||
<Maps />
|
<Maps />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Owner Routes */}
|
||||||
|
<Route path="/owner/snapshot" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
|
<OwnerSnapshot />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/owner/people" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
|
<PeopleDirectory />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/owner/vendors" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
|
<VendorDirectory />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/owner/documents" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
|
<DocumentManagement />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/owner/maps" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
|
<div className="h-full w-full bg-zinc-900 flex items-center justify-center text-white">
|
||||||
|
<span className="text-xl">Detailed Market Intelligence Map (Coming Soon)</span>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Protected Admin Routes */}
|
{/* Protected Admin Routes — Owner has full admin access */}
|
||||||
|
<Route
|
||||||
|
path="/admin/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/schedule"
|
path="/admin/schedule"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['ADMIN']}>
|
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
|
||||||
<AdminSchedule />
|
<AdminSchedule />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -80,11 +136,47 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/admin/leaderboard"
|
path="/admin/leaderboard"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['ADMIN']}>
|
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
|
||||||
<LeaderboardPage />
|
<LeaderboardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Contractor Routes */}
|
||||||
|
<Route path="/contractor/dashboard" element={
|
||||||
|
<ProtectedRoute allowedRoles={['CONTRACTOR']}>
|
||||||
|
<ContractorDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/contractor/projects" element={
|
||||||
|
<ProtectedRoute allowedRoles={['CONTRACTOR']}>
|
||||||
|
<ProjectList />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* Vendor Routes */}
|
||||||
|
<Route path="/vendor/dashboard" element={
|
||||||
|
<ProtectedRoute allowedRoles={['VENDOR']}>
|
||||||
|
<VendorDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/vendor/orders" element={
|
||||||
|
<ProtectedRoute allowedRoles={['VENDOR']}>
|
||||||
|
<VendorOrders />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* Subcontractor Routes */}
|
||||||
|
<Route path="/subcontractor/dashboard" element={
|
||||||
|
<ProtectedRoute allowedRoles={['SUBCONTRACTOR']}>
|
||||||
|
<SubContractorDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/subcontractor/projects" element={
|
||||||
|
<ProtectedRoute allowedRoles={['SUBCONTRACTOR']}>
|
||||||
|
<ProjectList />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Catch all */}
|
{/* Catch all */}
|
||||||
|
|||||||
+272
-54
@@ -29,7 +29,7 @@ COMPANY INFO:
|
|||||||
TONE: Professional, efficient, and data-aware.
|
TONE: Professional, efficient, and data-aware.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const getEmployeeContext = (user, meetings, properties, salesHistory) => {
|
const getEmployeeContext = (user, meetings, properties, salesHistory, users) => {
|
||||||
// --- ADMIN SPECIFIC CONTEXT ---
|
// --- ADMIN SPECIFIC CONTEXT ---
|
||||||
if (user.role === 'ADMIN') {
|
if (user.role === 'ADMIN') {
|
||||||
const unassignedCount = properties.filter(p => !p.assignedAgentId && p.canvassingStatus === 'Lead').length;
|
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
|
// Convert to Array & Sort
|
||||||
// We know IDs are e1..e5, maybe map names if possible?
|
const sortedStats = Object.keys(agentStats).map(agentId => {
|
||||||
// We don't have user list here easily unless passed, but we can just use ID or try to pass users?
|
const agent = users.find(u => u.id === agentId);
|
||||||
// Actually `salesHistory` doesn't have names. `users` list is needed.
|
return {
|
||||||
// Let's assume the user asks "Who is top?", the AI might need names.
|
name: agent ? agent.name : agentId,
|
||||||
// I will pass `users` to this function as well to resolve names.
|
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.
|
const leaderboardText = sortedStats.map((s, i) => `${i + 1}. ${s.name} - $${s.revenue.toLocaleString()}`).join('\n');
|
||||||
// Wait, `Chatbot` component has `useMockStore` which has `users`. I should pass `users` too.
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
ROLE: ADMIN (${user.name})
|
ROLE: ADMIN (${user.name})
|
||||||
@@ -79,10 +81,9 @@ GENERAL DATA ACCESS:
|
|||||||
- Total Properties: ${properties.length}
|
- Total Properties: ${properties.length}
|
||||||
- Total Revenue (All Agents): $${meetings.filter(m => m.status === 'Converted').reduce((sum, m) => sum + (m.dealValue || 0), 0).toLocaleString()}
|
- 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):
|
LEADERBOARD SNAPSHOT (Top 3):
|
||||||
- Navigate to: /admin/leaderboard for full details.
|
${leaderboardText}
|
||||||
- Context: The admin can see the full leaderboard.
|
- Full details at /admin/leaderboard
|
||||||
- You do NOT have the full live calculation here but you know the page exists.
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 { 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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [isMinimized, setIsMinimized] = useState(false);
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
@@ -297,6 +395,10 @@ const Chatbot = () => {
|
|||||||
greeting += "I have your latest territory data and agenda ready. What do you need?";
|
greeting += "I have your latest territory data and agenda ready. What do you need?";
|
||||||
} else if (user.role === 'CUSTOMER') {
|
} else if (user.role === 'CUSTOMER') {
|
||||||
greeting += "I have your property records open. How can I assist?";
|
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 }]);
|
setMessages([{ role: 'assistant', content: greeting }]);
|
||||||
@@ -313,11 +415,11 @@ const Chatbot = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
// ... existing demo logic ...
|
// ... existing demo logic ... (Simulated for brevity, keeping existing structure if needed, but replacing with smart demo response)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const responses = [
|
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.",
|
"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."
|
"Would you like to schedule a callback? Please log in first."
|
||||||
];
|
];
|
||||||
const randomResponse = responses[Math.floor(Math.random() * responses.length)];
|
const randomResponse = responses[Math.floor(Math.random() * responses.length)];
|
||||||
@@ -329,51 +431,69 @@ const Chatbot = () => {
|
|||||||
|
|
||||||
// --- REAL AI REQUEST ---
|
// --- REAL AI REQUEST ---
|
||||||
|
|
||||||
// 1. Build Dynamic Context
|
// --- NEW: Enhanced Context Builders for Phase 7 ---
|
||||||
let systemContext = BASE_IDENTITY;
|
|
||||||
|
|
||||||
if (user) {
|
const getOwnerContext = () => {
|
||||||
if (user.role === 'FIELD_AGENT' || user.role === 'ADMIN') {
|
// Summarize financials
|
||||||
// Pass salesHistory and users for accurate leaderboard context
|
// Real app: Calculate from store data
|
||||||
// We need to update getEmployeeContext signature to accept them.
|
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).";
|
||||||
// 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.
|
|
||||||
|
|
||||||
// Helper to get formatted leaderboard string
|
const getContractorContext = () => {
|
||||||
let leaderboardContext = "";
|
const myProjects = storeData.projects.filter(p => p.contractorId === 'con_001');
|
||||||
if (user.role === 'ADMIN') {
|
const activeCount = myProjects.filter(p => p.status === 'active').length;
|
||||||
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
|
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 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 sortedByRev = Object.entries(agentStats)
|
const getVendorContext = () => {
|
||||||
.map(([id, stats]) => ({
|
// Mock ID 'v3' for 'abc_supply' or 'v1' for generic
|
||||||
name: users.find(u => u.id === id)?.name || id,
|
const myId = user.id === 'ven_001' ? 'v3' : 'v1';
|
||||||
...stats
|
const me = storeData.vendors.find(v => v.id === myId) || storeData.vendors[0];
|
||||||
}))
|
const pending = me.spend?.pendingInvoices || 0;
|
||||||
.sort((a, b) => b.revenue - a.revenue)
|
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.`;
|
||||||
.slice(0, 3);
|
};
|
||||||
|
|
||||||
leaderboardContext = `
|
const getSubContractorContext = () => {
|
||||||
LEADERBOARD SNAPSHOT (Current Month):
|
// Mock ID 'sub_001'
|
||||||
${sortedByRev.map((a, i) => `${i + 1}. ${a.name} ($${a.revenue.toLocaleString()})`).join('\n')}
|
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.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------
|
||||||
|
|
||||||
|
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.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
systemContext += getEmployeeContext(user, meetings, properties) + leaderboardContext;
|
// Add current page context if needed (e.g. if on a specific project page)
|
||||||
} else if (user.role === 'CUSTOMER') {
|
// For now, we keep it high-level role based.
|
||||||
systemContext += getCustomerContext(user, meetings, properties);
|
|
||||||
}
|
return context;
|
||||||
} else {
|
};
|
||||||
// GUEST CONTEXT - The "Seasoned Consultant"
|
|
||||||
systemContext += getGuestContext();
|
// 1. Build Dynamic Context
|
||||||
}
|
let systemContext = generateContext();
|
||||||
|
|
||||||
// 2. Call Groq
|
// 2. Call Groq
|
||||||
const chatCompletion = await groq.chat.completions.create({
|
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();
|
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) {
|
if (!isOpen) {
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<button
|
<button
|
||||||
|
|||||||
+220
-152
@@ -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 { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
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 PageTransition from './PageTransition';
|
||||||
import Chatbot from './Chatbot';
|
import Chatbot from './Chatbot';
|
||||||
// import LeaderboardWidget from './dashboard/LeaderboardWidget'; // Deprecated in favor of full page
|
import Logo from '../assets/images/LynkedUp_Icon.png';
|
||||||
import Logo from '../assets/images/LynkedUp_Pro_F_logo_Y.png';
|
|
||||||
|
|
||||||
// Rainbow Sidebar Item Component
|
|
||||||
const SidebarItem = ({ to, icon: Icon, label, isCollapsed, onClick }) => {
|
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 (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
ref={divRef}
|
|
||||||
onClick={onClick}
|
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 : ""}
|
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'
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/*
|
{({ isActive }) => (
|
||||||
Rainbow Border Layer
|
<>
|
||||||
- Only visible on Hover (opacity controlled by state)
|
{/* Glow/Border Effect Container */}
|
||||||
*/}
|
<div className={`
|
||||||
<div
|
absolute inset-0 rounded-xl bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500
|
||||||
className='pointer-events-none absolute -inset-px opacity-0 transition duration-300 z-0'
|
opacity-0 transition-opacity duration-300 blur-[2px]
|
||||||
style={{
|
${isActive ? 'opacity-70' : 'group-hover:opacity-70'}
|
||||||
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%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Inner Mask (The "Button" surface) */}
|
{/* Sharp Border */}
|
||||||
<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'
|
<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) */}
|
{/* Masking Background (Creates the border look) */}
|
||||||
<div className={`relative z-10 flex items-center ${isCollapsed ? 'justify-center' : 'space-x-3'} w-full`}>
|
<div className={`
|
||||||
<Icon size={18} className="transition-transform group-hover:scale-110 duration-300 shrink-0" />
|
absolute inset-[1.5px] rounded-[10px] bg-zinc-50 dark:bg-[#09090b] transition-colors duration-200 z-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>
|
${isActive ? 'bg-white dark:bg-zinc-900' : 'group-hover:bg-white dark:group-hover:bg-zinc-900'}
|
||||||
<span className="nav-meta-z3">igotsar.matyas</span>
|
`} />
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
)}
|
||||||
{/* 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'}`} />
|
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
@@ -84,7 +77,10 @@ const Layout = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
// -- State --
|
||||||
|
// Desktop: User controls collapse manually. Default open.
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
// Mobile: Hidden by default.
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -92,7 +88,29 @@ const Layout = () => {
|
|||||||
navigate('/login');
|
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);
|
const isPublic = ['/', '/login'].includes(location.pathname);
|
||||||
|
|
||||||
if (isPublic) {
|
if (isPublic) {
|
||||||
@@ -103,132 +121,182 @@ const Layout = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Role-specific Navigation Items
|
||||||
<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">
|
const getNavItems = () => {
|
||||||
{location.pathname === '/emp/fa/maps' && (
|
if (!user) return [];
|
||||||
<span className="route-sig-m1">M@p5-{'Ѕ'}{'а'}{'t'}{'у'}{'а'}{'m'}-2025</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile Header */}
|
const commonItems = [
|
||||||
<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">
|
{ 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">
|
<div className="flex items-center gap-2">
|
||||||
<img src={Logo} alt="Logo" className="w-8 h-8" />
|
<img src={Logo} alt="LynkedUp Pro" className="w-8 h-8" />
|
||||||
<span className="font-bold text-lg">LynkedUp</span>
|
<span className="font-bold text-lg text-zinc-900 dark:text-white">LynkedUp <span className="text-amber-500">Pro</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
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>
|
</button>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{/* Mobile Backdrop */}
|
{/* --- Mobile Backdrop --- */}
|
||||||
{isMobileMenuOpen && (
|
{isMobileMenuOpen && (
|
||||||
<div
|
<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)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* --- Sidebar --- */}
|
||||||
{/* Enhanced glassmorphism with light/dark support */}
|
<aside
|
||||||
<aside className={`
|
className={`
|
||||||
fixed md:static inset-y-0 left-0 z-50
|
fixed md:static md:relative inset-y-0 left-0 z-50
|
||||||
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
|
bg-white dark:bg-[#09090b] border-r border-zinc-200 dark:border-white/5
|
||||||
${isCollapsed ? 'w-20' : 'w-72'}
|
transition-all duration-300 ease-in-out flex flex-col
|
||||||
bg-white/95 md:bg-white/60 dark:bg-zinc-900/95 md:dark:bg-zinc-900/60
|
${isMobileMenuOpen ? 'translate-x-0 w-64 shadow-2xl' : '-translate-x-full md:translate-x-0'}
|
||||||
backdrop-blur-2xl border-r border-zinc-200 dark:border-white/5
|
${isCollapsed ? 'md:w-20' : 'md:w-64'}
|
||||||
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
|
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>
|
||||||
|
|
||||||
{/* Collapse Toggle Button (Desktop Only) */}
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Desktop Collapse Toggle - Floating on Border */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
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"
|
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} />}
|
{isCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
|
||||||
</button>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-1">
|
{/* 2. Navigation Items */}
|
||||||
<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>
|
<nav className="flex-1 px-3 py-6 space-y-1 overflow-y-auto custom-scrollbar" role="navigation">
|
||||||
|
{navItems.map((item) => (
|
||||||
{user?.role === 'FIELD_AGENT' || user?.role === 'ADMIN' ? (
|
<SidebarItem
|
||||||
<>
|
key={item.to}
|
||||||
<SidebarItem to="/emp/fa/dashboard" icon={LayoutDashboard} label="Dashboard" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
|
to={item.to}
|
||||||
<SidebarItem to="/emp/fa/maps" icon={Map} label="Territory Map" isCollapsed={isCollapsed} onClick={() => setIsMobileMenuOpen(false)} />
|
icon={item.icon}
|
||||||
</>
|
label={item.label}
|
||||||
) : null}
|
isCollapsed={isCollapsed && !isMobileMenuOpen}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
{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>
|
</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>
|
</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">
|
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
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`}
|
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={isCollapsed ? "Toggle Theme" : ""}
|
title="Toggle Theme"
|
||||||
>
|
>
|
||||||
<div className="shrink-0">
|
{(!isCollapsed || isMobileMenuOpen) && <span className="text-xs font-medium">Dark Mode</span>}
|
||||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
{theme === 'dark' ? <Moon size={16} /> : <Sun size={16} />}
|
||||||
</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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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">
|
<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>
|
<PageTransition>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
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 divRef = useRef(null);
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
const [opacity, setOpacity] = useState(0);
|
const [opacity, setOpacity] = useState(0);
|
||||||
@@ -43,6 +43,7 @@ export const SpotlightCard = ({ children, className = "", spotlightColor = "rgba
|
|||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
{...props}
|
||||||
// Light: bg-white, Dark: bg-zinc-800
|
// 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}`}
|
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}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 w-full min-h-[200px]">
|
<div className="w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<ScatterChart
|
<ScatterChart
|
||||||
margin={{ top: 20, right: 20, bottom: 20, left: 0 }}
|
margin={{ top: 20, right: 20, bottom: 20, left: 0 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ const IntelligenceSidePanel = ({ data, onClose, variant = 'default' }) => {
|
|||||||
return () => document.removeEventListener('keydown', handleLightboxKeys);
|
return () => document.removeEventListener('keydown', handleLightboxKeys);
|
||||||
}, [lightboxIndex, photos.length]);
|
}, [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;
|
if (!data) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -303,6 +316,9 @@ const IntelligenceSidePanel = ({ data, onClose, variant = 'default' }) => {
|
|||||||
ref={lightboxRef}
|
ref={lightboxRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Photo Lightbox"
|
aria-label="Photo Lightbox"
|
||||||
|
data-lenis-prevent
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
@@ -313,41 +329,34 @@ const IntelligenceSidePanel = ({ data, onClose, variant = 'default' }) => {
|
|||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</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 */}
|
{/* 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
|
<img
|
||||||
src={photos[lightboxIndex].url}
|
src={photos[lightboxIndex].url}
|
||||||
alt={photos[lightboxIndex].caption || "Full screen view"}
|
alt={photos[lightboxIndex].caption || "Full screen view"}
|
||||||
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl border border-white/10"
|
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl border border-white/10"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-[-40px] text-cyan-400/50 text-xs uppercase tracking-widest font-mono">
|
{/* 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}
|
IMG_SEQ: {lightboxIndex + 1}/{photos.length}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation Right */}
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setLightboxIndex((prev) => (prev + 1) % photos.length); }}
|
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"
|
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"
|
aria-label="Next photo"
|
||||||
>
|
>
|
||||||
<ChevronRight size={32} />
|
<ChevronRight size={22} />
|
||||||
</button>
|
</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>< PREV</span>
|
|
||||||
<span>NEXT ></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export const OwnerIntentFunnel = ({ properties }) => {
|
|||||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">Conversion pipeline</p>
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">Conversion pipeline</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 w-full min-h-[200px]">
|
<div className="w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<BarChart
|
<BarChart
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
data={data}
|
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>
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">By Neighborhood Rating</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 w-full min-h-[200px]">
|
<div className="w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<BarChart
|
<BarChart
|
||||||
data={data}
|
data={data}
|
||||||
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export const RoofConditionChart = ({ properties }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 w-full min-h-[250px] relative">
|
<div className="w-full relative" style={{ height: 250 }}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={data}
|
data={data}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const WeatherRiskGauge = ({ properties, weather }) => {
|
|||||||
|
|
||||||
<div className="relative w-full h-[140px] flex items-end justify-center">
|
<div className="relative w-full h-[140px] flex items-end justify-center">
|
||||||
{/* Speedometer Chart */}
|
{/* Speedometer Chart */}
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={data}
|
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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -5,6 +5,16 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
const AuthContext = createContext();
|
const AuthContext = createContext();
|
||||||
|
|
||||||
|
export const ROLES = {
|
||||||
|
OWNER: 'OWNER',
|
||||||
|
ADMIN: 'ADMIN',
|
||||||
|
CONTRACTOR: 'CONTRACTOR',
|
||||||
|
SUBCONTRACTOR: 'SUBCONTRACTOR',
|
||||||
|
VENDOR: 'VENDOR',
|
||||||
|
FIELD_AGENT: 'FIELD_AGENT',
|
||||||
|
CUSTOMER: 'CUSTOMER'
|
||||||
|
};
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
@@ -16,11 +26,15 @@ export const AuthProvider = ({ children }) => {
|
|||||||
if (u.type !== type) return false;
|
if (u.type !== type) return false;
|
||||||
if (type === 'customer') return u.username === identifier && u.password === password;
|
if (type === 'customer') return u.username === identifier && u.password === password;
|
||||||
if (type === 'employee') return u.empId === identifier && u.password === password;
|
if (type === 'employee') return u.empId === identifier && u.password === password;
|
||||||
|
// Support new role types
|
||||||
|
if (['owner', 'contractor', 'subcontractor', 'vendor'].includes(type) || u.type === type) {
|
||||||
|
return (u.email === identifier || u.username === identifier) && u.password === password;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (foundUser) {
|
if (foundUser) {
|
||||||
// Ensure specific role exists
|
// Ensure specific role exists and matches requested type if applicable
|
||||||
const userWithRole = {
|
const userWithRole = {
|
||||||
...foundUser,
|
...foundUser,
|
||||||
role: foundUser.role || 'CUSTOMER'
|
role: foundUser.role || 'CUSTOMER'
|
||||||
|
|||||||
+617
-1
@@ -662,7 +662,73 @@ const MOCK_USERS = [
|
|||||||
// Admins (3 Admins)
|
// Admins (3 Admins)
|
||||||
{ id: 'a1', type: 'employee', empId: 'ADM01', email: 'admin@plano.com', password: 'password', role: 'ADMIN', name: 'Adam Admin' },
|
{ id: 'a1', type: 'employee', empId: 'ADM01', email: 'admin@plano.com', password: 'password', role: 'ADMIN', name: 'Adam Admin' },
|
||||||
{ id: 'a2', type: 'employee', empId: 'ADM02', email: 'admin2@plano.com', password: 'password', role: 'ADMIN', name: 'Amanda Manager' },
|
{ id: 'a2', type: 'employee', empId: 'ADM02', email: 'admin2@plano.com', password: 'password', role: 'ADMIN', name: 'Amanda Manager' },
|
||||||
{ id: 'a3', type: 'employee', empId: 'ADM03', email: 'admin3@plano.com', password: 'password', role: 'ADMIN', name: 'Arthur Director' }
|
{ id: 'a3', type: 'employee', empId: 'ADM03', email: 'admin3@plano.com', password: 'password', role: 'ADMIN', name: 'Arthur Director' },
|
||||||
|
|
||||||
|
// --- NEW ROLES (Owners Box) ---
|
||||||
|
// Owner
|
||||||
|
{
|
||||||
|
id: 'own_001',
|
||||||
|
type: 'owner',
|
||||||
|
username: 'justin', // For login
|
||||||
|
email: 'justin@lynkeduppro.com',
|
||||||
|
password: 'password', // Keeping simple for dev
|
||||||
|
role: 'OWNER',
|
||||||
|
name: 'Justin Owner',
|
||||||
|
companyName: 'LynkedUp Pro',
|
||||||
|
phone: '972-555-1234'
|
||||||
|
},
|
||||||
|
// Contractor
|
||||||
|
{
|
||||||
|
id: 'con_001',
|
||||||
|
type: 'contractor',
|
||||||
|
username: 'mike',
|
||||||
|
email: 'mike@texasbuilders.com',
|
||||||
|
password: 'password',
|
||||||
|
role: 'CONTRACTOR',
|
||||||
|
name: 'Mike Contractor',
|
||||||
|
companyName: 'Texas Builders LLC',
|
||||||
|
licenseNumber: 'TX-GC-123456',
|
||||||
|
phone: '214-555-5678'
|
||||||
|
},
|
||||||
|
// Subcontractor
|
||||||
|
{
|
||||||
|
id: 'sub_001',
|
||||||
|
type: 'subcontractor',
|
||||||
|
username: 'carlos',
|
||||||
|
email: 'carlos@electricpro.com',
|
||||||
|
password: 'password',
|
||||||
|
role: 'SUBCONTRACTOR',
|
||||||
|
name: 'Carlos Subcontractor',
|
||||||
|
companyName: 'Electric Pro Services',
|
||||||
|
tradeType: 'electrical',
|
||||||
|
phone: '469-555-9012'
|
||||||
|
},
|
||||||
|
// Vendor (Supplier)
|
||||||
|
{
|
||||||
|
id: 'ven_001',
|
||||||
|
type: 'vendor',
|
||||||
|
username: 'abc_supply',
|
||||||
|
email: 'sales@abcsupply.com',
|
||||||
|
password: 'password',
|
||||||
|
role: 'VENDOR',
|
||||||
|
name: 'ABC Supply Co.',
|
||||||
|
companyName: 'ABC Supply',
|
||||||
|
tradeType: 'materials',
|
||||||
|
phone: '972-555-8888'
|
||||||
|
},
|
||||||
|
// Vendor (Supplier)
|
||||||
|
{
|
||||||
|
id: 'ven_001',
|
||||||
|
type: 'vendor',
|
||||||
|
username: 'abc_supply',
|
||||||
|
email: 'sales@abcsupply.com',
|
||||||
|
password: 'password',
|
||||||
|
role: 'VENDOR',
|
||||||
|
name: 'ABC Supply Co.',
|
||||||
|
companyName: 'ABC Supply',
|
||||||
|
tradeType: 'materials',
|
||||||
|
phone: '972-555-8888'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- NEW MOCK SALES HISTORY FOR LEADERBOARD ---
|
// --- NEW MOCK SALES HISTORY FOR LEADERBOARD ---
|
||||||
@@ -821,6 +887,539 @@ function generateMockMeetings() {
|
|||||||
|
|
||||||
const MOCK_MEETINGS = generateMockMeetings();
|
const MOCK_MEETINGS = generateMockMeetings();
|
||||||
|
|
||||||
|
// --- NEW OWNERS BOX DATA SCHEMAS ---
|
||||||
|
|
||||||
|
const MOCK_PERSONNEL = [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
name: 'Jesus Gonzales',
|
||||||
|
role: 'W2_EMPLOYEE',
|
||||||
|
email: 'jesus@lynkeduppro.com',
|
||||||
|
phone: '214-555-0101',
|
||||||
|
team: 'Sales Team A',
|
||||||
|
manager: 'Justin Owner',
|
||||||
|
sensitiveData: {
|
||||||
|
ssn: '123-45-6789',
|
||||||
|
bankAccount: '9876543210'
|
||||||
|
},
|
||||||
|
compliance: {
|
||||||
|
w9Signed: true,
|
||||||
|
payPlanSigned: true,
|
||||||
|
driverLicense: {
|
||||||
|
uploaded: true,
|
||||||
|
expirationDate: '2026-08-15',
|
||||||
|
status: 'compliant'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: 'active'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
name: 'Sarah Sales',
|
||||||
|
role: '1099_SALES_REP',
|
||||||
|
email: 'sarah@lynkeduppro.com',
|
||||||
|
phone: '214-555-0102',
|
||||||
|
team: 'Sales Team A',
|
||||||
|
manager: 'Jesus Gonzales',
|
||||||
|
sensitiveData: {
|
||||||
|
ssn: '987-65-4321',
|
||||||
|
bankAccount: '1234567890'
|
||||||
|
},
|
||||||
|
compliance: {
|
||||||
|
w9Signed: true,
|
||||||
|
payPlanSigned: false,
|
||||||
|
driverLicense: {
|
||||||
|
uploaded: true,
|
||||||
|
expirationDate: '2025-12-01',
|
||||||
|
status: 'expiring'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: 'active'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_VENDORS = [
|
||||||
|
{
|
||||||
|
id: 'v1',
|
||||||
|
vendorName: 'Texas Roofing Crew A',
|
||||||
|
vendorType: 'roofing_crew',
|
||||||
|
primaryContact: {
|
||||||
|
name: 'Juan Perez',
|
||||||
|
email: 'juan@texasroofing.com',
|
||||||
|
phone: '469-555-0201'
|
||||||
|
},
|
||||||
|
sensitiveData: {
|
||||||
|
ein: '12-3456789',
|
||||||
|
bankAccount: '1122334455'
|
||||||
|
},
|
||||||
|
compliance: {
|
||||||
|
w9: { uploaded: true, status: 'approved' },
|
||||||
|
subcontractorAgreement: { uploaded: true, status: 'approved' },
|
||||||
|
coi: {
|
||||||
|
uploaded: true,
|
||||||
|
expirationDate: '2026-06-30',
|
||||||
|
status: 'compliant'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
spend: {
|
||||||
|
totalSpend: 154000,
|
||||||
|
lastPayment: { date: '2026-02-01', amount: 12500 },
|
||||||
|
ytdSpend: 45000,
|
||||||
|
pendingInvoices: 2500
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
rating: 4.8,
|
||||||
|
onTimeRate: 0.95,
|
||||||
|
jobsCompleted: 34,
|
||||||
|
activeJobs: 2
|
||||||
|
},
|
||||||
|
status: 'active'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'v2',
|
||||||
|
vendorName: 'Sparky Electric',
|
||||||
|
vendorType: 'electrical',
|
||||||
|
primaryContact: {
|
||||||
|
name: 'Mike Spark',
|
||||||
|
email: 'mike@sparky.com',
|
||||||
|
phone: '469-555-0202'
|
||||||
|
},
|
||||||
|
sensitiveData: {
|
||||||
|
ein: '98-7654321',
|
||||||
|
bankAccount: '5544332211'
|
||||||
|
},
|
||||||
|
compliance: {
|
||||||
|
w9: { uploaded: true, status: 'approved' },
|
||||||
|
subcontractorAgreement: { uploaded: false, status: 'pending' },
|
||||||
|
coi: {
|
||||||
|
uploaded: true,
|
||||||
|
expirationDate: '2026-01-15',
|
||||||
|
status: 'expired'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
spend: {
|
||||||
|
totalSpend: 4500,
|
||||||
|
lastPayment: { date: '2025-12-15', amount: 800 },
|
||||||
|
ytdSpend: 0,
|
||||||
|
pendingInvoices: 0
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
rating: 4.2,
|
||||||
|
onTimeRate: 0.88,
|
||||||
|
jobsCompleted: 5,
|
||||||
|
activeJobs: 0
|
||||||
|
},
|
||||||
|
status: 'non_compliant'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'v3',
|
||||||
|
vendorName: 'ABC Supply Co.',
|
||||||
|
vendorType: 'material_supplier',
|
||||||
|
primaryContact: {
|
||||||
|
name: 'Sales Desk',
|
||||||
|
email: 'sales@abcsupply.com',
|
||||||
|
phone: '972-555-8888'
|
||||||
|
},
|
||||||
|
sensitiveData: {
|
||||||
|
ein: '55-4433221',
|
||||||
|
bankAccount: '9988776655'
|
||||||
|
},
|
||||||
|
compliance: {
|
||||||
|
w9: { uploaded: true, status: 'approved' },
|
||||||
|
subcontractorAgreement: { uploaded: true, status: 'approved' },
|
||||||
|
coi: { uploaded: true, status: 'compliant', expirationDate: '2027-01-01' }
|
||||||
|
},
|
||||||
|
spend: {
|
||||||
|
totalSpend: 250000,
|
||||||
|
lastPayment: { date: '2026-02-10', amount: 15000 },
|
||||||
|
ytdSpend: 85000,
|
||||||
|
pendingInvoices: 12000
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
rating: 5.0,
|
||||||
|
onTimeRate: 0.99,
|
||||||
|
jobsCompleted: 100, // Deliveries
|
||||||
|
activeJobs: 5
|
||||||
|
},
|
||||||
|
status: 'active'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_ORDERS = [
|
||||||
|
{
|
||||||
|
id: 'ORD-001',
|
||||||
|
vendorId: 'v3',
|
||||||
|
projectId: 'proj_001',
|
||||||
|
projectAddress: '2604 Dunwick Dr, Plano, TX 75023',
|
||||||
|
orderDate: '2026-02-10',
|
||||||
|
dueDate: '2026-02-20',
|
||||||
|
deliveryDate: null,
|
||||||
|
status: 'shipped',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item_001',
|
||||||
|
description: 'Roofing Shingles - Architectural',
|
||||||
|
quantity: 50,
|
||||||
|
unit: 'bundles',
|
||||||
|
unitPrice: 45.00,
|
||||||
|
total: 2250.00,
|
||||||
|
specifications: 'GAF Timberline HDZ, Charcoal'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item_002',
|
||||||
|
description: 'Roofing Underlayment',
|
||||||
|
quantity: 10,
|
||||||
|
unit: 'rolls',
|
||||||
|
unitPrice: 85.00,
|
||||||
|
total: 850.00,
|
||||||
|
specifications: 'Synthetic, 10 sq per roll'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
subtotal: 3100.00,
|
||||||
|
tax: 248.00,
|
||||||
|
total: 3348.00,
|
||||||
|
deliveryAddress: {
|
||||||
|
street: '2604 Dunwick Dr',
|
||||||
|
city: 'Plano',
|
||||||
|
state: 'TX',
|
||||||
|
zip: '75023',
|
||||||
|
instructions: 'Deliver to side yard, call foreman on arrival'
|
||||||
|
},
|
||||||
|
timeline: [
|
||||||
|
{ date: '2026-02-10', event: 'Order Placed', status: 'completed' },
|
||||||
|
{ date: '2026-02-11', event: 'Order Confirmed', status: 'completed' },
|
||||||
|
{ date: '2026-02-15', event: 'Shipped', status: 'completed' },
|
||||||
|
{ date: '2026-02-20', event: 'Estimated Delivery', status: 'pending' }
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ id: 'doc_001', type: 'PO', name: 'PO-2604-Dunwick.pdf', url: '#' },
|
||||||
|
{ id: 'doc_002', type: 'Invoice', name: 'INV-001.pdf', url: '#' }
|
||||||
|
],
|
||||||
|
notes: 'Rush order for weather window',
|
||||||
|
contactPerson: 'John Smith',
|
||||||
|
contactPhone: '469-555-0123'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ORD-002',
|
||||||
|
vendorId: 'v3',
|
||||||
|
projectId: 'proj_002',
|
||||||
|
projectAddress: 'Plano, TX 75023',
|
||||||
|
orderDate: '2026-02-05',
|
||||||
|
dueDate: '2026-02-15',
|
||||||
|
deliveryDate: '2026-02-14',
|
||||||
|
status: 'delivered',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item_003',
|
||||||
|
description: 'Gutter System - Seamless Aluminum',
|
||||||
|
quantity: 150,
|
||||||
|
unit: 'linear feet',
|
||||||
|
unitPrice: 12.50,
|
||||||
|
total: 1875.00,
|
||||||
|
specifications: 'White, 5-inch K-style'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
subtotal: 1875.00,
|
||||||
|
tax: 150.00,
|
||||||
|
total: 2025.00,
|
||||||
|
deliveryAddress: {
|
||||||
|
street: 'Gutter Replacement Project',
|
||||||
|
city: 'Plano',
|
||||||
|
state: 'TX',
|
||||||
|
zip: '75023',
|
||||||
|
instructions: 'Standard delivery'
|
||||||
|
},
|
||||||
|
timeline: [
|
||||||
|
{ date: '2026-02-05', event: 'Order Placed', status: 'completed' },
|
||||||
|
{ date: '2026-02-06', event: 'Order Confirmed', status: 'completed' },
|
||||||
|
{ date: '2026-02-12', event: 'Shipped', status: 'completed' },
|
||||||
|
{ date: '2026-02-14', event: 'Delivered', status: 'completed' }
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ id: 'doc_003', type: 'PO', name: 'PO-Gutter-Project.pdf', url: '#' },
|
||||||
|
{ id: 'doc_004', type: 'Delivery Receipt', name: 'DR-002.pdf', url: '#' }
|
||||||
|
],
|
||||||
|
notes: null,
|
||||||
|
contactPerson: 'Mike Johnson',
|
||||||
|
contactPhone: '469-555-0456'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ORD-003',
|
||||||
|
vendorId: 'v3',
|
||||||
|
projectId: 'proj_003',
|
||||||
|
projectAddress: 'Dallas, TX 75201',
|
||||||
|
orderDate: '2026-02-12',
|
||||||
|
dueDate: '2026-02-25',
|
||||||
|
deliveryDate: null,
|
||||||
|
status: 'confirmed',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item_004',
|
||||||
|
description: 'Roofing Nails - Coil',
|
||||||
|
quantity: 20,
|
||||||
|
unit: 'boxes',
|
||||||
|
unitPrice: 35.00,
|
||||||
|
total: 700.00,
|
||||||
|
specifications: '1-1/4 inch, galvanized'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'item_005',
|
||||||
|
description: 'Flashing - Step',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'pieces',
|
||||||
|
unitPrice: 4.50,
|
||||||
|
total: 450.00,
|
||||||
|
specifications: 'Aluminum, 8-inch'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
subtotal: 1150.00,
|
||||||
|
tax: 92.00,
|
||||||
|
total: 1242.00,
|
||||||
|
deliveryAddress: {
|
||||||
|
street: 'Commercial Building',
|
||||||
|
city: 'Dallas',
|
||||||
|
state: 'TX',
|
||||||
|
zip: '75201',
|
||||||
|
instructions: 'Loading dock access required'
|
||||||
|
},
|
||||||
|
timeline: [
|
||||||
|
{ date: '2026-02-12', event: 'Order Placed', status: 'completed' },
|
||||||
|
{ date: '2026-02-13', event: 'Order Confirmed', status: 'completed' },
|
||||||
|
{ date: '2026-02-20', event: 'Estimated Ship Date', status: 'pending' },
|
||||||
|
{ date: '2026-02-25', event: 'Estimated Delivery', status: 'pending' }
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ id: 'doc_005', type: 'PO', name: 'PO-Dallas-Commercial.pdf', url: '#' }
|
||||||
|
],
|
||||||
|
notes: 'Confirm delivery time 24hrs in advance',
|
||||||
|
contactPerson: 'Sarah Williams',
|
||||||
|
contactPhone: '214-555-0789'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_VENDOR_INVOICES = [
|
||||||
|
{
|
||||||
|
id: 'INV-2026-001',
|
||||||
|
vendorId: 'v3',
|
||||||
|
orderId: 'ORD-002',
|
||||||
|
projectId: 'proj_002',
|
||||||
|
project: 'Plano, TX 75023',
|
||||||
|
invoiceDate: '2026-02-14',
|
||||||
|
dueDate: '2026-03-01',
|
||||||
|
amount: 2025.00,
|
||||||
|
status: 'paid',
|
||||||
|
paymentTerms: 'Net 15',
|
||||||
|
description: 'Gutter System Delivery'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'INV-2026-002',
|
||||||
|
vendorId: 'v3',
|
||||||
|
orderId: 'ORD-001',
|
||||||
|
projectId: 'proj_001',
|
||||||
|
project: '2604 Dunwick Dr, Plano, TX 75023',
|
||||||
|
invoiceDate: '2026-02-15',
|
||||||
|
dueDate: '2026-03-02',
|
||||||
|
amount: 3348.00,
|
||||||
|
status: 'pending',
|
||||||
|
paymentTerms: 'Net 15',
|
||||||
|
description: 'Roofing Materials Delivery'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'INV-2026-003',
|
||||||
|
vendorId: 'v3',
|
||||||
|
orderId: 'ORD-003',
|
||||||
|
projectId: 'proj_003',
|
||||||
|
project: 'Dallas, TX 75201',
|
||||||
|
invoiceDate: '2026-02-13',
|
||||||
|
dueDate: '2026-02-28',
|
||||||
|
amount: 1242.00,
|
||||||
|
status: 'pending',
|
||||||
|
paymentTerms: 'Net 15',
|
||||||
|
description: 'Roofing Accessories'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'INV-2025-045',
|
||||||
|
vendorId: 'v3',
|
||||||
|
orderId: 'ORD-OLD-001',
|
||||||
|
projectId: 'proj_old_001',
|
||||||
|
project: 'Frisco, TX 75034',
|
||||||
|
invoiceDate: '2025-12-20',
|
||||||
|
dueDate: '2026-01-05',
|
||||||
|
amount: 4500.00,
|
||||||
|
status: 'paid',
|
||||||
|
paymentTerms: 'Net 15',
|
||||||
|
description: 'Complete Roofing Package'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_DOCUMENTS = [
|
||||||
|
{
|
||||||
|
id: 'd1',
|
||||||
|
entityId: 'p1',
|
||||||
|
entityType: 'personnel',
|
||||||
|
entityName: 'Jesus Gonzales',
|
||||||
|
documentType: 'DL',
|
||||||
|
fileName: 'JG_DriverLicense.pdf',
|
||||||
|
fileUrl: '#',
|
||||||
|
uploadedBy: 'Jesus Gonzales',
|
||||||
|
uploadedAt: '2025-08-15',
|
||||||
|
expirationDate: '2026-08-15',
|
||||||
|
status: 'approved',
|
||||||
|
aiConfidence: 0.98,
|
||||||
|
reviewedBy: 'Admin',
|
||||||
|
reviewedAt: '2025-08-16'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'd2',
|
||||||
|
entityId: 'v2',
|
||||||
|
entityType: 'vendor',
|
||||||
|
entityName: 'Sparky Electric',
|
||||||
|
documentType: 'COI',
|
||||||
|
fileName: 'Sparky_COI_2025.pdf',
|
||||||
|
fileUrl: '#',
|
||||||
|
uploadedBy: 'Mike Spark',
|
||||||
|
uploadedAt: '2025-01-10',
|
||||||
|
expirationDate: '2026-01-15',
|
||||||
|
status: 'expired',
|
||||||
|
aiConfidence: 0.95,
|
||||||
|
reviewedBy: 'Admin',
|
||||||
|
reviewedAt: '2025-01-11'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'd3',
|
||||||
|
entityId: 'p2',
|
||||||
|
entityType: 'personnel',
|
||||||
|
entityName: 'Sarah Sales',
|
||||||
|
documentType: 'PAY_PLAN',
|
||||||
|
fileName: 'Sarah_PayPlan_Signed.pdf',
|
||||||
|
fileUrl: '#',
|
||||||
|
uploadedBy: 'Sarah Sales',
|
||||||
|
uploadedAt: '2026-02-14',
|
||||||
|
expirationDate: null,
|
||||||
|
status: 'pending_review',
|
||||||
|
aiConfidence: 0.88,
|
||||||
|
reviewedBy: null,
|
||||||
|
reviewedAt: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOCK_PROJECTS = [
|
||||||
|
{
|
||||||
|
id: 'proj_1',
|
||||||
|
propertyId: 'P-2602',
|
||||||
|
address: '2604 Dunwick Dr, Plano, TX 75023',
|
||||||
|
contractorId: 'con_001',
|
||||||
|
subcontractorIds: ['sub_001'],
|
||||||
|
projectType: 'Roof Replacement',
|
||||||
|
status: 'active',
|
||||||
|
budget: 25000,
|
||||||
|
spent: 12500,
|
||||||
|
margin: 0.35,
|
||||||
|
startDate: '2026-02-01',
|
||||||
|
endDate: '2026-02-15',
|
||||||
|
completionPercentage: 65,
|
||||||
|
milestones: [
|
||||||
|
{
|
||||||
|
id: 'ms_1',
|
||||||
|
name: 'Material Delivery',
|
||||||
|
dueDate: '2026-02-02',
|
||||||
|
status: 'completed',
|
||||||
|
assignedTo: 'ven_001' // Supplier
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ms_2',
|
||||||
|
name: 'Roof Tear-off',
|
||||||
|
dueDate: '2026-02-04',
|
||||||
|
status: 'completed',
|
||||||
|
assignedTo: 'con_001'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ms_3',
|
||||||
|
name: 'Dry-In Inspection',
|
||||||
|
dueDate: '2026-02-06',
|
||||||
|
status: 'completed',
|
||||||
|
assignedTo: 'con_001'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ms_4',
|
||||||
|
name: 'Shingle Install',
|
||||||
|
dueDate: '2026-02-10',
|
||||||
|
status: 'in_progress',
|
||||||
|
assignedTo: 'con_001'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ms_5',
|
||||||
|
name: 'Final Inspection',
|
||||||
|
dueDate: '2026-02-14',
|
||||||
|
status: 'pending',
|
||||||
|
assignedTo: 'con_001'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
invoices: [
|
||||||
|
{
|
||||||
|
id: 'inv_1',
|
||||||
|
amount: 10000,
|
||||||
|
submittedBy: 'con_001',
|
||||||
|
status: 'paid',
|
||||||
|
dueDate: '2026-02-05',
|
||||||
|
datePaid: '2026-02-06'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inv_2',
|
||||||
|
amount: 5000,
|
||||||
|
submittedBy: 'sub_001',
|
||||||
|
status: 'pending',
|
||||||
|
dueDate: '2026-02-10'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inv_3',
|
||||||
|
amount: 12000,
|
||||||
|
submittedBy: 'ven_001', // ABC Supply
|
||||||
|
status: 'pending',
|
||||||
|
dueDate: '2026-02-12'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ name: 'Scope of Work', url: '#', type: 'contract' },
|
||||||
|
{ name: 'Permit #22-401', url: '#', type: 'permit' },
|
||||||
|
{ name: 'Material Order', url: '#', type: 'invoice' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proj_2',
|
||||||
|
propertyId: 'P-2608',
|
||||||
|
address: '2617 Rothland Ln, Plano, TX 75023',
|
||||||
|
contractorId: 'con_001',
|
||||||
|
subcontractorIds: [],
|
||||||
|
projectType: 'Gutter Replacement',
|
||||||
|
status: 'scheduled',
|
||||||
|
budget: 4500,
|
||||||
|
spent: 0,
|
||||||
|
margin: 0.40,
|
||||||
|
startDate: '2026-02-20',
|
||||||
|
endDate: '2026-02-22',
|
||||||
|
completionPercentage: 0,
|
||||||
|
milestones: [
|
||||||
|
{
|
||||||
|
id: 'ms_2_1',
|
||||||
|
name: 'Material Delivery',
|
||||||
|
dueDate: '2026-02-19',
|
||||||
|
status: 'pending',
|
||||||
|
assignedTo: 'ven_001'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ms_2_2',
|
||||||
|
name: 'Installation',
|
||||||
|
dueDate: '2026-02-20',
|
||||||
|
status: 'pending',
|
||||||
|
assignedTo: 'con_001'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
invoices: [],
|
||||||
|
documents: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
// --- CONTEXT SETUP ---
|
// --- CONTEXT SETUP ---
|
||||||
|
|
||||||
const MockStoreContext = createContext();
|
const MockStoreContext = createContext();
|
||||||
@@ -831,6 +1430,14 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
const [meetings, setMeetings] = useState(MOCK_MEETINGS);
|
const [meetings, setMeetings] = useState(MOCK_MEETINGS);
|
||||||
const [salesHistory, setSalesHistory] = useState(MOCK_SALES_HISTORY);
|
const [salesHistory, setSalesHistory] = useState(MOCK_SALES_HISTORY);
|
||||||
|
|
||||||
|
// New Data States
|
||||||
|
const [personnel, setPersonnel] = useState(MOCK_PERSONNEL);
|
||||||
|
const [vendors, setVendors] = useState(MOCK_VENDORS);
|
||||||
|
const [documents, setDocuments] = useState(MOCK_DOCUMENTS);
|
||||||
|
const [projects, setProjects] = useState(MOCK_PROJECTS);
|
||||||
|
const [orders, setOrders] = useState(MOCK_ORDERS);
|
||||||
|
const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES);
|
||||||
|
|
||||||
// Initialize properties once
|
// Initialize properties once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const data = generateProperties();
|
const data = generateProperties();
|
||||||
@@ -908,6 +1515,15 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
users,
|
users,
|
||||||
meetings,
|
meetings,
|
||||||
salesHistory, // Exported for Leaderboard
|
salesHistory, // Exported for Leaderboard
|
||||||
|
|
||||||
|
// New Owners Box Data
|
||||||
|
personnel,
|
||||||
|
vendors,
|
||||||
|
documents,
|
||||||
|
projects,
|
||||||
|
orders,
|
||||||
|
vendorInvoices,
|
||||||
|
|
||||||
updatePropertyStatus,
|
updatePropertyStatus,
|
||||||
addMeeting,
|
addMeeting,
|
||||||
updateUser: (updatedUser) => {
|
updateUser: (updatedUser) => {
|
||||||
|
|||||||
+50
-40
@@ -2,6 +2,7 @@ import React, { useState, useMemo, useEffect, useRef } from 'react';
|
|||||||
import { useMockStore } from '../data/mockStore';
|
import { useMockStore } from '../data/mockStore';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import { Calendar, User, Clock, MapPin, MoreHorizontal, Filter, X, ChevronDown } from 'lucide-react';
|
import { Calendar, User, Clock, MapPin, MoreHorizontal, Filter, X, ChevronDown } from 'lucide-react';
|
||||||
|
import { SpotlightCard } from '../components/SpotlightCard';
|
||||||
|
|
||||||
const AdminSchedule = () => {
|
const AdminSchedule = () => {
|
||||||
const { meetings } = useMockStore();
|
const { meetings } = useMockStore();
|
||||||
@@ -122,19 +123,27 @@ const AdminSchedule = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-7xl mx-auto">
|
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white selection:bg-blue-500/30 relative pb-20 transition-colors duration-300">
|
||||||
<header className="mb-8">
|
{/* Ambient Background Glows */}
|
||||||
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white mb-2">Team Schedule</h1>
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
<p className="text-zinc-600 dark:text-slate-400">Manage field agent appointments</p>
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 p-8 max-w-7xl mx-auto space-y-8">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 mb-2 tracking-tight">Team Schedule</h1>
|
||||||
|
<p className="text-zinc-600 dark:text-zinc-400">Manage field agent appointments</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Filters Section */}
|
{/* Filters Section */}
|
||||||
<div className="mb-6 flex flex-wrap gap-4 items-center">
|
<div className="flex flex-wrap gap-4 items-center">
|
||||||
{/* Date Filter */}
|
{/* Date Filter */}
|
||||||
<div className="relative" ref={dateDropdownRef}>
|
<div className="relative" ref={dateDropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDateDropdown(!showDateDropdown)}
|
onClick={() => setShowDateDropdown(!showDateDropdown)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-slate-800 border border-zinc-200 dark:border-slate-700 rounded-lg text-sm font-medium text-zinc-700 dark:text-slate-200 hover:bg-zinc-50 dark:hover:bg-slate-700 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/[0.04] border border-zinc-200 dark:border-white/10 rounded-lg text-sm font-medium text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/[0.07] transition-colors"
|
||||||
>
|
>
|
||||||
<Calendar size={16} />
|
<Calendar size={16} />
|
||||||
{dateFilter === 'all' ? 'All Dates' :
|
{dateFilter === 'all' ? 'All Dates' :
|
||||||
@@ -147,51 +156,51 @@ const AdminSchedule = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showDateDropdown && (
|
{showDateDropdown && (
|
||||||
<div className="absolute top-full mt-2 left-0 bg-white dark:bg-slate-800 border border-zinc-200 dark:border-slate-700 rounded-lg shadow-xl z-50 min-w-[200px]">
|
<div className="absolute top-full mt-2 left-0 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700/50 rounded-lg backdrop-blur-xl shadow-2xl z-50 min-w-[200px]">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setDateFilter('all'); setShowDateDropdown(false); }}
|
onClick={() => { setDateFilter('all'); setShowDateDropdown(false); }}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
>
|
>
|
||||||
All Dates
|
All Dates
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setDateFilter('today'); setShowDateDropdown(false); }}
|
onClick={() => { setDateFilter('today'); setShowDateDropdown(false); }}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
>
|
>
|
||||||
Today
|
Today
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setDateFilter('week'); setShowDateDropdown(false); }}
|
onClick={() => { setDateFilter('week'); setShowDateDropdown(false); }}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
>
|
>
|
||||||
This Week
|
This Week
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setDateFilter('month'); setShowDateDropdown(false); }}
|
onClick={() => { setDateFilter('month'); setShowDateDropdown(false); }}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
>
|
>
|
||||||
This Month
|
This Month
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setDateFilter('quarter'); setShowDateDropdown(false); }}
|
onClick={() => { setDateFilter('quarter'); setShowDateDropdown(false); }}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
>
|
>
|
||||||
This Quarter
|
This Quarter
|
||||||
</button>
|
</button>
|
||||||
<div className="border-t border-zinc-200 dark:border-slate-700 p-3">
|
<div className="border-t border-zinc-200 dark:border-zinc-700/50 p-3">
|
||||||
<p className="text-xs font-semibold text-zinc-500 dark:text-slate-400 mb-2">Custom Range</p>
|
<p className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 mb-2">Custom Range</p>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={customDateRange.start}
|
value={customDateRange.start}
|
||||||
onChange={(e) => setCustomDateRange(prev => ({ ...prev, start: e.target.value }))}
|
onChange={(e) => setCustomDateRange(prev => ({ ...prev, start: e.target.value }))}
|
||||||
className="w-full px-2 py-1 text-xs bg-white dark:bg-slate-900 border border-zinc-200 dark:border-slate-600 rounded mb-2 text-zinc-900 dark:text-white"
|
className="w-full px-2 py-1 text-xs bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded mb-2 text-zinc-900 dark:text-white"
|
||||||
placeholder="Start date"
|
placeholder="Start date"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={customDateRange.end}
|
value={customDateRange.end}
|
||||||
onChange={(e) => setCustomDateRange(prev => ({ ...prev, end: e.target.value }))}
|
onChange={(e) => setCustomDateRange(prev => ({ ...prev, end: e.target.value }))}
|
||||||
className="w-full px-2 py-1 text-xs bg-white dark:bg-slate-900 border border-zinc-200 dark:border-slate-600 rounded mb-2 text-zinc-900 dark:text-white"
|
className="w-full px-2 py-1 text-xs bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded mb-2 text-zinc-900 dark:text-white"
|
||||||
placeholder="End date"
|
placeholder="End date"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -210,7 +219,7 @@ const AdminSchedule = () => {
|
|||||||
<div className="relative" ref={statusDropdownRef}>
|
<div className="relative" ref={statusDropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowStatusDropdown(!showStatusDropdown)}
|
onClick={() => setShowStatusDropdown(!showStatusDropdown)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-slate-800 border border-zinc-200 dark:border-slate-700 rounded-lg text-sm font-medium text-zinc-700 dark:text-slate-200 hover:bg-zinc-50 dark:hover:bg-slate-700 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/[0.04] border border-zinc-200 dark:border-white/10 rounded-lg text-sm font-medium text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/[0.07] transition-colors"
|
||||||
>
|
>
|
||||||
<Filter size={16} />
|
<Filter size={16} />
|
||||||
Status {statusFilter.length > 0 && `(${statusFilter.length})`}
|
Status {statusFilter.length > 0 && `(${statusFilter.length})`}
|
||||||
@@ -218,17 +227,17 @@ const AdminSchedule = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showStatusDropdown && (
|
{showStatusDropdown && (
|
||||||
<div className="absolute top-full mt-2 left-0 bg-white dark:bg-slate-800 border border-zinc-200 dark:border-slate-700 rounded-lg shadow-xl z-50 min-w-[200px]">
|
<div className="absolute top-full mt-2 left-0 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700/50 rounded-lg backdrop-blur-xl shadow-2xl z-50 min-w-[200px]">
|
||||||
{availableStatuses.map(status => (
|
{availableStatuses.map(status => (
|
||||||
<label
|
<label
|
||||||
key={status}
|
key={status}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 cursor-pointer transition-colors"
|
className="flex items-center gap-2 px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={statusFilter.includes(status)}
|
checked={statusFilter.includes(status)}
|
||||||
onChange={() => toggleStatusFilter(status)}
|
onChange={() => toggleStatusFilter(status)}
|
||||||
className="rounded border-zinc-300 dark:border-slate-600"
|
className="rounded border-zinc-300 dark:border-zinc-600"
|
||||||
/>
|
/>
|
||||||
{status}
|
{status}
|
||||||
</label>
|
</label>
|
||||||
@@ -249,13 +258,13 @@ const AdminSchedule = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results Count */}
|
{/* Results Count */}
|
||||||
<div className="ml-auto text-sm text-zinc-600 dark:text-slate-400">
|
<div className="ml-auto text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
Showing {filteredMeetings.length} of {meetings.length} appointments
|
Showing {filteredMeetings.length} of {meetings.length} appointments
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-slate-900 border border-zinc-200 dark:border-slate-800 rounded-3xl shadow-xl overflow-hidden">
|
<SpotlightCard className="overflow-hidden">
|
||||||
<div className="p-6 border-b border-zinc-200 dark:border-slate-800">
|
<div className="p-6 border-b border-zinc-200 dark:border-zinc-800">
|
||||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white flex items-center">
|
<h2 className="text-xl font-bold text-zinc-900 dark:text-white flex items-center">
|
||||||
<Calendar size={20} className="text-blue-500 mr-2" />
|
<Calendar size={20} className="text-blue-500 mr-2" />
|
||||||
All Active Appointments
|
All Active Appointments
|
||||||
@@ -263,8 +272,8 @@ const AdminSchedule = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm text-zinc-600 dark:text-slate-400">
|
<table className="w-full text-left text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
<thead className="bg-zinc-100 dark:bg-slate-950 text-zinc-700 dark:text-slate-200 uppercase font-bold text-xs">
|
<thead className="bg-zinc-100 dark:bg-zinc-950 text-zinc-700 dark:text-zinc-200 uppercase font-bold text-xs">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 min-w-[150px]">Agent</th>
|
<th className="px-6 py-4 min-w-[150px]">Agent</th>
|
||||||
<th className="px-6 py-4 min-w-[180px]">Customer</th>
|
<th className="px-6 py-4 min-w-[180px]">Customer</th>
|
||||||
@@ -274,30 +283,30 @@ const AdminSchedule = () => {
|
|||||||
<th className="px-6 py-4 text-right min-w-[150px]">Actions</th>
|
<th className="px-6 py-4 text-right min-w-[150px]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-200 dark:divide-slate-800">
|
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||||
{filteredMeetings.length === 0 ? (
|
{filteredMeetings.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan="6" className="px-6 py-12 text-center text-zinc-500 dark:text-slate-400">
|
<td colSpan="6" className="px-6 py-12 text-center text-zinc-500 dark:text-zinc-400">
|
||||||
No appointments found matching the selected filters.
|
No appointments found matching the selected filters.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredMeetings.map((meeting) => (
|
filteredMeetings.map((meeting) => (
|
||||||
<tr key={meeting.id} className="hover:bg-zinc-50 dark:hover:bg-slate-800/50 transition-colors">
|
<tr key={meeting.id} className="hover:bg-zinc-50 dark:hover:bg-white/[0.03] transition-colors">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-slate-700 flex items-center justify-center text-zinc-700 dark:text-slate-300 font-bold text-xs">
|
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-zinc-700 flex items-center justify-center text-zinc-700 dark:text-zinc-300 font-bold text-xs">
|
||||||
{meeting.agentId}
|
{meeting.agentId}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-zinc-900 dark:text-slate-200 whitespace-normal">Agent {meeting.agentId}</span>
|
<span className="font-medium text-zinc-900 dark:text-zinc-200 whitespace-normal">Agent {meeting.agentId}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-zinc-900 dark:text-slate-300 whitespace-normal">{meeting.customerName}</td>
|
<td className="px-6 py-4 text-zinc-900 dark:text-zinc-300 whitespace-normal">{meeting.customerName}</td>
|
||||||
<td className="px-6 py-4 text-zinc-700 dark:text-slate-400">{meeting.propertyId}</td>
|
<td className="px-6 py-4 text-zinc-700 dark:text-zinc-400">{meeting.propertyId}</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-zinc-900 dark:text-slate-200">{meeting.date}</span>
|
<span className="text-zinc-900 dark:text-zinc-200">{meeting.date}</span>
|
||||||
<span className="text-xs text-zinc-600 dark:text-slate-400">{meeting.time}</span>
|
<span className="text-xs text-zinc-600 dark:text-zinc-400">{meeting.time}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
@@ -315,31 +324,31 @@ const AdminSchedule = () => {
|
|||||||
<button className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 text-xs font-bold">
|
<button className="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 text-xs font-bold">
|
||||||
Reschedule
|
Reschedule
|
||||||
</button>
|
</button>
|
||||||
<div className="relative">
|
<div className="relative action-dropdown-container">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpenDropdownId(openDropdownId === meeting.id ? null : meeting.id)}
|
onClick={() => setOpenDropdownId(openDropdownId === meeting.id ? null : meeting.id)}
|
||||||
className="text-zinc-600 dark:text-slate-400 hover:text-zinc-900 dark:hover:text-slate-200 transition-colors"
|
className="text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-200 transition-colors"
|
||||||
>
|
>
|
||||||
<MoreHorizontal size={16} />
|
<MoreHorizontal size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{openDropdownId === meeting.id && (
|
{openDropdownId === meeting.id && (
|
||||||
<div className="absolute right-0 top-full mt-1 bg-white dark:bg-slate-800 border border-zinc-200 dark:border-slate-700 rounded-lg shadow-xl z-50 min-w-[160px]">
|
<div className="absolute right-0 top-full mt-1 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700/50 rounded-lg backdrop-blur-xl shadow-2xl z-50 min-w-[160px]">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleActionClick(meeting.id, 'view')}
|
onClick={() => handleActionClick(meeting.id, 'view')}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
>
|
>
|
||||||
View Details
|
View Details
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleActionClick(meeting.id, 'reschedule')}
|
onClick={() => handleActionClick(meeting.id, 'reschedule')}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
>
|
>
|
||||||
Reschedule
|
Reschedule
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleActionClick(meeting.id, 'complete')}
|
onClick={() => handleActionClick(meeting.id, 'complete')}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-slate-200 hover:bg-zinc-100 dark:hover:bg-slate-700 transition-colors"
|
className="w-full text-left px-4 py-2 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
>
|
>
|
||||||
Mark Complete
|
Mark Complete
|
||||||
</button>
|
</button>
|
||||||
@@ -360,6 +369,7 @@ const AdminSchedule = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Chatbot from '../components/Chatbot';
|
||||||
|
|
||||||
|
const AiAssistantPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-zinc-50 dark:bg-black flex flex-col">
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<header className="p-4 md:p-6 border-b border-zinc-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-900/50 backdrop-blur-sm z-10">
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||||
|
<span className="w-2 h-6 bg-blue-500 rounded-full"></span>
|
||||||
|
AI Assistant
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 text-sm ml-4">Your dedicated AI concierge for detailed insights and assistance.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
<Chatbot inline={true} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AiAssistantPage;
|
||||||
+96
-68
@@ -2,14 +2,13 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import Chatbot from '../components/Chatbot';
|
import Chatbot from '../components/Chatbot';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import { ArrowRight, CheckCircle2, Home, Star, ShieldCheck, Clock, Award, TrendingDown, Users, MapPin, Phone, Mail, Instagram, Twitter, Youtube, Sun, Moon, LogOut, LayoutDashboard, User, Menu, X, ChevronDown } from 'lucide-react';
|
import { ArrowRight, CheckCircle2, Home, Star, ShieldCheck, Clock, Award, TrendingDown, Users, MapPin, Phone, Mail, Instagram, Twitter, Youtube, Sun, Moon, LogOut, LayoutDashboard, User, Menu, X, ChevronDown, Zap, Briefcase, Map, Tag } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { SpotlightCard } from '../components/SpotlightCard';
|
import { SpotlightCard } from '../components/SpotlightCard';
|
||||||
import { ComparisonSlider } from '../components/ComparisonSlider';
|
import { ComparisonSlider } from '../components/ComparisonSlider';
|
||||||
import Services from '../components/Services';
|
import Services from '../components/Services';
|
||||||
import IntelligenceMap from '../components/IntelligenceMap';
|
import IntelligenceMap from '../components/IntelligenceMap';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import gsap from 'gsap';
|
import gsap from 'gsap';
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
import Logo from '../assets/images/LynkedUp_Pro_F_logo_Y.png';
|
import Logo from '../assets/images/LynkedUp_Pro_F_logo_Y.png';
|
||||||
@@ -132,6 +131,20 @@ const Landing = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to determine dashboard path based on role
|
||||||
|
const getDashboardPath = (role) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'OWNER': return '/owner/snapshot';
|
||||||
|
case 'ADMIN': return '/admin/dashboard';
|
||||||
|
case 'CONTRACTOR': return '/contractor/dashboard';
|
||||||
|
case 'SUBCONTRACTOR': return '/subcontractor/dashboard';
|
||||||
|
case 'VENDOR': return '/vendor/dashboard';
|
||||||
|
case 'FIELD_AGENT': return '/emp/fa/dashboard';
|
||||||
|
case 'CUSTOMER': return '/portal/profile';
|
||||||
|
default: return '/login';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-[#050505] text-zinc-900 dark:text-white transition-colors duration-300 overflow-x-hidden selection:bg-emerald-500/30">
|
<div className="min-h-screen bg-zinc-50 dark:bg-[#050505] text-zinc-900 dark:text-white transition-colors duration-300 overflow-x-hidden selection:bg-emerald-500/30">
|
||||||
|
|
||||||
@@ -207,7 +220,7 @@ const Landing = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
to={user.role === 'CUSTOMER' ? '/portal/profile' : '/emp/fa/dashboard'}
|
to={getDashboardPath(user.role)}
|
||||||
className="px-5 py-2 rounded-lg text-sm font-bold bg-blue-500 text-white hover:bg-blue-600 transition-colors shadow-sm shadow-blue-500/25"
|
className="px-5 py-2 rounded-lg text-sm font-bold bg-blue-500 text-white hover:bg-blue-600 transition-colors shadow-sm shadow-blue-500/25"
|
||||||
>
|
>
|
||||||
{user.role === 'CUSTOMER' ? 'My Profile' : 'Dashboard'}
|
{user.role === 'CUSTOMER' ? 'My Profile' : 'Dashboard'}
|
||||||
@@ -226,16 +239,10 @@ const Landing = () => {
|
|||||||
{/* Mobile Menu Toggle */}
|
{/* Mobile Menu Toggle */}
|
||||||
<div className="flex md:hidden items-center gap-2">
|
<div className="flex md:hidden items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
|
||||||
className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors text-zinc-500 dark:text-zinc-400"
|
|
||||||
aria-label="Toggle Theme"
|
|
||||||
>
|
|
||||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="p-2 text-zinc-600 dark:text-zinc-400"
|
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors text-zinc-600 dark:text-zinc-400"
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
|
aria-expanded={isMobileMenuOpen}
|
||||||
>
|
>
|
||||||
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -243,84 +250,104 @@ const Landing = () => {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ── Mobile Full-Screen Overlay ── */}
|
{/* ── Mobile Backdrop ── */}
|
||||||
<AnimatePresence>
|
|
||||||
{isMobileMenuOpen && (
|
{isMobileMenuOpen && (
|
||||||
<motion.div
|
<div
|
||||||
initial={{ opacity: 0 }}
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100] md:hidden"
|
||||||
animate={{ opacity: 1 }}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
exit={{ opacity: 0 }}
|
aria-hidden="true"
|
||||||
transition={{ duration: 0.2 }}
|
/>
|
||||||
className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col"
|
)}
|
||||||
|
|
||||||
|
{/* ── Mobile Sidebar — slides from left, mirrors Layout.jsx pattern ── */}
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
fixed inset-y-0 left-0 z-[101] w-72 md:hidden
|
||||||
|
bg-white dark:bg-[#09090b] border-r border-zinc-200 dark:border-white/5
|
||||||
|
transition-transform duration-300 ease-in-out flex flex-col shadow-2xl
|
||||||
|
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
{/* Overlay Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 h-16">
|
<div className="h-16 flex items-center justify-between px-4 border-b border-zinc-100 dark:border-white/5 shrink-0">
|
||||||
<img src={Logo} alt="LynkedUp Pro" className="h-12 w-auto object-contain" />
|
<div className="flex items-center gap-2">
|
||||||
|
<img src={Logo} alt="LynkedUp Pro" className="h-10 w-auto object-contain" />
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className="p-2 text-zinc-600 dark:text-zinc-400"
|
className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 transition-colors"
|
||||||
aria-label="Close menu"
|
aria-label="Close menu"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overlay Links */}
|
{/* Nav Links */}
|
||||||
<motion.div
|
<nav className="flex-1 px-3 py-6 space-y-1 overflow-y-auto">
|
||||||
initial="closed"
|
|
||||||
animate="open"
|
|
||||||
variants={{ open: { transition: { staggerChildren: 0.06 } }, closed: {} }}
|
|
||||||
className="flex-1 flex flex-col items-center justify-center gap-2 px-6"
|
|
||||||
>
|
|
||||||
{[
|
{[
|
||||||
{ label: 'How It Works', id: 'how-it-works' },
|
{ label: 'How It Works', id: 'how-it-works', icon: Zap },
|
||||||
{ label: 'Services', id: 'services' },
|
{ label: 'Services', id: 'services', icon: Briefcase },
|
||||||
{ label: 'Intelligence', id: 'intelligence' },
|
{ label: 'Intelligence', id: 'intelligence', icon: Map },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<motion.button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
variants={{ closed: { opacity: 0, y: 20 }, open: { opacity: 1, y: 0 } }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
onClick={() => scrollToSection(item.id)}
|
onClick={() => scrollToSection(item.id)}
|
||||||
className="text-2xl font-bold text-zinc-900 dark:text-white py-3 hover:text-blue-500 transition-colors"
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/5 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
|
<item.icon size={18} className="shrink-0 text-zinc-400 dark:text-zinc-500" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</motion.button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<motion.div
|
|
||||||
variants={{ closed: { opacity: 0, y: 20 }, open: { opacity: 1, y: 0 } }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className="text-2xl font-bold text-zinc-900 dark:text-white py-3 hover:text-blue-500 transition-colors"
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/5 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
|
<Tag size={18} className="shrink-0 text-zinc-400 dark:text-zinc-500" />
|
||||||
Pricing
|
Pricing
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Overlay Bottom Actions */}
|
{/* Sign Up / Login nav item — visible only when logged out */}
|
||||||
<motion.div
|
{!user && (
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<div className="my-3 border-t border-zinc-100 dark:border-white/5" />
|
||||||
transition={{ delay: 0.3, duration: 0.3 }}
|
<Link
|
||||||
className="px-6 pb-10 space-y-3"
|
to="/login"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
|
<User size={18} className="shrink-0" />
|
||||||
|
Sign Up / Login
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t border-zinc-100 dark:border-white/5 space-y-3 shrink-0">
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 hover:text-amber-500 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium">Dark Mode</span>
|
||||||
|
{theme === 'dark' ? <Moon size={16} /> : <Sun size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* CTAs */}
|
||||||
{!user ? (
|
{!user ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className="block w-full text-center py-4 rounded-xl text-base font-bold bg-blue-500 text-white hover:bg-blue-600 transition-colors shadow-sm"
|
className="block w-full text-center py-3 rounded-xl text-sm font-bold bg-blue-500 text-white hover:bg-blue-600 transition-colors shadow-sm shadow-blue-500/25"
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to="/login"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className="block w-full text-center py-4 rounded-xl text-base font-bold border border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
className="block w-full text-center py-3 rounded-xl text-sm font-bold border border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
@@ -328,24 +355,23 @@ const Landing = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to={user.role === 'CUSTOMER' ? '/portal/profile' : '/emp/fa/dashboard'}
|
to={getDashboardPath(user.role)}
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className="block w-full text-center py-4 rounded-xl text-base font-bold bg-blue-500 text-white hover:bg-blue-600 transition-colors shadow-sm"
|
className="block w-full text-center py-3 rounded-xl text-sm font-bold bg-blue-500 text-white hover:bg-blue-600 transition-colors shadow-sm shadow-blue-500/25"
|
||||||
>
|
>
|
||||||
{user.role === 'CUSTOMER' ? 'My Profile' : 'Dashboard'}
|
{user.role === 'CUSTOMER' ? 'My Profile' : 'Dashboard'}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<Link
|
||||||
onClick={() => { useAuth().logout(); setIsMobileMenuOpen(false); }}
|
to="/login"
|
||||||
className="block w-full text-center py-4 rounded-xl text-base font-bold border border-red-200 dark:border-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors"
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className="block w-full text-center py-3 rounded-xl text-sm font-bold border border-red-200 dark:border-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors"
|
||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</aside>
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* ============================================ */}
|
{/* ============================================ */}
|
||||||
{/* HERO SECTION — "The Overhead View" */}
|
{/* HERO SECTION — "The Overhead View" */}
|
||||||
@@ -409,13 +435,15 @@ const Landing = () => {
|
|||||||
<ArrowRight size={18} className="ml-2 transition-transform group-hover:translate-x-0.5" />
|
<ArrowRight size={18} className="ml-2 transition-transform group-hover:translate-x-0.5" />
|
||||||
</Link>
|
</Link>
|
||||||
{/* B2B: Contractors */}
|
{/* B2B: Contractors */}
|
||||||
<Link
|
<a
|
||||||
to="/login"
|
href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="group px-8 py-4 rounded-full bg-zinc-100 dark:bg-white/[0.06] hover:bg-zinc-200 dark:hover:bg-white/[0.10] border border-zinc-300 dark:border-white/[0.10] hover:border-zinc-400 dark:hover:border-white/[0.15] text-zinc-700 dark:text-white/90 font-bold text-sm transition-all flex items-center hover:scale-[1.02]"
|
className="group px-8 py-4 rounded-full bg-zinc-100 dark:bg-white/[0.06] hover:bg-zinc-200 dark:hover:bg-white/[0.10] border border-zinc-300 dark:border-white/[0.10] hover:border-zinc-400 dark:hover:border-white/[0.15] text-zinc-700 dark:text-white/90 font-bold text-sm transition-all flex items-center hover:scale-[1.02]"
|
||||||
>
|
>
|
||||||
Start Free Trial
|
See The Process
|
||||||
<ArrowRight size={18} className="ml-2 transition-transform group-hover:translate-x-0.5" />
|
<ArrowRight size={18} className="ml-2 transition-transform group-hover:translate-x-0.5" />
|
||||||
</Link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Discount banner — Veterans, Seniors, Active Duty */}
|
{/* Discount banner — Veterans, Seniors, Active Duty */}
|
||||||
|
|||||||
+126
-53
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { User, Briefcase, Lock, ArrowRight, AlertCircle, Home } from 'lucide-react';
|
import { User, Briefcase, Lock, ArrowRight, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
||||||
import { SpotlightCard } from '../components/SpotlightCard';
|
import { SpotlightCard } from '../components/SpotlightCard';
|
||||||
import Logo from '../assets/images/LynkedUp_Pro_F_logo_Y.png';
|
import Logo from '../assets/images/LynkedUp_Pro_F_logo_Y.png';
|
||||||
|
|
||||||
@@ -53,9 +53,10 @@ const RainbowButton = ({ children, onClick, type = "button", className = "" }) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const [isEmployee, setIsEmployee] = useState(false);
|
const [loginType, setLoginType] = useState('customer'); // 'customer', 'employee', 'owner', 'contractor', 'subcontractor'
|
||||||
const [identifier, setIdentifier] = useState('');
|
const [identifier, setIdentifier] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
@@ -70,15 +71,28 @@ const Login = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = isEmployee ? 'employee' : 'customer';
|
const result = login(identifier, password, loginType);
|
||||||
const result = login(identifier, password, type);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Role-based Redirect
|
// Role-based Redirect
|
||||||
if (result.role === 'CUSTOMER') {
|
switch (result.role) {
|
||||||
|
case 'CUSTOMER':
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} else {
|
break;
|
||||||
navigate('/emp/fa/dashboard');
|
case 'OWNER':
|
||||||
|
navigate('/owner/snapshot');
|
||||||
|
break;
|
||||||
|
case 'CONTRACTOR':
|
||||||
|
navigate('/contractor/dashboard');
|
||||||
|
break;
|
||||||
|
case 'VENDOR':
|
||||||
|
navigate('/vendor/dashboard');
|
||||||
|
break;
|
||||||
|
case 'SUBCONTRACTOR':
|
||||||
|
navigate('/subcontractor/dashboard');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
navigate('/emp/fa/dashboard'); // Default for Employee/Admin
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(result.message);
|
setError(result.message);
|
||||||
@@ -87,17 +101,33 @@ const Login = () => {
|
|||||||
|
|
||||||
const fillDemo = (role) => {
|
const fillDemo = (role) => {
|
||||||
if (role === 'customer') {
|
if (role === 'customer') {
|
||||||
setIsEmployee(false);
|
setLoginType('customer');
|
||||||
setIdentifier('alice');
|
setIdentifier('alice');
|
||||||
setPassword('password');
|
setPassword('password');
|
||||||
} else if (role === 'agent') {
|
} else if (role === 'agent') {
|
||||||
setIsEmployee(true);
|
setLoginType('employee');
|
||||||
setIdentifier('FA001');
|
setIdentifier('FA001');
|
||||||
setPassword('password');
|
setPassword('password');
|
||||||
} else if (role === 'admin') {
|
} else if (role === 'admin') {
|
||||||
setIsEmployee(true);
|
setLoginType('employee');
|
||||||
setIdentifier('ADM01');
|
setIdentifier('ADM01');
|
||||||
setPassword('password');
|
setPassword('password');
|
||||||
|
} else if (role === 'owner') {
|
||||||
|
setLoginType('owner');
|
||||||
|
setIdentifier('justin');
|
||||||
|
setPassword('password');
|
||||||
|
} else if (role === 'contractor') {
|
||||||
|
setLoginType('contractor');
|
||||||
|
setIdentifier('mike');
|
||||||
|
setPassword('password');
|
||||||
|
} else if (role === 'vendor') {
|
||||||
|
setLoginType('vendor');
|
||||||
|
setIdentifier('abc_supply');
|
||||||
|
setPassword('password');
|
||||||
|
} else if (role === 'subcontractor') {
|
||||||
|
setLoginType('subcontractor');
|
||||||
|
setIdentifier('carlos');
|
||||||
|
setPassword('password');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,96 +140,139 @@ const Login = () => {
|
|||||||
<div className="absolute bottom-[-20%] right-[-20%] w-[70%] h-[70%] bg-blue-500/5 dark:bg-blue-900/10 blur-[150px] rounded-full opacity-50"></div>
|
<div className="absolute bottom-[-20%] right-[-20%] w-[70%] h-[70%] bg-blue-500/5 dark:bg-blue-900/10 blur-[150px] rounded-full opacity-50"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Autofill CSS Override */}
|
||||||
|
<style>{`
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
input:-webkit-autofill:active {
|
||||||
|
-webkit-box-shadow: 0 0 0 30px #f4f4f5 inset !important;
|
||||||
|
-webkit-text-fill-color: #18181b !important;
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
input:-webkit-autofill:active {
|
||||||
|
-webkit-box-shadow: 0 0 0 30px #18181b inset !important; /* zinc-900 matches dark mode inputs */
|
||||||
|
-webkit-text-fill-color: white !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dark input:-webkit-autofill,
|
||||||
|
.dark input:-webkit-autofill:hover,
|
||||||
|
.dark input:-webkit-autofill:focus,
|
||||||
|
.dark input:-webkit-autofill:active {
|
||||||
|
-webkit-box-shadow: 0 0 0 30px #1c1917 inset !important; /* Slightly lighter than pure black for input bg */
|
||||||
|
-webkit-text-fill-color: white !important;
|
||||||
|
caret-color: white !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="w-full max-w-lg z-10 relative">
|
||||||
<div className="w-full max-w-md z-10 relative">
|
|
||||||
<SpotlightCard className="bg-white/60 dark:bg-black/30 backdrop-blur-3xl border-0 ring-1 ring-black/5 dark:ring-white/5 shadow-2xl dark:shadow-2xl">
|
<SpotlightCard className="bg-white/60 dark:bg-black/30 backdrop-blur-3xl border-0 ring-1 ring-black/5 dark:ring-white/5 shadow-2xl dark:shadow-2xl">
|
||||||
<div className="p-8">
|
<div className="p-6 md:p-10"> {/* ADJUSTED PADDING FOR MOBILE */}
|
||||||
<div className="text-center mb-10 pt-2">
|
<div className="text-center mb-8 md:mb-10 pt-2">
|
||||||
<img src={Logo} alt="LynkedUp Pro" className="w-20 h-20 mx-auto mb-6 object-contain drop-shadow-2xl" />
|
<img src={Logo} alt="LynkedUp Pro" className="w-20 md:w-24 h-20 md:h-24 mx-auto mb-4 md:mb-6 object-contain drop-shadow-2xl" />
|
||||||
<h1 className="text-3xl font-black text-zinc-900 dark:text-white mb-2 tracking-tighter">
|
<h1 className="text-2xl md:text-3xl font-black text-zinc-900 dark:text-white mb-2 tracking-tighter">
|
||||||
Welcome Back
|
Welcome Back
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-zinc-500 font-medium">Sign in to your LynkedUp Pro account</p>
|
<p className="text-zinc-500 font-medium text-base md:text-lg">Sign in to your LynkedUp Pro account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Neo-Toggle */}
|
{/* Role Tabs — 3-col grid on mobile, 6-col on desktop */}
|
||||||
<div className="flex p-1.5 bg-zinc-100 dark:bg-black/40 rounded-xl mb-8 border border-zinc-200 dark:border-white/5 mx-1">
|
<div className="grid grid-cols-3 md:grid-cols-6 gap-1.5 p-1.5 bg-zinc-100 dark:bg-black/40 rounded-xl mb-8 md:mb-10 border border-zinc-200 dark:border-white/5">
|
||||||
|
{[
|
||||||
|
{ key: 'customer', label: 'Customer' },
|
||||||
|
{ key: 'employee', label: 'Employee' },
|
||||||
|
{ key: 'owner', label: 'Owner' },
|
||||||
|
{ key: 'contractor', label: 'Contract.' },
|
||||||
|
{ key: 'vendor', label: 'Vendor' },
|
||||||
|
{ key: 'subcontractor', label: 'Sub-Con' },
|
||||||
|
].map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEmployee(false)}
|
key={key}
|
||||||
className={`flex-1 flex items-center justify-center py-2.5 text-xs font-bold uppercase tracking-wider rounded-lg transition-all duration-300 ${!isEmployee
|
onClick={() => setLoginType(key)}
|
||||||
|
className={`flex items-center justify-center py-2.5 px-1 text-[10px] font-bold uppercase rounded-lg transition-all duration-300 whitespace-nowrap ${loginType === key
|
||||||
? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm border border-black/5 dark:border-white/5'
|
? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm border border-black/5 dark:border-white/5'
|
||||||
: 'text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400'}`}
|
: 'text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400'}`}
|
||||||
>
|
>
|
||||||
<User size={14} className="mr-2" />
|
{label}
|
||||||
Customer
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEmployee(true)}
|
|
||||||
className={`flex-1 flex items-center justify-center py-2.5 text-xs font-bold uppercase tracking-wider rounded-lg transition-all duration-300 ${isEmployee
|
|
||||||
? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm border border-black/5 dark:border-white/5'
|
|
||||||
: 'text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400'}`}
|
|
||||||
>
|
|
||||||
<Briefcase size={14} className="mr-2" />
|
|
||||||
Employee
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-6">
|
<form onSubmit={handleLogin} className="space-y-6 md:space-y-8">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-bold text-zinc-500 ml-1 uppercase tracking-widest">
|
<label className="text-xs font-bold text-zinc-500 ml-1 uppercase tracking-widest">
|
||||||
{isEmployee ? 'Employee ID' : 'Username'}
|
{loginType === 'employee' ? 'Employee ID' : 'Username'}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-zinc-900 dark:group-focus-within:text-white transition-colors">
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-zinc-900 dark:group-focus-within:text-white transition-colors z-10">
|
||||||
{isEmployee ? <Briefcase size={18} /> : <User size={18} />}
|
{loginType === 'customer' ? <User size={20} /> : <Briefcase size={20} />}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={identifier}
|
value={identifier}
|
||||||
onChange={(e) => setIdentifier(e.target.value)}
|
onChange={(e) => setIdentifier(e.target.value)}
|
||||||
className="w-full bg-zinc-100 dark:bg-black/20 border border-zinc-200 dark:border-white/5 text-zinc-900 dark:text-white rounded-xl py-4 pl-12 pr-4 focus:outline-none focus:ring-1 focus:ring-black/10 dark:focus:ring-white/20 focus:bg-white dark:focus:bg-black/40 transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-700 font-medium"
|
className="w-full bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-900 dark:text-white rounded-xl py-4 pl-12 pr-4 focus:outline-none focus:ring-1 focus:ring-black/10 dark:focus:ring-white/20 focus:bg-white dark:focus:bg-zinc-900 transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-700 font-medium relative z-0"
|
||||||
placeholder={isEmployee ? "e.g., FA001" : "e.g., alice"}
|
placeholder={loginType === 'employee' ? "e.g., FA001" : "e.g., username"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-bold text-zinc-500 ml-1 uppercase tracking-widest">Password</label>
|
<div className="flex justify-between items-center ml-1">
|
||||||
|
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest">Password</label>
|
||||||
|
<button type="button" className="text-xs font-semibold text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 transition-colors">
|
||||||
|
Forgot Password?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-zinc-900 dark:group-focus-within:text-white transition-colors">
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-zinc-900 dark:group-focus-within:text-white transition-colors z-10">
|
||||||
<Lock size={18} />
|
<Lock size={20} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showPassword ? "text" : "password"}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full bg-zinc-100 dark:bg-black/20 border border-zinc-200 dark:border-white/5 text-zinc-900 dark:text-white rounded-xl py-4 pl-12 pr-4 focus:outline-none focus:ring-1 focus:ring-black/10 dark:focus:ring-white/20 focus:bg-white dark:focus:bg-black/40 transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-700 font-medium"
|
className="w-full bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-900 dark:text-white rounded-xl py-4 pl-12 pr-12 focus:outline-none focus:ring-1 focus:ring-black/10 dark:focus:ring-white/20 focus:bg-white dark:focus:bg-zinc-900 transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-700 font-medium relative z-0"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors z-10"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center space-x-2 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 p-3 rounded-xl text-xs font-bold uppercase tracking-wide">
|
<div className="flex items-center space-x-2 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 p-4 rounded-xl text-sm font-bold uppercase tracking-wide">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={18} />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RainbowButton type="submit" className="mt-2 text-white">
|
<RainbowButton type="submit" className="mt-4 text-white py-4 md:py-5 text-base md:text-lg">
|
||||||
<span>Sign In</span>
|
<span>Sign In</span>
|
||||||
<ArrowRight size={18} />
|
<ArrowRight size={20} />
|
||||||
</RainbowButton>
|
</RainbowButton>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Demo Quick Links */}
|
{/* Demo Quick Links */}
|
||||||
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-white/5">
|
<div className="mt-8 md:mt-10 pt-6 md:pt-8 border-t border-zinc-200 dark:border-white/5">
|
||||||
<p className="text-[10px] text-center text-zinc-500 uppercase tracking-widest font-bold mb-4">Quick Demo Access</p>
|
<p className="text-[10px] text-center text-zinc-500 uppercase tracking-widest font-bold mb-4">Quick Demo Access</p>
|
||||||
<div className="flex gap-2 justify-center">
|
<div className="grid grid-cols-3 md:grid-cols-4 gap-2.5">
|
||||||
<button onClick={() => fillDemo('customer')} className="text-[10px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-500 px-3 py-2 rounded-lg hover:border-zinc-300 dark:hover:border-white/20 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors">Customer</button>
|
<button onClick={() => fillDemo('customer')} className="text-[10px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-500 px-2 py-2.5 rounded-lg hover:border-zinc-300 dark:hover:border-white/20 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors">Customer</button>
|
||||||
<button onClick={() => fillDemo('agent')} className="text-[10px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-500 px-3 py-2 rounded-lg hover:border-zinc-300 dark:hover:border-white/20 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors">Agent</button>
|
<button onClick={() => fillDemo('agent')} className="text-[10px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-500 px-2 py-2.5 rounded-lg hover:border-zinc-300 dark:hover:border-white/20 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors">Agent</button>
|
||||||
<button onClick={() => fillDemo('admin')} className="text-[10px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-500 px-3 py-2 rounded-lg hover:border-zinc-300 dark:hover:border-white/20 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors">Admin</button>
|
<button onClick={() => fillDemo('admin')} className="text-[10px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-500 px-2 py-2.5 rounded-lg hover:border-zinc-300 dark:hover:border-white/20 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors">Admin</button>
|
||||||
|
<button onClick={() => fillDemo('owner')} className="text-[10px] font-bold uppercase tracking-wider bg-amber-100 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-500/20 text-amber-600 dark:text-amber-400 px-2 py-2.5 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/30 transition-colors">Owner</button>
|
||||||
|
<button onClick={() => fillDemo('contractor')} className="text-[10px] font-bold uppercase tracking-wider bg-blue-100 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-500/20 text-blue-600 dark:text-blue-400 px-2 py-2.5 rounded-lg hover:bg-blue-200 dark:hover:bg-blue-900/30 transition-colors">Contractor</button>
|
||||||
|
<button onClick={() => fillDemo('vendor')} className="text-[10px] font-bold uppercase tracking-wider bg-cyan-100 dark:bg-cyan-900/20 border border-cyan-200 dark:border-cyan-500/20 text-cyan-600 dark:text-cyan-400 px-2 py-2.5 rounded-lg hover:bg-cyan-200 dark:hover:bg-cyan-900/30 transition-colors">Vendor</button>
|
||||||
|
<button onClick={() => fillDemo('subcontractor')} className="text-[10px] font-bold uppercase tracking-wider bg-purple-100 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-500/20 text-purple-600 dark:text-purple-400 px-2 py-2.5 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-900/30 transition-colors">Sub-Con</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,6 +103,25 @@ const MapLegend = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Map Resizer Component
|
||||||
|
const MapResizer = () => {
|
||||||
|
const map = useMapEvents({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
map.invalidateSize();
|
||||||
|
});
|
||||||
|
const container = map.getContainer();
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Map View
|
// Map View
|
||||||
const MapView = ({ data, onSelect, onMapCreate }) => {
|
const MapView = ({ data, onSelect, onMapCreate }) => {
|
||||||
const mapStart = [33.0708, -96.7455];
|
const mapStart = [33.0708, -96.7455];
|
||||||
@@ -110,6 +129,9 @@ const MapView = ({ data, onSelect, onMapCreate }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MapContainer center={mapStart} zoom={18} scrollWheelZoom={true} className="w-full h-full z-0 relative outline-none" style={{ height: "100%", width: "100%" }}>
|
<MapContainer center={mapStart} zoom={18} scrollWheelZoom={true} className="w-full h-full z-0 relative outline-none" style={{ height: "100%", width: "100%" }}>
|
||||||
|
|
||||||
|
<MapResizer />
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
- Light Mode: Standard vibrant OSM tiles (no filters)
|
- Light Mode: Standard vibrant OSM tiles (no filters)
|
||||||
- Dark Mode: Inverted, Grayscale, High Contrast for dark map
|
- Dark Mode: Inverted, Grayscale, High Contrast for dark map
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useMockStore } from '../data/mockStore';
|
||||||
|
import { filterProjectsForUser, canViewSensitiveData } from '../utils/permissions';
|
||||||
|
import MaskedData from '../components/MaskedData';
|
||||||
|
|
||||||
|
const PlaceholderDashboard = ({ title, role }) => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const { personnel, vendors, documents, projects } = useMockStore();
|
||||||
|
|
||||||
|
// Filter data based on permissions
|
||||||
|
const visibleProjects = filterProjectsForUser(user, projects);
|
||||||
|
const canSeeSensitive = canViewSensitiveData(user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-white p-8">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<header className="flex justify-between items-center mb-8 pb-4 border-b border-white/10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-emerald-400">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400 mt-2">Welcome, {user?.name} ({role})</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="px-4 py-2 bg-red-500/10 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors border border-red-500/20"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="p-6 rounded-xl bg-slate-900/50 border border-white/10 backdrop-blur-md">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-emerald-400">Security & Permissions Check 🔒</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-slate-300 font-medium mb-3">Data Visibility</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-slate-400">
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span>Total Projects:</span>
|
||||||
|
<span className="font-mono text-white">{projects.length}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span>Visible Projects (Role filtered):</span>
|
||||||
|
<span className="font-mono text-blue-400 font-bold">{visibleProjects.length}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span>Can View Sensitive Data:</span>
|
||||||
|
<span className={`font-mono font-bold ${canSeeSensitive ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{canSeeSensitive ? 'YES' : 'NO'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-slate-300 font-medium mb-3">Sensitive Data Test</h3>
|
||||||
|
<div className="bg-black/40 p-4 rounded-lg border border-white/5 space-y-3">
|
||||||
|
{personnel.length > 0 && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-slate-400">Sample SSN ({personnel[0].name}):</span>
|
||||||
|
<MaskedData value={personnel[0].sensitiveData?.ssn} label="SSN" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vendors.length > 0 && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-slate-400">Sample Bank ({vendors[0].vendorName}):</span>
|
||||||
|
<MaskedData value={vendors[0].sensitiveData?.bankAccount} label="Bank" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<div className="p-5 rounded-lg bg-slate-900/30 border border-white/5">
|
||||||
|
<div className="text-slate-400 text-sm mb-1">My Projects</div>
|
||||||
|
<div className="text-3xl font-bold text-blue-400">{visibleProjects.length || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 rounded-lg bg-slate-900/30 border border-white/5">
|
||||||
|
<div className="text-slate-400 text-sm mb-1">Vendors</div>
|
||||||
|
<div className="text-3xl font-bold text-purple-400">{vendors?.length || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 rounded-lg bg-slate-900/30 border border-white/5">
|
||||||
|
<div className="text-slate-400 text-sm mb-1">Personnel</div>
|
||||||
|
<div className="text-3xl font-bold text-amber-400">{personnel?.length || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 rounded-lg bg-slate-900/30 border border-white/5">
|
||||||
|
<div className="text-slate-400 text-sm mb-1">Documents</div>
|
||||||
|
<div className="text-3xl font-bold text-emerald-400">{documents?.length || 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 rounded-lg bg-slate-900/30 border border-white/5 mt-4">
|
||||||
|
<div className="text-slate-400 text-sm mb-1">Session ID</div>
|
||||||
|
<div className="text-white/50 font-mono text-xs">{user?.id || 'Unknown'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaceholderDashboard;
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
import { StatCard } from '../../components/StatCard';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
import { Hammer, CheckSquare, Clock, AlertTriangle, Calendar, DollarSign } from 'lucide-react';
|
||||||
|
import ProjectDetailsModal from '../../components/contractor/ProjectDetailsModal';
|
||||||
|
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
|
||||||
|
|
||||||
|
const ContractorDashboard = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { projects } = useMockStore();
|
||||||
|
|
||||||
|
// 1. Identify current contractor
|
||||||
|
// Filter projects where this contractor is the Lead
|
||||||
|
const myProjects = projects.filter(p => p.contractorId === user?.id || p.contractorId === 'con_001'); // Default to con_001 for mock
|
||||||
|
|
||||||
|
// 2. Aggregate Data
|
||||||
|
const activeProjects = myProjects.filter(p => p.status === 'active').length;
|
||||||
|
const upcomingProjects = myProjects.filter(p => p.status === 'scheduled').length;
|
||||||
|
|
||||||
|
// Calculate total budget managed
|
||||||
|
const totalBudget = myProjects.reduce((sum, p) => sum + (p.budget || 0), 0);
|
||||||
|
const totalSpent = myProjects.reduce((sum, p) => sum + (p.spent || 0), 0);
|
||||||
|
const budgetUtilization = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
|
||||||
|
|
||||||
|
// Upcoming Milestones (next 7 days)
|
||||||
|
const upcomingMilestones = myProjects.flatMap(p =>
|
||||||
|
p.milestones.filter(m => {
|
||||||
|
const dueDate = new Date(m.dueDate);
|
||||||
|
const today = new Date('2026-02-05'); // Fixed context date
|
||||||
|
const diffTime = Math.abs(dueDate - today);
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
return diffDays <= 7 && m.status !== 'completed';
|
||||||
|
}).map(m => ({ ...m, projectAddress: p.address }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [selectedProject, setSelectedProject] = useState(null);
|
||||||
|
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||||
|
const [isFinancialModalOpen, setIsFinancialModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Financial data for modal
|
||||||
|
const financialData = {
|
||||||
|
total: totalBudget,
|
||||||
|
spent: totalSpent,
|
||||||
|
remaining: totalBudget - totalSpent,
|
||||||
|
paid: totalSpent,
|
||||||
|
pending: 0,
|
||||||
|
items: myProjects.map(p => ({
|
||||||
|
date: p.startDate,
|
||||||
|
description: p.address,
|
||||||
|
project: p.projectType,
|
||||||
|
status: p.status === 'completed' ? 'paid' : 'pending',
|
||||||
|
amount: p.budget
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProjectClick = (project) => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setIsProjectModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8 animate-in fade-in duration-500">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Contractor Command Center</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
Managing {activeProjects} active builds with <span className="text-blue-500 font-semibold">${totalBudget.toLocaleString()}</span> in volume.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/contractor/projects')}
|
||||||
|
className="bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-900 dark:text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors border border-zinc-200 dark:border-white/10"
|
||||||
|
>
|
||||||
|
View Schedule
|
||||||
|
</button>
|
||||||
|
<button className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors shadow-lg shadow-blue-500/20">
|
||||||
|
+ Log Daily Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<StatCard
|
||||||
|
label="Active Projects"
|
||||||
|
value={activeProjects}
|
||||||
|
icon={Hammer}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Upcoming Starts"
|
||||||
|
value={upcomingProjects}
|
||||||
|
icon={Calendar}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
||||||
|
<StatCard
|
||||||
|
label="Budget Utilization"
|
||||||
|
value={budgetUtilization}
|
||||||
|
icon={DollarSign}
|
||||||
|
color={budgetUtilization > 90 ? 'red' : 'emerald'}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<StatCard
|
||||||
|
label="Pending Actions"
|
||||||
|
value={upcomingMilestones.length}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
{/* Active Projects List */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<SpotlightCard className="p-6 h-full">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Active Build Status</h2>
|
||||||
|
<button onClick={() => navigate('/contractor/projects')} className="text-sm text-blue-500 hover:text-blue-400">View All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{myProjects.filter(p => p.status === 'active').map(proj => (
|
||||||
|
<div
|
||||||
|
key={proj.id}
|
||||||
|
onClick={() => handleProjectClick(proj)}
|
||||||
|
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-all cursor-pointer group"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-zinc-900 dark:text-white">{proj.address}</h4>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">{proj.projectType} • Started {new Date(proj.startDate).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
<span className="px-2 py-1 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-xs font-bold rounded uppercase">
|
||||||
|
On Track
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs font-medium text-zinc-500">
|
||||||
|
<span>Completion</span>
|
||||||
|
<span>{proj.completionPercentage}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full"
|
||||||
|
style={{ width: `${proj.completionPercentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestones Preview */}
|
||||||
|
<div className="mt-4 flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||||
|
{proj.milestones.map(ms => (
|
||||||
|
<span
|
||||||
|
key={ms.id}
|
||||||
|
className={`whitespace-nowrap px-2 py-1 text-[10px] font-bold uppercase border rounded ${ms.status === 'completed'
|
||||||
|
? 'border-emerald-200 text-emerald-600 dark:border-emerald-500/30 dark:text-emerald-400'
|
||||||
|
: ms.status === 'in_progress'
|
||||||
|
? 'border-blue-200 text-blue-600 dark:border-blue-500/30 dark:text-blue-400'
|
||||||
|
: 'border-zinc-200 text-zinc-400 dark:border-zinc-700 dark:text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ms.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel: Tasks & Alerts */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Critical Tasks (Next 7 Days)</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{upcomingMilestones.length > 0 ? upcomingMilestones.map((ms, idx) => (
|
||||||
|
<div key={idx} className="flex items-start gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-500/5 hover:bg-red-100 dark:hover:bg-red-500/10 transition-colors cursor-pointer group">
|
||||||
|
<div className="mt-1 w-2 h-2 rounded-full bg-red-500 shrink-0 group-hover:scale-125 transition-transform" />
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-bold text-zinc-900 dark:text-white">{ms.name}</h5>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{ms.projectAddress}</p>
|
||||||
|
<p className="text-xs font-mono font-medium text-red-500 mt-1">Due: {ms.dueDate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<p className="text-sm text-zinc-500 italic">No critical tasks due.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Crew Availability</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Mock Crew Data */}
|
||||||
|
{['Crew Alpha (Roofing)', 'Crew Beta (Gutters)'].map((crew, i) => (
|
||||||
|
<div key={i} className="flex justify-between items-center text-sm py-2 border-b border-zinc-100 dark:border-white/5 last:border-0">
|
||||||
|
<span className="text-zinc-900 dark:text-white">{crew}</span>
|
||||||
|
<span className="flex items-center text-emerald-500 text-xs font-bold uppercase">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2" />
|
||||||
|
Available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<ProjectDetailsModal
|
||||||
|
isOpen={isProjectModalOpen}
|
||||||
|
onClose={() => setIsProjectModalOpen(false)}
|
||||||
|
project={selectedProject}
|
||||||
|
/>
|
||||||
|
<FinancialSummaryModal
|
||||||
|
isOpen={isFinancialModalOpen}
|
||||||
|
onClose={() => setIsFinancialModalOpen(false)}
|
||||||
|
role="CONTRACTOR"
|
||||||
|
data={financialData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContractorDashboard;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
import { Briefcase, Calendar, CheckCircle, Clock, AlertTriangle, ChevronRight } from 'lucide-react';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
import ProjectDetailsModal from '../../components/contractor/ProjectDetailsModal';
|
||||||
|
|
||||||
|
const ProjectList = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { projects, vendors } = useMockStore();
|
||||||
|
|
||||||
|
// Filter projects for this contractor/subcontractor
|
||||||
|
const myProjects = projects.filter(p =>
|
||||||
|
p.contractorId === user.id || p.subcontractorIds?.includes(user.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return 'text-emerald-700 bg-emerald-100 border-emerald-200 dark:text-emerald-300 dark:bg-emerald-500/10 dark:border-emerald-500/20';
|
||||||
|
case 'completed': return 'text-blue-700 bg-blue-100 border-blue-200 dark:text-blue-300 dark:bg-blue-500/10 dark:border-blue-500/20';
|
||||||
|
case 'pending': return 'text-amber-700 bg-amber-100 border-amber-200 dark:text-amber-300 dark:bg-amber-500/10 dark:border-amber-500/20';
|
||||||
|
default: return 'text-zinc-600 bg-zinc-100 border-zinc-200 dark:text-zinc-400 dark:bg-zinc-500/10 dark:border-zinc-500/20';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [selectedProject, setSelectedProject] = useState(null);
|
||||||
|
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleProjectClick = (project) => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setIsProjectModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white relative pb-20 transition-colors duration-300">
|
||||||
|
{/* Ambient Background Glows */}
|
||||||
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 p-8 max-w-7xl mx-auto">
|
||||||
|
<header className="mb-8 border-b border-zinc-200 dark:border-white/5 pb-6">
|
||||||
|
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 mb-2 tracking-tight">My Projects</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 font-light">Manage your active assignments and track progress.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{myProjects.length === 0 ? (
|
||||||
|
<SpotlightCard className="text-center py-20 border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50/50 dark:bg-white/5">
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<div className="p-4 bg-zinc-100 dark:bg-white/5 rounded-full text-zinc-400 mb-4">
|
||||||
|
<Briefcase size={48} className="opacity-50" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-zinc-900 dark:text-white mb-2">No Active Projects</h3>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400">You currently have no projects assigned.</p>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{myProjects.map(project => (
|
||||||
|
<SpotlightCard key={project.id} className="p-0 group overflow-hidden cursor-pointer" onClick={() => handleProjectClick(project)}>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between md:items-center gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">Project #{project.id}</h2>
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${getStatusColor(project.status)}`}>
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 text-sm flex items-center font-medium">
|
||||||
|
<Briefcase size={14} className="mr-2" />
|
||||||
|
{project.projectType.charAt(0).toUpperCase() + project.projectType.slice(1)} Project
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">Budget</div>
|
||||||
|
<div className="text-lg font-bold text-zinc-900 dark:text-white">${project.budget.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right pl-6 border-l border-zinc-200 dark:border-white/10">
|
||||||
|
<div className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">Deadline</div>
|
||||||
|
<div className="text-lg font-bold text-zinc-900 dark:text-white flex items-center justify-end gap-2">
|
||||||
|
{project.endDate}
|
||||||
|
<Calendar size={16} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-6 p-4 bg-zinc-50 dark:bg-white/5 rounded-xl border border-zinc-100 dark:border-white/5">
|
||||||
|
<div className="flex justify-between text-xs mb-2 font-bold uppercase tracking-wider">
|
||||||
|
<span className="text-zinc-500">Completion Status</span>
|
||||||
|
<span className="text-emerald-600 dark:text-emerald-400">
|
||||||
|
{project.status === 'completed' ? '100%' : 'In Progress'}
|
||||||
|
</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 transition-all duration-1000 ease-out"
|
||||||
|
style={{ width: project.status === 'completed' ? '100%' : '45%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{/* Avatar Placeholders */}
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-[10px] font-bold text-blue-600 dark:text-blue-300 border-2 border-white dark:border-zinc-900 shadow-sm">PM</div>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center text-[10px] font-bold text-purple-600 dark:text-purple-300 border-2 border-white dark:border-zinc-900 shadow-sm">GC</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center text-sm font-bold text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors bg-blue-50 dark:bg-blue-500/10 px-4 py-2 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-500/20"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleProjectClick(project);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View Details <ChevronRight size={16} className="ml-1 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Decorative Bottom Glow */}
|
||||||
|
<div className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-zinc-200 dark:via-white/10 to-transparent opacity-50" />
|
||||||
|
</SpotlightCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<ProjectDetailsModal
|
||||||
|
isOpen={isProjectModalOpen}
|
||||||
|
onClose={() => setIsProjectModalOpen(false)}
|
||||||
|
project={selectedProject}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectList;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import DocumentReviewQueue from '../../components/documents/DocumentReviewQueue';
|
||||||
|
import { FileText, Shield, AlertTriangle, CheckCircle, Clock } from 'lucide-react';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
|
||||||
|
const DocumentManagement = () => {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden relative">
|
||||||
|
{/* Ambient Background Glows */}
|
||||||
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex flex-col min-h-0 max-w-7xl mx-auto w-full p-4 md:p-6 lg:p-8">
|
||||||
|
<header className="mb-6 flex flex-col md:flex-row justify-between items-start md:items-end shrink-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">Document Control</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">Review, approve, and manage critical business documents.</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 md:mt-0 flex space-x-3">
|
||||||
|
<SpotlightCard className="cursor-pointer hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors" rounded="rounded-xl">
|
||||||
|
<div className="px-4 py-2 flex items-center gap-2">
|
||||||
|
<Shield size={16} className="text-emerald-500" />
|
||||||
|
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300">Audit Log</span>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold transition-colors shadow-lg shadow-blue-600/20">
|
||||||
|
<FileText size={16} />
|
||||||
|
<span>Upload New</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
|
||||||
|
{/* Main Content: Review Queue */}
|
||||||
|
<div className="w-full lg:w-2/3 flex flex-col min-h-0">
|
||||||
|
<SpotlightCard className="h-full flex flex-col overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 flex items-center gap-2">
|
||||||
|
<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">Review Queue</h2>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">Documents requiring your attention</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden p-6">
|
||||||
|
<DocumentReviewQueue />
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar: Compliance Overview */}
|
||||||
|
<div className="w-full lg:w-1/3 flex flex-col gap-6">
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||||
|
<Clock size={16} className="text-blue-500" />
|
||||||
|
Quick Stats
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Pending Approval</span>
|
||||||
|
<span className="text-xl font-bold text-amber-500">3</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Expiring Soon (30d)</span>
|
||||||
|
<span className="text-xl font-bold text-red-500">1</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Total Documents</span>
|
||||||
|
<span className="text-xl font-bold text-zinc-900 dark:text-white">124</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
<SpotlightCard className="p-6 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/10 dark:to-indigo-900/10 border-blue-200 dark:border-blue-500/20">
|
||||||
|
<h3 className="text-sm font-bold text-blue-700 dark:text-blue-300 mb-2 flex items-center gap-2">
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
Compliance Tip
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-600/80 dark:text-blue-200/80 leading-relaxed">
|
||||||
|
Ensure all 1099 vendors have an updated W-9 on file before processing Q1 payments to avoid compliance flags.
|
||||||
|
</p>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentManagement;
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import FinancialKPICards from '../../components/owner/FinancialKPICards';
|
||||||
|
import UrgentItemsPanel from '../../components/owner/UrgentItemsPanel';
|
||||||
|
import FinancialDetailsModal from '../../components/owner/FinancialDetailsModal';
|
||||||
|
import ActionCenterModal from '../../components/owner/ActionCenterModal';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
|
||||||
|
const OwnerSnapshot = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Modal States
|
||||||
|
const [financialModal, setFinancialModal] = useState({ isOpen: false, type: 'revenue' });
|
||||||
|
const [actionModal, setActionModal] = useState({ isOpen: false, filter: 'all' });
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleFinancialClick = (type) => {
|
||||||
|
setFinancialModal({ isOpen: true, type });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActionClick = (id) => {
|
||||||
|
// Map IDs to filter types
|
||||||
|
const filterMap = {
|
||||||
|
'docs': 'docs',
|
||||||
|
'vendors': 'vendors',
|
||||||
|
'invoices': 'invoices',
|
||||||
|
'all': 'all'
|
||||||
|
};
|
||||||
|
setActionModal({ isOpen: true, filter: filterMap[id] || 'all' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get time of day for greeting
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const greeting = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white relative pb-20 transition-colors duration-300">
|
||||||
|
{/* Ambient Background Glows */}
|
||||||
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 p-8 max-w-7xl mx-auto space-y-8">
|
||||||
|
|
||||||
|
{/* Header Section */}
|
||||||
|
<header className="flex flex-col md:flex-row justify-between items-start md:items-center border-b border-zinc-200 dark:border-white/5 pb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">
|
||||||
|
{greeting}, {user?.name?.split(' ')[0] || 'Owner'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-2 font-light">Here's your business snapshot for today.</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 md:mt-0 flex space-x-3">
|
||||||
|
<button className="px-4 py-2 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-xl text-sm font-bold text-zinc-700 dark:text-zinc-300 transition-colors border border-zinc-200 dark:border-white/5 shadow-sm">
|
||||||
|
Download Report
|
||||||
|
</button>
|
||||||
|
<button className="px-4 py-2 bg-amber-500 hover:bg-amber-600 text-black font-bold rounded-xl text-sm transition-colors shadow-lg shadow-amber-500/20">
|
||||||
|
Quick Action
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Financial KPIs */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold text-zinc-900 dark:text-white/90 mb-4 flex items-center">
|
||||||
|
<span className="w-2 h-8 bg-emerald-500 rounded-full mr-3"></span>
|
||||||
|
Financial Overview
|
||||||
|
</h2>
|
||||||
|
<FinancialKPICards onCardClick={handleFinancialClick} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Main Content Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
|
||||||
|
{/* Urgent Items Panel (Takes up 2 columns on large screens) */}
|
||||||
|
<section className="lg:col-span-2">
|
||||||
|
<UrgentItemsPanel onActionClick={handleActionClick} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Activity Feed / Notifications Placeholder (Right Column) */}
|
||||||
|
<SpotlightCard className="h-full">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-500 mr-2 animate-pulse"></div>
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Recent Activity</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-start space-x-3 p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
||||||
|
<div className="w-2 h-2 mt-2 rounded-full bg-blue-400 shrink-0"></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-300">New invoice submitted by <span className="text-zinc-900 dark:text-white font-bold">Texas Builders LLC</span></p>
|
||||||
|
<span className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">2 hours ago</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-start space-x-3 p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
||||||
|
<div className="w-2 h-2 mt-2 rounded-full bg-emerald-400 shrink-0"></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-300">Payment received for <span className="text-zinc-900 dark:text-white font-bold">Project P-2602</span></p>
|
||||||
|
<span className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">5 hours ago</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/owner/vendors')}
|
||||||
|
className="w-full mt-6 py-2 text-xs font-bold uppercase tracking-wider text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white border border-zinc-200 dark:border-white/10 hover:bg-zinc-100 dark:hover:bg-white/5 rounded-xl transition-all"
|
||||||
|
>
|
||||||
|
View All Activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Modals */}
|
||||||
|
<FinancialDetailsModal
|
||||||
|
isOpen={financialModal.isOpen}
|
||||||
|
onClose={() => setFinancialModal({ ...financialModal, isOpen: false })}
|
||||||
|
type={financialModal.type}
|
||||||
|
/>
|
||||||
|
<ActionCenterModal
|
||||||
|
isOpen={actionModal.isOpen}
|
||||||
|
onClose={() => setActionModal({ ...actionModal, isOpen: false })}
|
||||||
|
defaultFilter={actionModal.filter}
|
||||||
|
// Force re-render on filter change if needed, generally okay as props update
|
||||||
|
key={actionModal.filter}
|
||||||
|
/>
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OwnerSnapshot;
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
import { Search, Filter, Shield, User, Briefcase, Zap } from 'lucide-react';
|
||||||
|
import MaskedData from '../../components/MaskedData';
|
||||||
|
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
|
||||||
|
const PeopleDirectory = () => {
|
||||||
|
const { personnel, vendors } = useMockStore();
|
||||||
|
const [filter, setFilter] = useState('all'); // all, employee, contractor, subcontractor
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedPerson, setSelectedPerson] = useState(null);
|
||||||
|
|
||||||
|
// Combine and normalize data for display
|
||||||
|
const allPeople = [
|
||||||
|
...personnel.map(p => ({ ...p, type: 'personnel', category: 'Internal Team' })),
|
||||||
|
...vendors.map(v => ({ ...v, name: v.vendorName, type: 'vendor', category: 'Vendor/Partner' }))
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredPeople = allPeople.filter(person => {
|
||||||
|
const matchesSearch = person.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
person.email?.toLowerCase().includes(search.toLowerCase());
|
||||||
|
|
||||||
|
if (filter === 'all') return matchesSearch;
|
||||||
|
if (filter === 'employee') return matchesSearch && person.type === 'personnel';
|
||||||
|
if (filter === 'contractor') return matchesSearch && person.role === 'CONTRACTOR'; // Assuming role field exists
|
||||||
|
if (filter === 'subcontractor') return matchesSearch && person.role === 'SUBCONTRACTOR'; // Assuming role field exists
|
||||||
|
|
||||||
|
return matchesSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden relative">
|
||||||
|
{/* Ambient Background Glows */}
|
||||||
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex flex-col min-h-0 max-w-7xl mx-auto w-full p-4 md:p-6 lg:p-8">
|
||||||
|
<header className="mb-6 shrink-0">
|
||||||
|
<h1 className="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">People Directory</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">Manage your team, contractors, and compliance records.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
|
||||||
|
{/* Left Sidebar: List & Search */}
|
||||||
|
<SpotlightCard className="w-full lg:w-1/3 flex flex-col overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider whitespace-nowrap transition-colors ${filter === 'all'
|
||||||
|
? 'bg-zinc-900 text-white dark:bg-white dark:text-black'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('employee')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider whitespace-nowrap transition-colors ${filter === 'employee'
|
||||||
|
? 'bg-blue-600 text-white dark:bg-blue-500 dark:text-white'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Employees
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('contractor')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider whitespace-nowrap transition-colors ${filter === 'contractor'
|
||||||
|
? 'bg-purple-600 text-white dark:bg-purple-500 dark:text-white'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Contractors
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
||||||
|
{filteredPeople.map((person) => (
|
||||||
|
<div
|
||||||
|
key={person.id}
|
||||||
|
onClick={() => setSelectedPerson(person)}
|
||||||
|
className={`p-3 rounded-xl border transition-all cursor-pointer group ${selectedPerson?.id === person.id
|
||||||
|
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 shadow-sm'
|
||||||
|
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm ${person.type === 'personnel'
|
||||||
|
? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400 ring-2 ring-white dark:ring-zinc-900 group-hover:scale-110 transition-transform'
|
||||||
|
: 'bg-purple-100 text-purple-600 dark:bg-purple-500/20 dark:text-purple-400 ring-2 ring-white dark:ring-zinc-900 group-hover:scale-110 transition-transform'
|
||||||
|
}`}>
|
||||||
|
{person.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-bold text-sm ${selectedPerson?.id === person.id ? 'text-blue-700 dark:text-blue-300' : 'text-zinc-900 dark:text-white'}`}>
|
||||||
|
{person.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{person.role || person.vendorType}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
{/* Right Panel: Details */}
|
||||||
|
<div className="w-full lg:w-2/3 flex flex-col min-h-0">
|
||||||
|
{selectedPerson ? (
|
||||||
|
<SpotlightCard className="h-full flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
|
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white">{selectedPerson.name}</h2>
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${selectedPerson.status === 'active'
|
||||||
|
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
|
||||||
|
: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20'
|
||||||
|
}`}>
|
||||||
|
{selectedPerson.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Briefcase size={16} />
|
||||||
|
{selectedPerson.category} • {selectedPerson.role || selectedPerson.vendorType}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-white dark:bg-white/5 hover:bg-zinc-50 dark:hover:bg-white/10 rounded-xl text-sm font-bold text-zinc-700 dark:text-zinc-300 transition-colors border border-zinc-200 dark:border-white/10 shadow-sm">
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
{/* Sensitive Data Section */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2">
|
||||||
|
<Shield size={16} className="text-blue-500" />
|
||||||
|
Sensitive Information
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Email</span>
|
||||||
|
<span className="text-zinc-900 dark:text-white text-sm font-medium">{selectedPerson.email || selectedPerson.primaryContact?.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Phone</span>
|
||||||
|
<span className="text-zinc-900 dark:text-white text-sm font-medium">{selectedPerson.phone || selectedPerson.primaryContact?.phone}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Masked Fields */}
|
||||||
|
{selectedPerson.sensitiveData?.ssn && (
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">SSN</span>
|
||||||
|
<MaskedData value={selectedPerson.sensitiveData.ssn} label="SSN" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedPerson.sensitiveData?.ein && (
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">EIN</span>
|
||||||
|
<MaskedData value={selectedPerson.sensitiveData.ein} label="EIN" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedPerson.sensitiveData?.bankAccount && (
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Bank Account</span>
|
||||||
|
<MaskedData value={selectedPerson.sensitiveData.bankAccount} label="Bank" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compliance Section */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2 mb-4">
|
||||||
|
<Zap size={16} className="text-amber-500" />
|
||||||
|
Compliance & Documents
|
||||||
|
</h3>
|
||||||
|
<ComplianceChecklist data={selectedPerson} type={selectedPerson.type} />
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20">
|
||||||
|
<h4 className="text-xs font-bold text-blue-600 dark:text-blue-300 mb-3 uppercase tracking-wider">Quick Actions</h4>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="flex-1 px-3 py-2 bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-500 text-white rounded-lg text-xs font-bold uppercase transition-colors shadow-sm">
|
||||||
|
Request Document
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 px-3 py-2 bg-white hover:bg-zinc-50 dark:bg-white/5 dark:hover:bg-white/10 rounded-lg text-xs font-bold uppercase transition-colors text-zinc-600 dark:text-zinc-300 border border-zinc-200 dark:border-white/10">
|
||||||
|
View Audit Log
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
) : (
|
||||||
|
<SpotlightCard className="h-full border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50/50 dark:bg-white/5 flex items-center justify-center p-8 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="w-16 h-16 bg-zinc-100 dark:bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-zinc-400">
|
||||||
|
<User size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-zinc-900 dark:text-white mb-2">Select a Person</h3>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto text-sm">Select an employee, contractor, or vendor from the list to view their detailed profile.</p>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PeopleDirectory;
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
import { Search, Filter, Briefcase, DollarSign, FileText, AlertTriangle, CheckCircle } from 'lucide-react';
|
||||||
|
import MaskedData from '../../components/MaskedData';
|
||||||
|
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
|
||||||
|
const VendorDirectory = () => {
|
||||||
|
const { vendors } = useMockStore();
|
||||||
|
const [filterType, setFilterType] = useState('all'); // all, roofing, electrical, plumbing, hvac
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedVendor, setSelectedVendor] = useState(null);
|
||||||
|
|
||||||
|
const filteredVendors = vendors.filter(vendor => {
|
||||||
|
const matchesSearch = vendor.vendorName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
vendor.primaryContact.name.toLowerCase().includes(search.toLowerCase());
|
||||||
|
|
||||||
|
const matchesType = filterType === 'all' || vendor.vendorType === filterType;
|
||||||
|
|
||||||
|
return matchesSearch && matchesType;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate total spend for filtered view
|
||||||
|
const totalSpend = filteredVendors.reduce((acc, v) => acc + (v.spend?.totalSpend || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden relative">
|
||||||
|
{/* Ambient Background Glows */}
|
||||||
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex flex-col min-h-0 max-w-7xl mx-auto w-full p-4 md:p-6 lg:p-8">
|
||||||
|
<header className="mb-6 flex flex-col md:flex-row justify-between items-start md:items-end shrink-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">Vendor Management</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">Track vendor compliance, spend, and performance.</p>
|
||||||
|
</div>
|
||||||
|
<SpotlightCard className="mt-4 md:mt-0 px-4 py-2 flex items-center gap-3">
|
||||||
|
<span className="text-[10px] text-zinc-500 dark:text-zinc-400 uppercase font-bold tracking-wider">Total Spend (Visible)</span>
|
||||||
|
<div className="h-4 w-px bg-zinc-200 dark:bg-white/10"></div>
|
||||||
|
<p className="text-xl font-bold text-zinc-900 dark:text-white">${totalSpend.toLocaleString()}</p>
|
||||||
|
</SpotlightCard>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
|
||||||
|
{/* Left Sidebar: Vendor List */}
|
||||||
|
<SpotlightCard className="w-full lg:w-1/3 flex flex-col overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-zinc-200 dark:border-white/5 space-y-4 bg-zinc-50/50 dark:bg-white/5">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search vendors..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-zinc-100 dark:bg-black/40 border border-zinc-200 dark:border-white/10 rounded-xl py-2 pl-10 pr-4 text-sm text-zinc-900 dark:text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{['all', 'roofing_crew', 'electrical', 'plumbing', 'hvac'].map(type => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setFilterType(type)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-colors ${filterType === type
|
||||||
|
? 'bg-zinc-900 text-white dark:bg-white dark:text-black'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type.replace('_', ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
||||||
|
{filteredVendors.map((vendor) => (
|
||||||
|
<div
|
||||||
|
key={vendor.id}
|
||||||
|
onClick={() => setSelectedVendor(vendor)}
|
||||||
|
className={`p-3 rounded-xl border transition-all cursor-pointer group ${selectedVendor?.id === vendor.id
|
||||||
|
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 shadow-sm'
|
||||||
|
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-bold text-sm ${selectedVendor?.id === vendor.id ? 'text-blue-700 dark:text-blue-300' : 'text-zinc-900 dark:text-white'}`}>
|
||||||
|
{vendor.vendorName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 uppercase tracking-wide mb-1.5">{vendor.vendorType?.replace('_', ' ')}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{vendor.status === 'active' ?
|
||||||
|
<span className="flex items-center gap-1 text-[10px] font-bold text-emerald-600 dark:text-emerald-400"><CheckCircle size={10} /> Active</span> :
|
||||||
|
<span className="flex items-center gap-1 text-[10px] font-bold text-red-600 dark:text-red-400"><AlertTriangle size={10} /> {vendor.status}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-[10px] text-zinc-400 uppercase font-bold">YTD Spend</p>
|
||||||
|
<p className="font-mono text-xs font-bold text-zinc-700 dark:text-zinc-300">${(vendor.spend?.totalSpend / 1000).toFixed(1)}k</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
{/* Right Panel: Vendor Details */}
|
||||||
|
<div className="w-full lg:w-2/3 flex flex-col min-h-0">
|
||||||
|
{selectedVendor ? (
|
||||||
|
<SpotlightCard className="h-full flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
|
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white">{selectedVendor.vendorName}</h2>
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${selectedVendor.status === 'active'
|
||||||
|
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
|
||||||
|
: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20'
|
||||||
|
}`}>
|
||||||
|
{selectedVendor.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Briefcase size={16} />
|
||||||
|
{selectedVendor.vendorType?.replace('_', ' ')} • {selectedVendor.primaryContact?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-white dark:bg-white/5 hover:bg-zinc-50 dark:hover:bg-white/10 rounded-xl text-sm font-bold text-zinc-700 dark:text-zinc-300 transition-colors border border-zinc-200 dark:border-white/10 shadow-sm">
|
||||||
|
Edit Vendor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
{/* Financials & Compliance */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2">
|
||||||
|
<DollarSign size={16} className="text-emerald-500" />
|
||||||
|
Financials & Banking
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">EIN / Tax ID</span>
|
||||||
|
<MaskedData value={selectedVendor.sensitiveData?.ein} label="EIN" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Bank Account</span>
|
||||||
|
<MaskedData value={selectedVendor.sensitiveData?.bankAccount} label="Bank" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Last Payment</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-zinc-900 dark:text-white font-bold text-sm">${selectedVendor.spend?.lastPayment?.amount.toLocaleString()}</div>
|
||||||
|
<div className="text-[10px] text-zinc-400">{new Date(selectedVendor.spend?.lastPayment?.date).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-zinc-900 dark:text-white mb-4 uppercase tracking-wider">Compliance Status</h4>
|
||||||
|
<ComplianceChecklist data={selectedVendor} type="vendor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documents & Actions */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2">
|
||||||
|
<FileText size={16} className="text-blue-500" />
|
||||||
|
Actions
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<button className="w-full p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:border-amber-400/50 hover:bg-zinc-100 dark:hover:bg-white/10 text-left transition-all group">
|
||||||
|
<span className="block text-sm font-bold text-zinc-700 dark:text-zinc-300 group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors">Request COI Update</span>
|
||||||
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">Send automated email request</span>
|
||||||
|
</button>
|
||||||
|
<button className="w-full p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:border-blue-400/50 hover:bg-zinc-100 dark:hover:bg-white/10 text-left transition-all group">
|
||||||
|
<span className="block text-sm font-bold text-zinc-700 dark:text-zinc-300 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">View Invoices</span>
|
||||||
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">See payment history</span>
|
||||||
|
</button>
|
||||||
|
<button className="w-full p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:border-red-400/50 hover:bg-zinc-100 dark:hover:bg-white/10 text-left transition-all group">
|
||||||
|
<span className="block text-sm font-bold text-zinc-700 dark:text-zinc-300 group-hover:text-red-600 dark:group-hover:text-red-400 transition-colors">Suspend Vendor</span>
|
||||||
|
<span className="text-xs text-zinc-500 dark:text-zinc-400">Restrict access to new projects</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
) : (
|
||||||
|
<SpotlightCard className="h-full border-dashed border-zinc-300 dark:border-white/10 bg-zinc-50/50 dark:bg-white/5 flex items-center justify-center p-8 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="w-16 h-16 bg-zinc-100 dark:bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4 text-zinc-400">
|
||||||
|
<Briefcase size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold text-zinc-900 dark:text-white mb-2">Select a Vendor</h3>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto text-sm">Choose a vendor from the list to view their compliance status, spend history, and manage their account.</p>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorDirectory;
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
import { StatCard } from '../../components/StatCard';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
import { CheckSquare, Clock, Wallet, MapPin, Camera } from 'lucide-react';
|
||||||
|
import TaskDetailsModal from '../../components/contractor/TaskDetailsModal';
|
||||||
|
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
|
||||||
|
|
||||||
|
const SubContractorDashboard = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { projects } = useMockStore();
|
||||||
|
|
||||||
|
// 1. Identify current subcontractor (e.g. 'sub_001')
|
||||||
|
const subId = user?.id || 'sub_001';
|
||||||
|
|
||||||
|
// 2. Aggregate Tasks (Milestones assigned to me)
|
||||||
|
const myTasks = projects.flatMap(p =>
|
||||||
|
p.milestones.filter(m => m.assignedTo === subId)
|
||||||
|
.map(m => ({
|
||||||
|
...m,
|
||||||
|
projectAddress: p.address,
|
||||||
|
projectId: p.id,
|
||||||
|
projectStatus: p.status
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTasks = myTasks.filter(t => t.status === 'in_progress').length;
|
||||||
|
const pendingTasks = myTasks.filter(t => t.status === 'pending').length;
|
||||||
|
const completedTasks = myTasks.filter(t => t.status === 'completed').length;
|
||||||
|
|
||||||
|
// 3. Financials (Invoices submitted by me)
|
||||||
|
const myInvoices = projects.flatMap(p =>
|
||||||
|
p.invoices.filter(i => i.submittedBy === subId)
|
||||||
|
);
|
||||||
|
const pendingPay = myInvoices.filter(i => i.status === 'pending').reduce((sum, i) => sum + i.amount, 0);
|
||||||
|
const totalEarnings = myInvoices.filter(i => i.status === 'paid').reduce((sum, i) => sum + i.amount, 0);
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [selectedTask, setSelectedTask] = useState(null);
|
||||||
|
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||||
|
const [isFinancialModalOpen, setIsFinancialModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Financial data for modal
|
||||||
|
const financialData = {
|
||||||
|
total: totalEarnings + pendingPay,
|
||||||
|
paid: totalEarnings,
|
||||||
|
pending: pendingPay,
|
||||||
|
spent: 0,
|
||||||
|
remaining: 0,
|
||||||
|
items: myInvoices.map(inv => ({
|
||||||
|
date: inv.dueDate || 'N/A',
|
||||||
|
description: `Invoice #${inv.id}`,
|
||||||
|
project: myTasks.find(t => t.projectId === inv.projectId)?.projectAddress || 'Unknown',
|
||||||
|
status: inv.status,
|
||||||
|
amount: inv.amount
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskClick = (task) => {
|
||||||
|
setSelectedTask(task);
|
||||||
|
setIsTaskModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskUpdate = (taskId, actionType) => {
|
||||||
|
console.log(`Task ${taskId} updated with action: ${actionType}`);
|
||||||
|
// In a real app, this would update the backend
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8 animate-in fade-in duration-500">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Field Task Center</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
You have <span className="text-blue-500 font-bold">{activeTasks} active tasks</span> and <span className="text-emerald-500 font-bold">${pendingPay.toLocaleString()}</span> in pending payouts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions (Mobile First) */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<button className="flex flex-col items-center justify-center p-4 rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-500/30 hover:bg-blue-500 transition-all active:scale-95">
|
||||||
|
<Camera size={24} className="mb-2" />
|
||||||
|
<span className="text-sm font-bold">Upload Photo</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex flex-col items-center justify-center p-4 rounded-xl bg-zinc-100 dark:bg-white/10 text-zinc-900 dark:text-white hover:bg-zinc-200 dark:hover:bg-white/20 transition-all active:scale-95">
|
||||||
|
<Clock size={24} className="mb-2" />
|
||||||
|
<span className="text-sm font-bold">Clock In</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">
|
||||||
|
<MapPin size={24} className="mb-2" />
|
||||||
|
<span className="text-sm font-bold">Check Location</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex flex-col items-center justify-center p-4 rounded-xl bg-emerald-600 text-white shadow-lg shadow-emerald-500/30 hover:bg-emerald-500 transition-all active:scale-95">
|
||||||
|
<CheckSquare size={24} className="mb-2" />
|
||||||
|
<span className="text-sm font-bold">Complete Task</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<StatCard
|
||||||
|
label="Tasks In Progress"
|
||||||
|
value={activeTasks}
|
||||||
|
icon={Clock}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Pending Tasks"
|
||||||
|
value={pendingTasks}
|
||||||
|
icon={CheckSquare}
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
||||||
|
<StatCard
|
||||||
|
label="Pending Payouts"
|
||||||
|
value={`$${pendingPay.toLocaleString()}`}
|
||||||
|
icon={Wallet}
|
||||||
|
color="emerald"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<StatCard
|
||||||
|
label="Jobs Completed"
|
||||||
|
value={completedTasks}
|
||||||
|
icon={CheckSquare}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Task List */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<SpotlightCard className="p-6 h-full">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">My Assignments</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{myTasks.length > 0 ? myTasks.map((task, idx) => (
|
||||||
|
<div key={idx} className="group p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer" onClick={() => handleTaskClick(task)}>
|
||||||
|
<div className="flex flex-col md:flex-row justify-between md:items-center gap-4">
|
||||||
|
|
||||||
|
{/* Left: Task Info */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`p-3 rounded-lg ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400' :
|
||||||
|
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400' :
|
||||||
|
'bg-zinc-100 text-zinc-600 dark:bg-white/10 dark:text-zinc-400'
|
||||||
|
}`}>
|
||||||
|
<CheckSquare size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-zinc-900 dark:text-white">{task.name}</h4>
|
||||||
|
<div className="flex items-center text-xs text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
<MapPin size={12} className="mr-1" />
|
||||||
|
{task.projectAddress}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Status & Action */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs font-mono font-medium text-zinc-500 mb-1">Due: {task.dueDate}</p>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600' :
|
||||||
|
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600' :
|
||||||
|
'bg-amber-100 text-amber-600'
|
||||||
|
}`}>
|
||||||
|
{task.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{task.status !== 'completed' && (
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTaskClick(task);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="text-center py-10 text-zinc-500">
|
||||||
|
<p>No tasks assigned.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Earnings Widget */}
|
||||||
|
<div>
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Earnings Tracker</h2>
|
||||||
|
<div className="relative pt-4 pb-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-1">Total Earned (YTD)</p>
|
||||||
|
<h3 className="text-4xl font-mono font-bold text-emerald-500">${totalEarnings.toLocaleString()}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-zinc-500">Paid Invoices</span>
|
||||||
|
<span className="font-bold text-zinc-900 dark:text-white">{myInvoices.filter(i => i.status === 'paid').length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-zinc-500">Pending Invoices</span>
|
||||||
|
<span className="font-bold text-amber-500">{myInvoices.filter(i => i.status === 'pending').length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full mt-8 py-3 rounded-xl bg-zinc-100 dark:bg-white/10 hover:bg-zinc-200 dark:hover:bg-white/20 text-zinc-900 dark:text-white font-bold text-sm transition-colors">
|
||||||
|
View Payment History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<TaskDetailsModal
|
||||||
|
isOpen={isTaskModalOpen}
|
||||||
|
onClose={() => setIsTaskModalOpen(false)}
|
||||||
|
task={selectedTask}
|
||||||
|
onUpdate={handleTaskUpdate}
|
||||||
|
/>
|
||||||
|
<FinancialSummaryModal
|
||||||
|
isOpen={isFinancialModalOpen}
|
||||||
|
onClose={() => setIsFinancialModalOpen(false)}
|
||||||
|
role="SUBCONTRACTOR"
|
||||||
|
data={financialData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubContractorDashboard;
|
||||||
Vendored
+258
@@ -0,0 +1,258 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
import { StatCard } from '../../components/StatCard';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
import { DollarSign, ClipboardCheck, TrendingUp, AlertCircle, Calendar, FileText, CheckCircle2 } from 'lucide-react';
|
||||||
|
import OrderDetailsModal from '../../components/vendor/OrderDetailsModal';
|
||||||
|
import VendorFinancialSummaryModal from '../../components/vendor/VendorFinancialSummaryModal';
|
||||||
|
import ComplianceDetailsModal from '../../components/vendor/ComplianceDetailsModal';
|
||||||
|
import PerformanceMetricsModal from '../../components/vendor/PerformanceMetricsModal';
|
||||||
|
|
||||||
|
const VendorDashboard = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { vendors, projects, orders, vendorInvoices } = useMockStore();
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||||
|
const [isOrderModalOpen, setIsOrderModalOpen] = useState(false);
|
||||||
|
const [isFinancialModalOpen, setIsFinancialModalOpen] = useState(false);
|
||||||
|
const [isComplianceModalOpen, setIsComplianceModalOpen] = useState(false);
|
||||||
|
const [isPerformanceModalOpen, setIsPerformanceModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 1. Identify current vendor
|
||||||
|
// For mock purposes, if logged in as 'abc_supply', we map to 'v3'
|
||||||
|
// In real app, user.vendorId would track this.
|
||||||
|
const vendorId = user.id === 'ven_001' ? 'v3' : 'v1';
|
||||||
|
const vendorData = vendors.find(v => v.id === vendorId) || vendors[0];
|
||||||
|
|
||||||
|
// 2. Aggregate Data
|
||||||
|
// Find projects where this vendor is assigned to milestones or has invoices
|
||||||
|
// A. Milestones
|
||||||
|
const activeDeliveries = projects.flatMap(p => p.milestones)
|
||||||
|
.filter(m => m.assignedTo === vendorId && m.status === 'pending').length;
|
||||||
|
|
||||||
|
const completedDeliveries = projects.flatMap(p => p.milestones)
|
||||||
|
.filter(m => m.assignedTo === vendorId && m.status === 'completed').length;
|
||||||
|
|
||||||
|
// B. Financials
|
||||||
|
const pendingInvoices = vendorData.spend?.pendingInvoices || 0;
|
||||||
|
const totalEarnings = vendorData.spend?.totalSpend || 0;
|
||||||
|
|
||||||
|
// C. Compliance
|
||||||
|
const complianceStatus = vendorData.compliance?.coi?.status === 'compliant' ? 'Verified' : 'Action Required';
|
||||||
|
|
||||||
|
// D. Financial Data for Modal
|
||||||
|
const vendorOrders = orders.filter(o => o.vendorId === vendorId);
|
||||||
|
const vendorInvoicesList = vendorInvoices.filter(inv => inv.vendorId === vendorId);
|
||||||
|
const paidInvoices = vendorInvoicesList.filter(inv => inv.status === 'paid').reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
const pendingPayments = vendorInvoicesList.filter(inv => inv.status === 'pending').reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
const financialData = {
|
||||||
|
totalEarnings: vendorData.spend?.ytdSpend || 0,
|
||||||
|
paidInvoices,
|
||||||
|
pendingPayments,
|
||||||
|
invoices: vendorInvoicesList
|
||||||
|
};
|
||||||
|
|
||||||
|
// E. Click Handlers
|
||||||
|
const handleOrderClick = (order) => {
|
||||||
|
setSelectedOrder(order);
|
||||||
|
setIsOrderModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8 animate-in fade-in duration-500">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Vendor Portal</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
Welcome back, <span className="text-blue-500 font-semibold">{vendorData.vendorName}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
onClick={() => setIsComplianceModalOpen(true)}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-bold uppercase cursor-pointer hover:opacity-80 transition-opacity ${complianceStatus === 'Verified'
|
||||||
|
? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||||
|
: 'bg-red-100 text-red-600 dark:bg-red-500/10 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
Compliance: {complianceStatus}
|
||||||
|
</span>
|
||||||
|
<button className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||||
|
Submit Invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
||||||
|
<StatCard
|
||||||
|
label="Pending Invoices"
|
||||||
|
value={`$${pendingInvoices.toLocaleString()}`}
|
||||||
|
icon={DollarSign}
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<StatCard
|
||||||
|
label="Active Orders"
|
||||||
|
value={activeDeliveries}
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
color="blue"
|
||||||
|
onClick={() => navigate('/vendor/orders')}
|
||||||
|
className="cursor-pointer hover:border-blue-500/50 transition-colors"
|
||||||
|
/>
|
||||||
|
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
||||||
|
<StatCard
|
||||||
|
label="Total Earnings (YTD)"
|
||||||
|
value={`$${vendorData.spend?.ytdSpend?.toLocaleString() || '0'}`}
|
||||||
|
icon={TrendingUp}
|
||||||
|
color="emerald"
|
||||||
|
trend={12}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div onClick={() => setIsPerformanceModalOpen(true)} className="cursor-pointer">
|
||||||
|
<StatCard
|
||||||
|
label="Performance Rating"
|
||||||
|
value={vendorData.performance?.rating || 0}
|
||||||
|
icon={CheckCircle2}
|
||||||
|
color="purple"
|
||||||
|
suffix="/5.0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
{/* Active Projects / Deliveries */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<SpotlightCard className="p-6 h-full">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Active Orders & Deliveries</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{vendorOrders.map(order => (
|
||||||
|
<div
|
||||||
|
key={order.id}
|
||||||
|
onClick={() => handleOrderClick(order)}
|
||||||
|
className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-colors cursor-pointer group">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-3 rounded-lg bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400">
|
||||||
|
<FileText size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{order.id}</h4>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||||
|
{order.projectAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-mono font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Due: {order.dueDate}
|
||||||
|
</p>
|
||||||
|
<span className={`text-xs font-bold uppercase ${order.status === 'delivered' ? 'text-emerald-500' :
|
||||||
|
order.status === 'shipped' ? 'text-blue-500' : 'text-amber-500'
|
||||||
|
}`}>
|
||||||
|
{order.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{vendorOrders.length === 0 && (
|
||||||
|
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<p>No active orders found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Updates & Alerts */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Action Center</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Invoices */}
|
||||||
|
<div className="p-4 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-100 dark:border-amber-500/20">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle size={18} className="text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-amber-900 dark:text-amber-100">Review Rejected Invoice</h4>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
||||||
|
Invoice #INV-2024-001 was flagged for missing PO number.
|
||||||
|
</p>
|
||||||
|
<button className="mt-2 text-xs font-bold text-amber-600 dark:text-amber-400 hover:underline">
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compliance */}
|
||||||
|
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Calendar size={18} className="text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-blue-900 dark:text-blue-100">COI Renewal Upcoming</h4>
|
||||||
|
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||||
|
Your General Liability policy expires in 45 days.
|
||||||
|
</p>
|
||||||
|
<button className="mt-2 text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
Upload Renewal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Quick Stats</h2>
|
||||||
|
<ul className="space-y-3 text-sm">
|
||||||
|
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500">On-Time Delivery Rate</span>
|
||||||
|
<span className="font-bold text-emerald-500">{vendorData.performance?.onTimeRate * 100}%</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500">Average Payment Window</span>
|
||||||
|
<span className="font-bold text-zinc-900 dark:text-white">Net 15</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500">Active Contracts</span>
|
||||||
|
<span className="font-bold text-zinc-900 dark:text-white">3</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</SpotlightCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<OrderDetailsModal
|
||||||
|
isOpen={isOrderModalOpen}
|
||||||
|
onClose={() => setIsOrderModalOpen(false)}
|
||||||
|
order={selectedOrder}
|
||||||
|
/>
|
||||||
|
<VendorFinancialSummaryModal
|
||||||
|
isOpen={isFinancialModalOpen}
|
||||||
|
onClose={() => setIsFinancialModalOpen(false)}
|
||||||
|
data={financialData}
|
||||||
|
/>
|
||||||
|
<ComplianceDetailsModal
|
||||||
|
isOpen={isComplianceModalOpen}
|
||||||
|
onClose={() => setIsComplianceModalOpen(false)}
|
||||||
|
vendorData={vendorData}
|
||||||
|
/>
|
||||||
|
<PerformanceMetricsModal
|
||||||
|
isOpen={isPerformanceModalOpen}
|
||||||
|
onClose={() => setIsPerformanceModalOpen(false)}
|
||||||
|
vendorData={vendorData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorDashboard;
|
||||||
Vendored
+148
@@ -0,0 +1,148 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
import { Package, FileText, Filter, Search, ChevronRight, CheckCircle, Clock, Truck } from 'lucide-react';
|
||||||
|
import OrderDetailsModal from '../../components/vendor/OrderDetailsModal';
|
||||||
|
|
||||||
|
const VendorOrders = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { projects, orders } = useMockStore();
|
||||||
|
const [filter, setFilter] = useState('all');
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||||
|
const [isOrderModalOpen, setIsOrderModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Mock ID logic
|
||||||
|
const vendorId = user.id === 'ven_001' ? 'v3' : 'v1';
|
||||||
|
|
||||||
|
// Get vendor orders from MOCK_ORDERS
|
||||||
|
const vendorOrders = orders.filter(o => o.vendorId === vendorId);
|
||||||
|
const filteredOrders = filter === 'all' ? vendorOrders : vendorOrders.filter(o => o.status === filter);
|
||||||
|
|
||||||
|
const handleOrderClick = (order) => {
|
||||||
|
setSelectedOrder(order);
|
||||||
|
setIsOrderModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'delivered': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400';
|
||||||
|
case 'shipped': return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400';
|
||||||
|
default: return 'bg-zinc-100 text-zinc-700 dark:bg-white/10 dark:text-zinc-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8 animate-in fade-in duration-500">
|
||||||
|
<header className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">My Orders</h1>
|
||||||
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">Manage purchase orders and deliveries.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search orders..."
|
||||||
|
className="pl-9 pr-4 py-2 rounded-lg 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>
|
||||||
|
<button className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-sm font-medium hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors">
|
||||||
|
<Filter size={16} /> Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Status Tabs */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||||
|
{['all', 'pending', 'confirmed', 'shipped', 'delivered'].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setFilter(tab)}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-bold uppercase tracking-wide whitespace-nowrap transition-colors ${filter === tab
|
||||||
|
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||||
|
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpotlightCard className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-200 dark:border-white/10 text-xs font-bold uppercase tracking-wider text-zinc-500 bg-zinc-50/50 dark:bg-white/5">
|
||||||
|
<th className="p-4">Order ID</th>
|
||||||
|
<th className="p-4">Project / Location</th>
|
||||||
|
<th className="p-4">Items / Service</th>
|
||||||
|
<th className="p-4">Due Date</th>
|
||||||
|
<th className="p-4">Amount</th>
|
||||||
|
<th className="p-4">Status</th>
|
||||||
|
<th className="p-4">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{filteredOrders.map(order => (
|
||||||
|
<tr
|
||||||
|
key={order.id}
|
||||||
|
onClick={() => handleOrderClick(order)}
|
||||||
|
className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors group cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="p-4 font-mono text-sm text-zinc-500">{order.id}</td>
|
||||||
|
<td className="p-4 text-sm font-medium text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||||
|
{order.projectAddress || order.project}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Package size={16} className="text-zinc-400" />
|
||||||
|
{order.items ? `${order.items.length} items` : order.item}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-sm text-zinc-500">{order.dueDate}</td>
|
||||||
|
<td className="p-4 text-sm font-medium text-zinc-900 dark:text-white">${(order.total || order.amount).toLocaleString()}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide ${getStatusColor(order.status)}`}>
|
||||||
|
{order.status === 'delivered' && <CheckCircle size={12} className="mr-1.5" />}
|
||||||
|
{order.status === 'shipped' && <Truck size={12} className="mr-1.5" />}
|
||||||
|
{(order.status === 'pending' || order.status === 'confirmed') && <Clock size={12} className="mr-1.5" />}
|
||||||
|
{order.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOrderClick(order);
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-lg hover:bg-zinc-200 dark:hover:bg-white/10 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{filteredOrders.length === 0 && (
|
||||||
|
<div className="p-12 text-center text-zinc-500">
|
||||||
|
<Package size={48} className="mx-auto mb-4 opacity-20" />
|
||||||
|
<p>No orders found matching this filter.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
{/* Order Details Modal */}
|
||||||
|
<OrderDetailsModal
|
||||||
|
isOpen={isOrderModalOpen}
|
||||||
|
onClose={() => setIsOrderModalOpen(false)}
|
||||||
|
order={selectedOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VendorOrders;
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { ROLES } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export const PERMISSIONS = {
|
||||||
|
// Admin / Owner Capabilities
|
||||||
|
VIEW_ALL_PROJECTS: 'VIEW_ALL_PROJECTS',
|
||||||
|
VIEW_ALL_PERSONNEL: 'VIEW_ALL_PERSONNEL',
|
||||||
|
VIEW_FINANCIALS: 'VIEW_FINANCIALS',
|
||||||
|
VIEW_SENSITIVE_DATA: 'VIEW_SENSITIVE_DATA', // SSN, Bank Accounts
|
||||||
|
MANAGE_USERS: 'MANAGE_USERS',
|
||||||
|
APPROVE_DOCUMENTS: 'APPROVE_DOCUMENTS',
|
||||||
|
|
||||||
|
// Contractor / Vendor Capabilities
|
||||||
|
VIEW_ASSIGNED_PROJECTS: 'VIEW_ASSIGNED_PROJECTS',
|
||||||
|
UPLOAD_DOCUMENTS: 'UPLOAD_DOCUMENTS',
|
||||||
|
SUBMIT_INVOICES: 'SUBMIT_INVOICES',
|
||||||
|
MANAGE_OWN_CREW: 'MANAGE_OWN_CREW',
|
||||||
|
|
||||||
|
// Subcontractor Capabilities
|
||||||
|
VIEW_ASSIGNED_TASKS: 'VIEW_ASSIGNED_TASKS',
|
||||||
|
UPDATE_TASK_STATUS: 'UPDATE_TASK_STATUS'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_PERMISSIONS = {
|
||||||
|
[ROLES.OWNER]: [
|
||||||
|
PERMISSIONS.VIEW_ALL_PROJECTS,
|
||||||
|
PERMISSIONS.VIEW_ALL_PERSONNEL,
|
||||||
|
PERMISSIONS.VIEW_FINANCIALS,
|
||||||
|
PERMISSIONS.VIEW_SENSITIVE_DATA,
|
||||||
|
PERMISSIONS.MANAGE_USERS,
|
||||||
|
PERMISSIONS.APPROVE_DOCUMENTS
|
||||||
|
],
|
||||||
|
[ROLES.ADMIN]: [
|
||||||
|
PERMISSIONS.VIEW_ALL_PROJECTS,
|
||||||
|
PERMISSIONS.VIEW_ALL_PERSONNEL,
|
||||||
|
PERMISSIONS.VIEW_FINANCIALS,
|
||||||
|
PERMISSIONS.MANAGE_USERS,
|
||||||
|
PERMISSIONS.APPROVE_DOCUMENTS
|
||||||
|
// Note: Admin might NOT have VIEW_SENSITIVE_DATA by default without extra auth
|
||||||
|
],
|
||||||
|
[ROLES.CONTRACTOR]: [
|
||||||
|
PERMISSIONS.VIEW_ASSIGNED_PROJECTS,
|
||||||
|
PERMISSIONS.UPLOAD_DOCUMENTS,
|
||||||
|
PERMISSIONS.SUBMIT_INVOICES,
|
||||||
|
PERMISSIONS.MANAGE_OWN_CREW
|
||||||
|
],
|
||||||
|
[ROLES.SUBCONTRACTOR]: [
|
||||||
|
PERMISSIONS.VIEW_ASSIGNED_TASKS,
|
||||||
|
PERMISSIONS.UPDATE_TASK_STATUS
|
||||||
|
],
|
||||||
|
[ROLES.FIELD_AGENT]: [
|
||||||
|
// Standard CRM permissions
|
||||||
|
],
|
||||||
|
[ROLES.CUSTOMER]: [
|
||||||
|
// Portal permissions
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a user has a specific permission.
|
||||||
|
* @param {Object} user - The user object from AuthContext
|
||||||
|
* @param {String} permission - The permission key to check
|
||||||
|
* @returns {Boolean}
|
||||||
|
*/
|
||||||
|
export const hasPermission = (user, permission) => {
|
||||||
|
if (!user || !user.role) return false;
|
||||||
|
const userPermissions = ROLE_PERMISSIONS[user.role] || [];
|
||||||
|
return userPermissions.includes(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters a list of projects based on user role.
|
||||||
|
* @param {Object} user - The user object
|
||||||
|
* @param {Array} projects - Array of project objects
|
||||||
|
* @returns {Array} - Filtered projects
|
||||||
|
*/
|
||||||
|
export const filterProjectsForUser = (user, projects) => {
|
||||||
|
if (!user) return [];
|
||||||
|
|
||||||
|
if (user.role === ROLES.OWNER || user.role === ROLES.ADMIN) {
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === ROLES.CONTRACTOR) {
|
||||||
|
return projects.filter(p => p.contractorId === user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === ROLES.SUBCONTRACTOR) {
|
||||||
|
return projects.filter(p => p.subcontractorIds?.includes(user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if sensitive data should be masked.
|
||||||
|
* @param {Object} user - The viewer
|
||||||
|
* @returns {Boolean} - True if data should be visible (NOT masked), False if it should be masked.
|
||||||
|
*/
|
||||||
|
export const canViewSensitiveData = (user) => {
|
||||||
|
return hasPermission(user, PERMISSIONS.VIEW_SENSITIVE_DATA);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user