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:
Satyam
2026-02-11 18:33:04 +05:30
parent bfc90b668b
commit be828b205d
7 changed files with 676 additions and 12 deletions
+32
View File
@@ -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) {
@@ -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
);
};