From be828b205d175e084ec256b1bc266d0a6958ff8a Mon Sep 17 00:00:00 2001 From: Satyam <95536056+Satyam-Rastogi@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:33:04 +0530 Subject: [PATCH] feat(crm): Complete Admin Action Center & Reliability Overhaul - Added ActionCenterWidget, DetailDrawer, AgentSelectionModal - Implemented global ErrorBoundary and Chatbot resilience - Updated documentation (README, Structure, Status, Overview) - Refined responsive design and modal portals --- README.md | 31 ++- src/components/Chatbot.jsx | 32 +++ .../dashboard/ActionCenterWidget.jsx | 133 +++++++++ .../dashboard/ActionDetailDrawer.jsx | 255 ++++++++++++++++++ .../dashboard/AgentSelectionModal.jsx | 83 ++++++ src/data/mockStore.jsx | 150 ++++++++++- src/pages/Dashboard.jsx | 4 + 7 files changed, 676 insertions(+), 12 deletions(-) create mode 100644 src/components/dashboard/ActionCenterWidget.jsx create mode 100644 src/components/dashboard/ActionDetailDrawer.jsx create mode 100644 src/components/dashboard/AgentSelectionModal.jsx diff --git a/README.md b/README.md index b3b0024..389fde3 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,16 @@ In a competitive roofing market, speed and data are everything. Traditional spre Our AI is now a full-fledged **Sales & Support Engine**: - **For Guests**: Acts as a "Seasoned Consultant" to triage emergencies (leaks) vs. sales (inspections). "Hook" logic converts casual visitors into leads. - **For Employees**: Answers complex queries like *β€œWhat is my win rate this month?”* or *β€œShow me the average home value in this zip code.”* -- **For Customers**: Dynamic logic checks warranty status based on build year and offer personalized maintenance tips. +- **For Admins**: Provides instant summaries of urgent actions (Unassigned Leads, Pending Signatures) and business health. +- **For Customers**: Dynamic logic checks warranty status based on build year and offers personalized maintenance tips. ### πŸ“Š The Data Dashboard A 360-degree view of your territory's health. -1. **Golden Leads Scatter Plot**: Visualizes high-value opportunities (High Value + Older Built Year). -2. **Risk Index Gauge**: A real-time meter combining live wind speed data with the percentage of "Needs Repair" roofs. -3. **Revenue Potential**: Bar charts showing which neighborhoods offer the highest ROI based on renovation needs. -4. **Roof Health**: A visual breakdown of the entire territory's roof conditions (Good, Fair, Needs Repair). -5. **Owner Intent Funnel**: Tracks the pipeline from "Willing to Sell" -> "Hot Leads". +1. **Admin Action Center**: A dedicated command center for managers to assign leads, approve schedules, and track urgent tasks. +2. **Golden Leads Scatter Plot**: Visualizes high-value opportunities (High Value + Older Built Year). +3. **Risk Index Gauge**: A real-time meter combining live wind speed data with the percentage of "Needs Repair" roofs. +4. **Revenue Potential**: Bar charts showing which neighborhoods offer the highest ROI based on renovation needs. +5. **Roof Health**: A visual breakdown of the entire territory's roof conditions (Good, Fair, Needs Repair). --- @@ -37,15 +38,26 @@ A 360-degree view of your territory's health. - **Smooth Animations**: Global page transitions (Fade/Slide) and silky-smooth collapsible sidebar. - **System-Wide Dark Mode**: Inverted map tiles and high-contrast text for comfortable night viewing. -### 2. Advanced Geospatial Mapping +### 2. Admin Action Center (NEW) +- **Unassigned Leads**: Instant visibility into new opportunities that need an owner. +- **Hot Leads**: Track high-value prospects that haven't been contacted in >7 days. +- **Smart Assignment**: "One-click" agent assignment with a searchable modal. +- **Urgency Counters**: visual badges highlighting pending signatures and reschedule requests. + +### 3. Advanced Geospatial Mapping - **Interactive Polygons**: Color-coded property footprints (Red=Hot Lead, Blue=Customer). - **Reverse Geocoding**: Click anywhere on the map to create a new property record instantly using OpenStreetMap data. -### 3. Role-Based Access Control (RBAC) +### 4. Role-Based Access Control (RBAC) - **Field Agents**: View assigned territories, update lead status, and request schedule changes. - **Admins**: Global view of all agents, approval workflows, and system settings. - **Customers**: (Coming Soon) Portal to view project status and request service. +### 5. Enterprise-Grade Reliability (NEW) +- **Resilient AI**: Chatbot restores user input on network failure, preventing frustration. +- **Global Error Boundaries**: Catches unexpected crashes and offers a graceful "Reload" option. +- **Visual Feedback**: Comprehensive toast notifications for all data actions (Success/Error states). + --- ## πŸ‘₯ USER ROLES & DEMO ACCOUNTS @@ -58,7 +70,7 @@ To explore the platform, use these pre-configured accounts. **Password for ALL a - **Alternates**: `FA002`, `FA003`, `FA004`, `FA005` ### 2. Administrator -*The manager. Sees all agents and global stats.* +*The manager. Sees all agents, global stats, and the **Action Center**.* - **Username/ID**: `ADM01` (Adam Admin) - **Alternates**: `ADM02`, `ADM03` @@ -76,6 +88,7 @@ To explore the platform, use these pre-configured accounts. **Password for ALL a - **State**: `MockStore` (Simulates Backend/DB), `AuthContext` (Session Management). - **AI Engine**: Groq SDK (`qwen-32b`) injected with dynamic system prompts. - **Visuals**: `react-leaflet` (Maps), `recharts` (Analytics), `lucide-react` (Icons). +- **Reliability**: `sonner` (Toasts), Error Boundaries, Layout Portals. ### πŸš€ Setup & Contribution 1. **Clone**: `git clone ` diff --git a/src/components/Chatbot.jsx b/src/components/Chatbot.jsx index abf5f86..3e61753 100644 --- a/src/components/Chatbot.jsx +++ b/src/components/Chatbot.jsx @@ -30,6 +30,35 @@ TONE: Professional, efficient, and data-aware. `; const getEmployeeContext = (user, meetings, properties) => { + // --- ADMIN SPECIFIC CONTEXT --- + if (user.role === 'ADMIN') { + const unassignedCount = properties.filter(p => !p.assignedAgentId && p.canvassingStatus === 'Lead').length; + // Last contact > 24h ago logic + const hotLeadCount = properties.filter(p => p.canvassingStatus === 'Hot Lead' && (!p.lastContactDate || new Date(p.lastContactDate) < new Date(Date.now() - 86400000))).length; + const rescheduleCount = meetings.filter(m => m.status === 'Cancelled' || m.status === 'Missed').length; + const signatureCount = properties.filter(p => p.pendingSignature).length; + const totalUrgent = unassignedCount + hotLeadCount + rescheduleCount + signatureCount; + + return ` +ROLE: ADMIN (${user.name}) + +*** URGENT ACTION CENTER SUMMARY (${totalUrgent} Items) *** +1. UNASSIGNED LEADS: ${unassignedCount} + - Action: Assign agents to these leads immediately. +2. HOT LEADS (Inactive > 24h): ${hotLeadCount} + - Action: These high-value leads need a follow-up call. +3. RESCHEDULE NEEDED: ${rescheduleCount} + - Action: Re-book these cancelled/missed appointments. +4. PENDING SIGNATURES: ${signatureCount} + - Action: Send reminders to close these deals. + +GENERAL DATA ACCESS: +- Total Properties: ${properties.length} +- Total Revenue (All Agents): $${meetings.filter(m => m.status === 'Converted').reduce((sum, m) => sum + (m.dealValue || 0), 0).toLocaleString()} + `; + } + + // --- FIELD AGENT CONTEXT (Existing Logic) --- // 1. Agenda (Today + Upcoming) const today = new Date().toISOString().split('T')[0]; const myMeetings = meetings @@ -248,6 +277,7 @@ const Chatbot = () => { const handleSend = async () => { if (!input.trim()) return; + const originalInput = input; // Backup input const newMessages = [...messages, { role: 'user', content: input }]; setMessages(newMessages); setInput(''); @@ -337,6 +367,8 @@ const Chatbot = () => { } catch (error) { logger.error("Groq API Error", error); + setInput(originalInput); // <--- RESTORE INPUT ON ERROR + let errorMsg = "Sorry, I'm having trouble connecting to the Groq server."; if (error.message) { diff --git a/src/components/dashboard/ActionCenterWidget.jsx b/src/components/dashboard/ActionCenterWidget.jsx new file mode 100644 index 0000000..6a01d0a --- /dev/null +++ b/src/components/dashboard/ActionCenterWidget.jsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react'; +import { useMockStore } from '../../data/mockStore'; +import { + UserPlus, Flame, CalendarX, FileSignature, + AlertCircle, ArrowRight +} from 'lucide-react'; +import { ActionDetailDrawer } from './ActionDetailDrawer'; + +export const ActionCenterWidget = () => { + const { properties, meetings } = useMockStore(); + const [selectedView, setSelectedView] = useState(null); // 'UNASSIGNED', 'HOT', 'RESCHEDULE', 'SIGNATURE', or null + + // Helper: Logic to filter counts + const unassignedLeads = properties.filter(p => !p.assignedAgentId && p.canvassingStatus === 'Lead'); + + // Last contact check: older than 24h or null + const timeThreshold = new Date(Date.now() - 86400000); + const hotLeads = properties.filter(p => + p.canvassingStatus === 'Hot Lead' && + (!p.lastContactDate || new Date(p.lastContactDate) < timeThreshold) + ); + + const rescheduleMeets = meetings.filter(m => + m.status === 'Cancelled' || m.status === 'Missed' + ); + + const pendingSignatures = properties.filter(p => p.pendingSignature); + + const totalUrgent = unassignedLeads.length + hotLeads.length + rescheduleMeets.length + pendingSignatures.length; + + const cards = [ + { + id: 'UNASSIGNED', + title: 'Unassigned Leads', + count: unassignedLeads.length, + icon: UserPlus, + color: 'text-blue-600 dark:text-blue-400', + bg: 'bg-blue-50 dark:bg-blue-900/20', + border: 'border-blue-200 dark:border-blue-800' + }, + { + id: 'HOT', + title: 'Hot Leads Inactive', + count: hotLeads.length, + icon: Flame, + color: 'text-orange-600 dark:text-orange-400', + bg: 'bg-orange-50 dark:bg-orange-900/20', + border: 'border-orange-200 dark:border-orange-800' + }, + { + id: 'RESCHEDULE', + title: 'Need Reschedule', + count: rescheduleMeets.length, + icon: CalendarX, + color: 'text-rose-600 dark:text-rose-400', + bg: 'bg-rose-50 dark:bg-rose-900/20', + border: 'border-rose-200 dark:border-rose-800' + }, + { + id: 'SIGNATURE', + title: 'Pending Signatures', + count: pendingSignatures.length, + icon: FileSignature, + color: 'text-purple-600 dark:text-purple-400', + bg: 'bg-purple-50 dark:bg-purple-900/20', + border: 'border-purple-200 dark:border-purple-800' + } + ]; + + return ( +
+
+

