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
This commit is contained in:
@@ -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**:
|
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 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 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
|
### 📊 The Data Dashboard
|
||||||
A 360-degree view of your territory's health.
|
A 360-degree view of your territory's health.
|
||||||
1. **Golden Leads Scatter Plot**: Visualizes high-value opportunities (High Value + Older Built Year).
|
1. **Admin Action Center**: A dedicated command center for managers to assign leads, approve schedules, and track urgent tasks.
|
||||||
2. **Risk Index Gauge**: A real-time meter combining live wind speed data with the percentage of "Needs Repair" roofs.
|
2. **Golden Leads Scatter Plot**: Visualizes high-value opportunities (High Value + Older Built Year).
|
||||||
3. **Revenue Potential**: Bar charts showing which neighborhoods offer the highest ROI based on renovation needs.
|
3. **Risk Index Gauge**: A real-time meter combining live wind speed data with the percentage of "Needs Repair" roofs.
|
||||||
4. **Roof Health**: A visual breakdown of the entire territory's roof conditions (Good, Fair, Needs Repair).
|
4. **Revenue Potential**: Bar charts showing which neighborhoods offer the highest ROI based on renovation needs.
|
||||||
5. **Owner Intent Funnel**: Tracks the pipeline from "Willing to Sell" -> "Hot Leads".
|
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.
|
- **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.
|
- **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).
|
- **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.
|
- **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.
|
- **Field Agents**: View assigned territories, update lead status, and request schedule changes.
|
||||||
- **Admins**: Global view of all agents, approval workflows, and system settings.
|
- **Admins**: Global view of all agents, approval workflows, and system settings.
|
||||||
- **Customers**: (Coming Soon) Portal to view project status and request service.
|
- **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
|
## 👥 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`
|
- **Alternates**: `FA002`, `FA003`, `FA004`, `FA005`
|
||||||
|
|
||||||
### 2. Administrator
|
### 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)
|
- **Username/ID**: `ADM01` (Adam Admin)
|
||||||
- **Alternates**: `ADM02`, `ADM03`
|
- **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).
|
- **State**: `MockStore` (Simulates Backend/DB), `AuthContext` (Session Management).
|
||||||
- **AI Engine**: Groq SDK (`qwen-32b`) injected with dynamic system prompts.
|
- **AI Engine**: Groq SDK (`qwen-32b`) injected with dynamic system prompts.
|
||||||
- **Visuals**: `react-leaflet` (Maps), `recharts` (Analytics), `lucide-react` (Icons).
|
- **Visuals**: `react-leaflet` (Maps), `recharts` (Analytics), `lucide-react` (Icons).
|
||||||
|
- **Reliability**: `sonner` (Toasts), Error Boundaries, Layout Portals.
|
||||||
|
|
||||||
### 🚀 Setup & Contribution
|
### 🚀 Setup & Contribution
|
||||||
1. **Clone**: `git clone <repo>`
|
1. **Clone**: `git clone <repo>`
|
||||||
|
|||||||
@@ -30,6 +30,35 @@ TONE: Professional, efficient, and data-aware.
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const getEmployeeContext = (user, meetings, properties) => {
|
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)
|
// 1. Agenda (Today + Upcoming)
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const myMeetings = meetings
|
const myMeetings = meetings
|
||||||
@@ -248,6 +277,7 @@ const Chatbot = () => {
|
|||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!input.trim()) return;
|
if (!input.trim()) return;
|
||||||
|
|
||||||
|
const originalInput = input; // Backup input
|
||||||
const newMessages = [...messages, { role: 'user', content: input }];
|
const newMessages = [...messages, { role: 'user', content: input }];
|
||||||
setMessages(newMessages);
|
setMessages(newMessages);
|
||||||
setInput('');
|
setInput('');
|
||||||
@@ -337,6 +367,8 @@ const Chatbot = () => {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Groq API Error", 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.";
|
let errorMsg = "Sorry, I'm having trouble connecting to the Groq server.";
|
||||||
|
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="mb-8 select-none">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
Action Center
|
||||||
|
{totalUrgent > 0 && (
|
||||||
|
<span className="flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 text-xs font-bold border border-red-200 dark:border-red-800 animate-pulse">
|
||||||
|
<AlertCircle size={12} />
|
||||||
|
{totalUrgent} URGENT
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 md:gap-4">
|
||||||
|
{cards.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={card.id}
|
||||||
|
onClick={() => 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
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className={`p-2 rounded-lg bg-white dark:bg-white/5 shadow-sm ${card.color}`}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-3xl font-black ${card.color}`}>
|
||||||
|
{card.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-xs font-bold text-gray-600 dark:text-gray-400 uppercase tracking-wider truncate mr-2">
|
||||||
|
{card.title}
|
||||||
|
</span>
|
||||||
|
<ArrowRight
|
||||||
|
size={14}
|
||||||
|
className={`opacity-0 group-hover:opacity-100 transform group-hover:translate-x-1 transition-all duration-300 ${card.color} hidden sm:block`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details Drawer */}
|
||||||
|
{selectedView && (
|
||||||
|
<ActionDetailDrawer
|
||||||
|
isOpen={!!selectedView}
|
||||||
|
onClose={() => setSelectedView(null)}
|
||||||
|
viewType={selectedView}
|
||||||
|
data={{ unassignedLeads, hotLeads, rescheduleMeets, pendingSignatures }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 => (
|
||||||
|
<div key={item.id} className="p-4 bg-white dark:bg-white/5 border border-zinc-200 dark:border-white/10 rounded-xl mb-3 flex justify-between items-center group hover:shadow-md transition-all">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="px-2 py-0.5 text-[10px] font-bold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 rounded uppercase tracking-wider">
|
||||||
|
Lead
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||||
|
{item.propertyData.propertyAddress.split(',')[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-zinc-500">
|
||||||
|
<span className="flex items-center gap-1"><MapPin size={10} /> Plano, TX</span>
|
||||||
|
<span className="font-medium text-emerald-600 dark:text-emerald-400">Est. ${item.propertyData.currentEstimatedMarketValue.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAssignClick(item)}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-bold rounded-lg shadow-sm hover:shadow transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<UserPlus size={14} /> Assign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewType === 'HOT') {
|
||||||
|
return data.hotLeads.map(item => (
|
||||||
|
<div key={item.id} className="p-4 bg-white dark:bg-white/5 border border-orange-100 dark:border-orange-500/20 rounded-xl mb-3 relative">
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-zinc-900 dark:text-white mb-1">
|
||||||
|
{item.propertyData.propertyAddress.split(',')[0]}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-orange-600 dark:text-orange-400 font-medium">
|
||||||
|
Last Contact: {item.lastContactDate ? new Date(item.lastContactDate).toLocaleDateString() : 'Never'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-xs font-bold text-zinc-500 block">Value</span>
|
||||||
|
<span className="text-sm font-mono text-zinc-900 dark:text-white">${item.propertyData.currentEstimatedMarketValue.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 border-t border-zinc-100 dark:border-white/5 pt-3">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setContactPopover(contactPopover === item.id ? null : item.id)}
|
||||||
|
className="px-3 py-1.5 bg-zinc-100 dark:bg-white/10 hover:bg-zinc-200 text-zinc-700 dark:text-white text-xs font-semibold rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Phone size={12} /> Contact
|
||||||
|
</button>
|
||||||
|
{/* Simple Popover */}
|
||||||
|
{contactPopover === item.id && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 w-64 bg-white dark:bg-zinc-800 shadow-xl rounded-xl border border-zinc-200 dark:border-zinc-700 p-4 z-50 animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
<h5 className="text-xs font-bold text-zinc-400 uppercase mb-2">Owner Info</h5>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-zinc-900 dark:text-white">{item.ownerData.fullName}</p>
|
||||||
|
<a href={`tel:${item.ownerData.primaryPhoneNumber}`} className="block text-sm text-blue-600 hover:underline flex items-center gap-2">
|
||||||
|
<Phone size={12} /> {item.ownerData.primaryPhoneNumber}
|
||||||
|
</a>
|
||||||
|
<a href={`mailto:${item.ownerData.emailAddress}`} className="block text-sm text-zinc-600 hover:text-zinc-900 truncate">
|
||||||
|
{item.ownerData.emailAddress}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleMarkContacted(e, item)}
|
||||||
|
className="flex-1 px-3 py-1.5 bg-orange-50 dark:bg-orange-900/20 hover:bg-orange-100 text-orange-700 dark:text-orange-300 text-xs font-semibold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<CheckCircle size={12} /> Mark Contacted
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewType === 'RESCHEDULE') {
|
||||||
|
return data.rescheduleMeets.map(item => (
|
||||||
|
<div key={item.id} className="p-4 bg-white dark:bg-white/5 border border-rose-100 dark:border-rose-500/20 rounded-xl mb-3">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-zinc-900 dark:text-white">{item.customerName}</h4>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">Original: {new Date(item.date).toLocaleDateString()} at {item.time}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-0.5 text-[10px] font-bold rounded uppercase ${item.status === 'Cancelled' ? 'bg-red-100 text-red-600' : 'bg-yellow-100 text-yellow-600'
|
||||||
|
}`}>
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<button className="px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-zinc-600 dark:text-zinc-300 text-xs font-semibold rounded-lg flex items-center gap-2">
|
||||||
|
<Calendar size={12} /> Open Calendar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewType === 'SIGNATURE') {
|
||||||
|
return data.pendingSignatures.map(item => (
|
||||||
|
<div key={item.id} className="p-4 bg-white dark:bg-white/5 border border-purple-100 dark:border-purple-500/20 rounded-xl mb-3">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<h4 className="text-sm font-bold text-zinc-900 dark:text-white">{item.ownerData.fullName}</h4>
|
||||||
|
<span className="text-xs font-mono text-purple-600 font-bold">${item.proposalValue?.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-zinc-500 mb-4">
|
||||||
|
<span className="flex items-center gap-1"><Clock size={10} /> Sent: {item.proposalSentDate ? new Date(item.proposalSentDate).toLocaleDateString() : 'N/A'}</span>
|
||||||
|
<span className="flex items-center gap-1"><MapPin size={10} /> {item.propertyData.propertyAddress.split(',')[0]}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSendReminder(item)}
|
||||||
|
className="w-full px-3 py-2 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white text-xs font-bold rounded-lg shadow-sm flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<ArrowRight size={12} /> Send Reminder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-[40] transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<div className={`
|
||||||
|
fixed inset-y-0 right-0 w-full sm:w-[450px]
|
||||||
|
bg-white dark:bg-zinc-900 shadow-2xl z-[50]
|
||||||
|
border-l border-zinc-200 dark:border-zinc-800
|
||||||
|
transform transition-transform duration-300 ease-in-out
|
||||||
|
flex flex-col
|
||||||
|
`}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`p-4 md:p-6 border-b border-zinc-100 dark:border-zinc-800 flex justify-between items-center ${header.bg} dark:bg-opacity-5 flex-shrink-0`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 bg-white rounded-lg shadow-sm ${header.color}`}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base md:text-lg font-bold text-zinc-900 dark:text-white truncate max-w-[200px] md:max-w-none">
|
||||||
|
{header.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 p-2 md:p-1 hover:bg-black/5 rounded-full transition-colors active:scale-95"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 bg-zinc-50/50 dark:bg-zinc-950/50 overscroll-contain">
|
||||||
|
{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)) && (
|
||||||
|
<div className="text-center py-12 text-zinc-400">
|
||||||
|
<CheckCircle size={48} className="mx-auto mb-4 opacity-50" />
|
||||||
|
<p className="text-sm">All caught up! No actions required.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nested Modal */}
|
||||||
|
<AgentSelectionModal
|
||||||
|
isOpen={isAgentModalOpen}
|
||||||
|
onClose={() => setIsAgentModalOpen(false)}
|
||||||
|
onSelect={handleAgentSelected}
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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(
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center p-4 sm:p-6">
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity" onClick={onClose}></div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-md bg-white dark:bg-zinc-900 rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 scale-100 animate-in slide-in-from-bottom-10 sm:slide-in-from-bottom-0 sm:zoom-in-95 duration-200 flex flex-col max-h-[85vh]">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-zinc-100 dark:border-zinc-800 flex justify-between items-center bg-zinc-50 dark:bg-white/5 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Assign Agent</h3>
|
||||||
|
<button onClick={onClose} className="p-2 text-zinc-400 hover:text-zinc-600 dark:hover:text-white bg-zinc-200 dark:bg-white/10 rounded-full active:scale-95">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="p-4 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||||
|
<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 agents..."
|
||||||
|
className="w-full pl-9 pr-4 py-2 bg-zinc-100 dark:bg-black/20 border border-transparent focus:border-blue-500 rounded-xl text-sm outline-none transition-all dark:text-white"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
|
{agents.map(agent => (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400">
|
||||||
|
<User size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-zinc-900 dark:text-white text-sm">{agent.name}</h4>
|
||||||
|
<p className="text-xs text-zinc-500">{agent.empId} • {agent.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span className="px-3 py-1 bg-blue-600 text-white text-xs font-bold rounded-lg flex items-center gap-1 shadow-sm">
|
||||||
|
Select <Check size={12} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{agents.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-zinc-400">
|
||||||
|
<p className="text-sm">No agents found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
+147
-3
@@ -1,4 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// --- DATA CONSTANTS (Extracted from old App.jsx) ---
|
// --- DATA CONSTANTS (Extracted from old App.jsx) ---
|
||||||
|
|
||||||
@@ -294,6 +296,15 @@ function generateProperties() {
|
|||||||
// Placeholder status, will be overwritten
|
// Placeholder status, will be overwritten
|
||||||
canvassingStatus: "Neutral",
|
canvassingStatus: "Neutral",
|
||||||
photos: isCommercial ? ["https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800", "https://images.unsplash.com/photo-1542665952-14513db15293?w=800"] : style.photos,
|
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: {
|
propertyData: {
|
||||||
propertyId: `P-${2600 + index}`,
|
propertyId: `P-${2600 + index}`,
|
||||||
propertyAddress: loc.address,
|
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)
|
// 3. Assign Tenants (Strict Counts & Data Depth)
|
||||||
// User Request: 10-15 Total Rented. 5-8 with "Complete" info, rest "Sparse" (Mandatory only).
|
// 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) ---
|
||||||
|
|
||||||
// --- NEW DATA (Users & Meetings) ---
|
|
||||||
|
|
||||||
const MOCK_USERS = [
|
const MOCK_USERS = [
|
||||||
// Customers
|
// Customers
|
||||||
{ id: 'c1', type: 'customer', username: 'alice', email: 'alice@example.com', password: 'password', name: 'Alice Customer', propertyId: 'P-2600' },
|
{ 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}` }]);
|
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 (
|
return (
|
||||||
<MockStoreContext.Provider value={{
|
<MockStoreContext.Provider value={{
|
||||||
properties,
|
properties,
|
||||||
@@ -716,7 +856,11 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
addMeeting,
|
addMeeting,
|
||||||
updateUser: (updatedUser) => {
|
updateUser: (updatedUser) => {
|
||||||
setUsers(prev => prev.map(u => u.id === updatedUser.id ? updatedUser : u));
|
setUsers(prev => prev.map(u => u.id === updatedUser.id ? updatedUser : u));
|
||||||
}
|
},
|
||||||
|
// Export new actions
|
||||||
|
assignAgent,
|
||||||
|
markLeadContacted,
|
||||||
|
sendReminder
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</MockStoreContext.Provider>
|
</MockStoreContext.Provider>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { RoofConditionChart } from '../components/dashboard/RoofConditionChart';
|
|||||||
import { OwnerIntentFunnel } from '../components/dashboard/OwnerIntentFunnel';
|
import { OwnerIntentFunnel } from '../components/dashboard/OwnerIntentFunnel';
|
||||||
import { RevenueByNeighborhood } from '../components/dashboard/RevenueByNeighborhood';
|
import { RevenueByNeighborhood } from '../components/dashboard/RevenueByNeighborhood';
|
||||||
import { GoldenLeadsScatter } from '../components/dashboard/GoldenLeadsScatter';
|
import { GoldenLeadsScatter } from '../components/dashboard/GoldenLeadsScatter';
|
||||||
|
import { ActionCenterWidget } from '../components/dashboard/ActionCenterWidget';
|
||||||
import { WeatherRiskGauge } from '../components/dashboard/WeatherRiskGauge';
|
import { WeatherRiskGauge } from '../components/dashboard/WeatherRiskGauge';
|
||||||
|
|
||||||
import Loader from '../components/Loader';
|
import Loader from '../components/Loader';
|
||||||
@@ -144,6 +145,9 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* --- ADMIN ACTION CENTER --- */}
|
||||||
|
{user?.role === 'ADMIN' && <ActionCenterWidget />}
|
||||||
|
|
||||||
{/* Top Metrics Grid */}
|
{/* Top Metrics Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
|
|||||||
Reference in New Issue
Block a user