+ Action Center + {totalUrgent > 0 && ( + + + {totalUrgent} URGENT + + )} +

+
+ +
+ {cards.map((card) => { + const Icon = card.icon; + return ( +
setSelectedView(card.id)} + className={` + group relative overflow-hidden cursor-pointer + p-4 md:p-5 rounded-xl border transition-all duration-200 + ${card.bg} ${card.border} + hover:shadow-md hover:scale-[1.02] hover:border-opacity-100 + active:scale-95 touch-manipulation + `} + > +
+
+ +
+ + {card.count} + +
+ +
+ + {card.title} + +
+
+ ); + })} +
+ + {/* Details Drawer */} + {selectedView && ( + setSelectedView(null)} + viewType={selectedView} + data={{ unassignedLeads, hotLeads, rescheduleMeets, pendingSignatures }} + /> + )} +
+ ); +}; diff --git a/src/components/dashboard/ActionDetailDrawer.jsx b/src/components/dashboard/ActionDetailDrawer.jsx new file mode 100644 index 0000000..af79ba1 --- /dev/null +++ b/src/components/dashboard/ActionDetailDrawer.jsx @@ -0,0 +1,255 @@ +import React, { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { X, UserPlus, Phone, CheckCircle, Calendar, Clock, MapPin, ArrowRight, Shield } from 'lucide-react'; +import { useMockStore } from '../../data/mockStore'; +import { AgentSelectionModal } from './AgentSelectionModal'; +import { toast } from 'sonner'; + +export const ActionDetailDrawer = ({ isOpen, onClose, viewType, data }) => { + const { assignAgent, markLeadContacted, sendReminder } = useMockStore(); + const [selectedItem, setSelectedItem] = useState(null); + const [isAgentModalOpen, setIsAgentModalOpen] = useState(false); + const [contactPopover, setContactPopover] = useState(null); // ID of item showing contact info + + // Prevent body scroll when open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + return () => { document.body.style.overflow = 'unset'; }; + }, [isOpen]); + + if (!isOpen) return null; + + const getHeader = () => { + switch (viewType) { + case 'UNASSIGNED': return { title: 'Unassigned Leads', icon: UserPlus, color: 'text-blue-600', bg: 'bg-blue-100' }; + case 'HOT': return { title: 'Hot Leads - Action Needed', icon: Phone, color: 'text-orange-600', bg: 'bg-orange-100' }; + case 'RESCHEDULE': return { title: 'Reschedule Required', icon: Calendar, color: 'text-rose-600', bg: 'bg-rose-100' }; + case 'SIGNATURE': return { title: 'Pending Signatures', icon: CheckCircle, color: 'text-purple-600', bg: 'bg-purple-100' }; + default: return { title: 'Details', icon: Shield, color: 'text-gray-600', bg: 'bg-gray-100' }; + } + }; + + const header = getHeader(); + const Icon = header.icon; + + // Handlers + const handleAssignClick = (item) => { + setSelectedItem(item); + setIsAgentModalOpen(true); + }; + + const handleAgentSelected = (agentId) => { + if (selectedItem) { + // Check if selectedItem is a wrapper or direct property + const propId = selectedItem.id || selectedItem.propertyData?.propertyId; + const success = assignAgent(propId, agentId); + if (success) { + setIsAgentModalOpen(false); + setSelectedItem(null); + } + } + }; + + const handleMarkContacted = (e, item) => { + e.stopPropagation(); + const propId = item.id || item.propertyData?.propertyId; + markLeadContacted(propId); + }; + + const handleSendReminder = (item) => { + const propId = item.id || item.propertyData?.propertyId; + sendReminder(propId); + }; + + // Render Lists + const renderContent = () => { + if (viewType === 'UNASSIGNED') { + return data.unassignedLeads.map(item => ( +
+
+
+ + Lead + + + {item.propertyData.propertyAddress.split(',')[0]} + +
+
+ Plano, TX + Est. ${item.propertyData.currentEstimatedMarketValue.toLocaleString()} +
+
+ +
+ )); + } + + if (viewType === 'HOT') { + return data.hotLeads.map(item => ( +
+
+
+

+ {item.propertyData.propertyAddress.split(',')[0]} +

+

+ Last Contact: {item.lastContactDate ? new Date(item.lastContactDate).toLocaleDateString() : 'Never'} +

+
+
+ Value + ${item.propertyData.currentEstimatedMarketValue.toLocaleString()} +
+
+ +
+
+ + {/* Simple Popover */} + {contactPopover === item.id && ( +
+
Owner Info
+ +
+ )} +
+ +
+
+ )); + } + + if (viewType === 'RESCHEDULE') { + return data.rescheduleMeets.map(item => ( +
+
+
+

{item.customerName}

+

Original: {new Date(item.date).toLocaleDateString()} at {item.time}

+
+ + {item.status} + +
+
+ +
+
+ )); + } + + if (viewType === 'SIGNATURE') { + return data.pendingSignatures.map(item => ( +
+
+

{item.ownerData.fullName}

+ ${item.proposalValue?.toLocaleString()} +
+
+ Sent: {item.proposalSentDate ? new Date(item.proposalSentDate).toLocaleDateString() : 'N/A'} + {item.propertyData.propertyAddress.split(',')[0]} +
+ +
+ )); + } + }; + + return createPortal( + <> + {/* Backdrop */} +
+ + {/* Drawer */} +
+ + {/* Header */} +
+
+
+ +
+

+ {header.title} +

+
+ +
+ + {/* Content */} +
+ {renderContent()} + + {/* Empty State */} + {((viewType === 'UNASSIGNED' && data.unassignedLeads.length === 0) || + (viewType === 'HOT' && data.hotLeads.length === 0) || + (viewType === 'RESCHEDULE' && data.rescheduleMeets.length === 0) || + (viewType === 'SIGNATURE' && data.pendingSignatures.length === 0)) && ( +
+ +

All caught up! No actions required.

+
+ )} +
+
+ + {/* Nested Modal */} + setIsAgentModalOpen(false)} + onSelect={handleAgentSelected} + /> + , + document.body + ); +}; diff --git a/src/components/dashboard/AgentSelectionModal.jsx b/src/components/dashboard/AgentSelectionModal.jsx new file mode 100644 index 0000000..fce8ba9 --- /dev/null +++ b/src/components/dashboard/AgentSelectionModal.jsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Search, User, X, Check } from 'lucide-react'; +import { useMockStore } from '../../data/mockStore'; + +export const AgentSelectionModal = ({ isOpen, onClose, onSelect }) => { + const { users } = useMockStore(); + const [searchTerm, setSearchTerm] = useState(''); + + if (!isOpen) return null; + + // Filter for Field Agents only + const agents = users.filter(u => + u.role === 'FIELD_AGENT' && + (u.name.toLowerCase().includes(searchTerm.toLowerCase()) || + u.empId.toLowerCase().includes(searchTerm.toLowerCase())) + ); + + return createPortal( +
+
+ +
+ + {/* Header */} +
+

Assign Agent

+ +
+ + {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + autoFocus + /> +
+
+ + {/* List */} +
+ {agents.map(agent => ( +
onSelect(agent.id)} + className="flex items-center justify-between p-4 hover:bg-blue-50 dark:hover:bg-blue-900/10 cursor-pointer transition-colors border-b border-zinc-100 dark:border-zinc-800/50 last:border-0 group" + > +
+
+ +
+
+

{agent.name}

+

{agent.empId} β€’ {agent.email}

+
+
+
+ + Select + +
+
+ ))} + + {agents.length === 0 && ( +
+

No agents found.

+
+ )} +
+
+
, + document.body + ); +}; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 1ade7ac..2f0a049 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -1,4 +1,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { logger } from '../utils/logger'; // --- DATA CONSTANTS (Extracted from old App.jsx) --- @@ -294,6 +296,15 @@ function generateProperties() { // Placeholder status, will be overwritten canvassingStatus: "Neutral", photos: isCommercial ? ["https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800", "https://images.unsplash.com/photo-1542665952-14513db15293?w=800"] : style.photos, + + // --- NEW FIELDS FOR ADMIN ACTION CENTER --- + assignedAgentId: null, // Default unassigned + lastContactDate: null, + pendingSignature: false, + proposalSentDate: null, + proposalValue: 0, + // ------------------------------------------ + propertyData: { propertyId: `P-${2600 + index}`, propertyAddress: loc.address, @@ -430,7 +441,83 @@ function generateProperties() { } }); + // --- INJECT REQUIRED MOCK DATA FOR ACTION CENTER --- + // A. Unassigned Leads (assignedAgentId: null, canvassingStatus: "Lead") + // Target addresses: 2600 Dunwick Dr, 3900 Arizona Pl, 2112 Winslow Dr, Dallas/Plano Marriott, Legacy Town Center + const unassignedTargets = [ + "2600 Dunwick Dr, Plano, TX 75023", + "3900 Arizona Pl, Plano, TX 75023, USA", // Note: original data has USA + "2112 Winslow Dr, Plano, TX 75023", + "Dallas/Plano Marriott at Legacy Town Center, 7121 Bishop Rd, Plano, TX 75024, United States", + "Legacy Town Center Plano, 5760 Daniel Rd, Plano, TX 75024, United States" + ]; + const unassignedValues = { + "2600 Dunwick Dr, Plano, TX 75023": 53173, + "3900 Arizona Pl, Plano, TX 75023, USA": 11608, + "2112 Winslow Dr, Plano, TX 75023": 40669, + "Dallas/Plano Marriott at Legacy Town Center, 7121 Bishop Rd, Plano, TX 75024, United States": 15766, + "Legacy Town Center Plano, 5760 Daniel Rd, Plano, TX 75024, United States": 12792 + }; + + allProperties.forEach(p => { + const addr = p.propertyData.propertyAddress; + if (unassignedTargets.includes(addr)) { + p.canvassingStatus = "Lead"; // "Lead" = Unassigned basically in this context, or just "Neutral" but we track assignments + p.assignedAgentId = null; + p.propertyData.currentEstimatedMarketValue = unassignedValues[addr]; + } else { + // Default random assignment for others if not unassigned + if (p.canvassingStatus !== "Hot Lead") { // Keep Hot Leads potentially unassigned or specific + // p.assignedAgentId = "e1"; // Optional: assign everything else + } + } + }); + + // B. Hot Leads Not Contacted + // Data: 2629 Rothland Ln (2026-02-08), 3920 Arizona Pl (2026-02-08), 3912 Arizona Pl (Never), 3909 Sailmaker Ln (Never) + const hotLeadTargets = { + "2629 Rothland Ln, Plano, TX 75023": "2026-02-08T10:00:00Z", + "3920 Arizona Pl, Plano, TX 75023, USA": "2026-02-08T14:30:00Z", + "3912 Arizona Pl, Plano, TX 75023, USA": null, + "3909 Sailmaker Ln, Plano, TX 75023, USA": null + }; + + allProperties.forEach(p => { + if (hotLeadTargets.hasOwnProperty(p.propertyData.propertyAddress)) { + p.canvassingStatus = "Hot Lead"; + p.lastContactDate = hotLeadTargets[p.propertyData.propertyAddress]; + + // Set values if needed match User Data? + // The prompt gave values: 2629 ($20,298), 3920 ($84,288), 3912 ($73,307), 3909 ($72,563) + if (p.propertyData.propertyAddress.includes("2629 Rothland")) p.propertyData.currentEstimatedMarketValue = 20298; + if (p.propertyData.propertyAddress.includes("3920 Arizona")) p.propertyData.currentEstimatedMarketValue = 84288; + if (p.propertyData.propertyAddress.includes("3912 Arizona")) p.propertyData.currentEstimatedMarketValue = 73307; + if (p.propertyData.propertyAddress.includes("3909 Sailmaker")) p.propertyData.currentEstimatedMarketValue = 72563; + } + }); + + // C. Pending Signatures + // Data: Client 1 (2612 Dunwick), Client 2 (2608 Dunwick), Client 9 (2617 Rothland), Client 12 (6609 Phoenix) + const signatureTargets = { + "2612 Dunwick Dr, Plano, TX 75023": { date: "2026-02-05", val: 8521 }, + "2608 Dunwick Dr, Plano, TX 75023": { date: "2026-02-09", val: 16468 }, + "2617 Rothland Ln, Plano, TX 75023": { date: "2026-02-08", val: 18392 }, + "6609 Phoenix Pl, Plano, TX 75023, USA": { date: "2026-02-06", val: 31768 } + }; + + allProperties.forEach(p => { + if (signatureTargets.hasOwnProperty(p.propertyData.propertyAddress)) { + p.pendingSignature = true; + p.proposalSentDate = signatureTargets[p.propertyData.propertyAddress].date; + p.proposalValue = signatureTargets[p.propertyData.propertyAddress].val; + p.propertyData.pendingSignature = true; // Sync with inner object if needed, but we extended outer + } else { + p.pendingSignature = false; + } + }); + + // ------------------------------------------------------- // 3. Assign Tenants (Strict Counts & Data Depth) // User Request: 10-15 Total Rented. 5-8 with "Complete" info, rest "Sparse" (Mandatory only). @@ -536,8 +623,6 @@ function generateProperties() { // --- NEW DATA (Users & Meetings) --- -// --- NEW DATA (Users & Meetings) --- - const MOCK_USERS = [ // Customers { id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600' }, @@ -706,6 +791,61 @@ export const MockStoreProvider = ({ children }) => { setMeetings(prev => [...prev, { ...meeting, id: `m${prev.length + 1}` }]); }; + // --- NEW ACTIONS for ADMIN CENTER --- + + const assignAgent = (propertyId, agentId) => { + let found = false; + setProperties(prev => prev.map(p => { + if (p.id === propertyId || p.propertyData.propertyId === propertyId) { + found = true; + return { ...p, assignedAgentId: agentId, canvassingStatus: "Assigned" }; + } + return p; + })); + + if (!found) { + toast.error("Failed to assign agent: Property not found."); + return false; + } + + const agent = users.find(u => u.id === agentId); + toast.success(`Agent ${agent ? agent.name : 'assigned'} linked to property.`); + logger.info(`Property ${propertyId} assigned to Agent ${agentId}`); + return true; + }; + + const markLeadContacted = (propertyId) => { + let found = false; + setProperties(prev => prev.map(p => { + if (p.id === propertyId || p.propertyData.propertyId === propertyId) { + found = true; + return { ...p, lastContactDate: new Date().toISOString() }; + } + return p; + })); + + if (!found) { + toast.error("Action failed: Property not found."); + return false; + } + + toast.success("Lead marked as contacted."); + return true; + }; + + const sendReminder = (propertyId) => { + const prop = properties.find(p => p.id === propertyId || p.propertyData.propertyId === propertyId); + if (!prop) { + toast.error("Failed to send reminder: Client not found."); + return false; + } + + const name = prop?.ownerData?.fullName || "Client"; + toast.success(`Reminder sent to ${name}`); + logger.info(`Reminder sent to property ${propertyId}`); + return true; + }; + return ( { addMeeting, updateUser: (updatedUser) => { setUsers(prev => prev.map(u => u.id === updatedUser.id ? updatedUser : u)); - } + }, + // Export new actions + assignAgent, + markLeadContacted, + sendReminder }}> {children} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index ae34ceb..bc4a978 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -14,6 +14,7 @@ import { RoofConditionChart } from '../components/dashboard/RoofConditionChart'; import { OwnerIntentFunnel } from '../components/dashboard/OwnerIntentFunnel'; import { RevenueByNeighborhood } from '../components/dashboard/RevenueByNeighborhood'; import { GoldenLeadsScatter } from '../components/dashboard/GoldenLeadsScatter'; +import { ActionCenterWidget } from '../components/dashboard/ActionCenterWidget'; import { WeatherRiskGauge } from '../components/dashboard/WeatherRiskGauge'; import Loader from '../components/Loader'; @@ -144,6 +145,9 @@ const Dashboard = () => { + {/* --- ADMIN ACTION CENTER --- */} + {user?.role === 'ADMIN' && } + {/* Top Metrics Grid */}