feat(dispatch): Phase 1 — LynkDispatch AI skeleton, mock data, routes, sidebar
- mockStore: add DISPATCH_LEADS (12), DISPATCH_REPS (5), DISPATCH_RECOMMENDATIONS (pre-baked AI scores + route paths for all 12 leads), dispatchLeads state, assignDispatchLead() action - LynkDispatchPage: 3-panel command center shell with storm banner, live badge, Storm Mode toggle, KPI bar with real unassigned count - App.jsx: routes /admin/dispatch and /owner/dispatch wired - Layout.jsx: LynkDispatch (Zap icon) added to ADMIN and OWNER sidebar nav
This commit is contained in:
+17
@@ -31,6 +31,7 @@ import ContractorDashboard from './pages/contractor/ContractorDashboard';
|
|||||||
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
|
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
|
||||||
import CreateLeadPage from './pages/CreateLeadPage';
|
import CreateLeadPage from './pages/CreateLeadPage';
|
||||||
import LeadsListPage from './pages/LeadsListPage';
|
import LeadsListPage from './pages/LeadsListPage';
|
||||||
|
import LynkDispatchPage from './pages/LynkDispatchPage';
|
||||||
|
|
||||||
// ... (existing imports)
|
// ... (existing imports)
|
||||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||||
@@ -197,6 +198,22 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/dispatch"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
|
||||||
|
<LynkDispatchPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/owner/dispatch"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
|
<LynkDispatchPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/maps"
|
path="/admin/maps"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTheme } from '../context/ThemeContext';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
||||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList
|
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import PageTransition from './PageTransition';
|
import PageTransition from './PageTransition';
|
||||||
@@ -138,6 +138,7 @@ const Layout = () => {
|
|||||||
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
{ to: "/owner/projects", icon: Briefcase, label: "Projects" },
|
{ to: "/owner/projects", icon: Briefcase, label: "Projects" },
|
||||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||||
|
{ to: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
|
||||||
{ to: "/owner/maps", icon: Map, label: "Territory Map" },
|
{ to: "/owner/maps", icon: Map, label: "Territory Map" },
|
||||||
{
|
{
|
||||||
to: "/owner/pro-canvas",
|
to: "/owner/pro-canvas",
|
||||||
@@ -155,6 +156,7 @@ const Layout = () => {
|
|||||||
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
|
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
|
||||||
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
||||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||||
|
{ to: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
|
||||||
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
|
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
|
||||||
{
|
{
|
||||||
to: "/admin/pro-canvas",
|
to: "/admin/pro-canvas",
|
||||||
|
|||||||
+342
-1
@@ -2720,6 +2720,339 @@ const MOCK_PROJECTS = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LynkDispatch AI — Mock Data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const DISPATCH_REPS = [
|
||||||
|
{
|
||||||
|
id: 'REP-01', name: 'Carlos Mendez', initials: 'CM',
|
||||||
|
status: 'en_route', // available | en_route | busy
|
||||||
|
currentLat: 33.0612, currentLng: -96.7891,
|
||||||
|
todayAppointments: 3, maxDaily: 6,
|
||||||
|
skills: ['insurance_claim', 'retail_estimate'],
|
||||||
|
performanceScore: 94,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'REP-02', name: 'Aisha Kumar', initials: 'AK',
|
||||||
|
status: 'available',
|
||||||
|
currentLat: 33.0812, currentLng: -96.7234,
|
||||||
|
todayAppointments: 2, maxDaily: 6,
|
||||||
|
skills: ['roof_inspection', 'emergency_tarp'],
|
||||||
|
performanceScore: 88,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'REP-03', name: 'Tom Bradley', initials: 'TB',
|
||||||
|
status: 'busy',
|
||||||
|
currentLat: 33.0389, currentLng: -96.7401,
|
||||||
|
todayAppointments: 5, maxDaily: 6,
|
||||||
|
skills: ['retail_estimate', 'roof_inspection'],
|
||||||
|
performanceScore: 79,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'REP-04', name: 'Nina Patel', initials: 'NP',
|
||||||
|
status: 'available',
|
||||||
|
currentLat: 33.0516, currentLng: -96.7512,
|
||||||
|
todayAppointments: 1, maxDaily: 6,
|
||||||
|
skills: ['insurance_claim', 'roof_inspection', 'emergency_tarp'],
|
||||||
|
performanceScore: 91,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'REP-05', name: 'Derek Walsh', initials: 'DW',
|
||||||
|
status: 'en_route',
|
||||||
|
currentLat: 33.0198, currentLng: -96.7234,
|
||||||
|
todayAppointments: 4, maxDaily: 6,
|
||||||
|
skills: ['retail_estimate', 'roof_inspection'],
|
||||||
|
performanceScore: 82,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const DISPATCH_LEADS_INITIAL = [
|
||||||
|
{
|
||||||
|
id: 'LD-1001', source: 'call_center', leadType: 'emergency_tarp', urgency: 'emergency',
|
||||||
|
customer: { name: 'John Smith', phone: '(972) 555-1001' },
|
||||||
|
property: { address: '2612 Dunwick Dr', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0708, lng: -96.7456 },
|
||||||
|
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 8, assignedRepId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1002', source: 'website_form', leadType: 'roof_inspection', urgency: 'high',
|
||||||
|
customer: { name: 'Maria Gonzalez', phone: '(972) 555-1002' },
|
||||||
|
property: { address: '4817 Shady Brook Ln', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0652, lng: -96.8021 },
|
||||||
|
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 14, assignedRepId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1003', source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
|
||||||
|
customer: { name: 'Robert Chen', phone: '(972) 555-1003' },
|
||||||
|
property: { address: '1234 Oak Creek Blvd', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0421, lng: -96.7138 },
|
||||||
|
estimatedDuration: 45, status: 'assigned', arrivedMinsAgo: 22, assignedRepId: 'REP-01',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1004', source: 'referral', leadType: 'retail_estimate', urgency: 'standard',
|
||||||
|
customer: { name: 'Sarah Mitchell', phone: '(972) 555-1004' },
|
||||||
|
property: { address: '890 Custer Rd', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0389, lng: -96.7334 },
|
||||||
|
estimatedDuration: 30, status: 'confirmed', arrivedMinsAgo: 35, assignedRepId: 'REP-03',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1005', source: 'call_center', leadType: 'roof_inspection', urgency: 'standard',
|
||||||
|
customer: { name: 'James Wilson', phone: '(972) 555-1005' },
|
||||||
|
property: { address: '321 Independence Pkwy', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0812, lng: -96.7521 },
|
||||||
|
estimatedDuration: 45, status: 'at_risk', arrivedMinsAgo: 47, assignedRepId: 'REP-02',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1006', source: 'website_form', leadType: 'insurance_claim', urgency: 'high',
|
||||||
|
customer: { name: 'Lisa Thompson', phone: '(972) 555-1006' },
|
||||||
|
property: { address: '7654 Preston Rd', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0934, lng: -96.8002 },
|
||||||
|
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 5, assignedRepId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1007', source: 'canvassing', leadType: 'retail_estimate', urgency: 'standard',
|
||||||
|
customer: { name: 'David Park', phone: '(972) 555-1007' },
|
||||||
|
property: { address: '2200 Alma Dr', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0516, lng: -96.7289 },
|
||||||
|
estimatedDuration: 30, status: 'confirmed', arrivedMinsAgo: 62, assignedRepId: 'REP-04',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1008', source: 'referral', leadType: 'roof_inspection', urgency: 'standard',
|
||||||
|
customer: { name: 'Angela Moore', phone: '(972) 555-1008' },
|
||||||
|
property: { address: '555 Legacy Dr', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0744, lng: -96.7612 },
|
||||||
|
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 28, assignedRepId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1009', source: 'call_center', leadType: 'emergency_tarp', urgency: 'emergency',
|
||||||
|
customer: { name: 'Carlos Rivera', phone: '(972) 555-1009' },
|
||||||
|
property: { address: '3301 Avenue K', city: 'Plano', state: 'TX', zip: '75074', lat: 33.0198, lng: -96.6989 },
|
||||||
|
estimatedDuration: 90, status: 'unassigned', arrivedMinsAgo: 3, assignedRepId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1010', source: 'website_form', leadType: 'retail_estimate', urgency: 'standard',
|
||||||
|
customer: { name: 'Patricia Davis', phone: '(972) 555-1010' },
|
||||||
|
property: { address: '6711 W Spring Creek Pkwy', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0888, lng: -96.7756 },
|
||||||
|
estimatedDuration: 30, status: 'at_risk', arrivedMinsAgo: 55, assignedRepId: 'REP-05',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1011', source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
|
||||||
|
customer: { name: 'Michael Anderson', phone: '(972) 555-1011' },
|
||||||
|
property: { address: '1800 Jupiter Rd', city: 'Plano', state: 'TX', zip: '75074', lat: 33.0267, lng: -96.7102 },
|
||||||
|
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 11, assignedRepId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'LD-1012', source: 'referral', leadType: 'roof_inspection', urgency: 'standard',
|
||||||
|
customer: { name: 'Jennifer White', phone: '(972) 555-1012' },
|
||||||
|
property: { address: '4400 W Parker Rd', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0612, lng: -96.8145 },
|
||||||
|
estimatedDuration: 45, status: 'confirmed', arrivedMinsAgo: 72, assignedRepId: 'REP-01',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Pre-baked AI recommendations keyed by lead ID — no algorithm needed, just convincing data.
|
||||||
|
// Each rep entry: repId, score, slotStart/End, factors (0-100 each), reasons[], stormReasons[], routePath[[lat,lng]]
|
||||||
|
export const DISPATCH_RECOMMENDATIONS = {
|
||||||
|
'LD-1001': [
|
||||||
|
{
|
||||||
|
repId: 'REP-04', score: 91, slotStart: '1:30 PM', slotEnd: '2:30 PM',
|
||||||
|
factors: { proximity: 88, driveTime: 90, calendarFit: 95, weather: 90, skillMatch: 100 },
|
||||||
|
reasons: ['Closest eligible rep — 2.1 mi', '12 min drive time estimated', 'Insurance claim certified', 'Buffer preserved before next appointment'],
|
||||||
|
stormReasons: ['Lightning risk before 2 PM — slot pushed to 1:30 PM', 'Rep carries emergency tarp kit'],
|
||||||
|
routePath: [[33.0516, -96.7512], [33.0612, -96.7484], [33.0708, -96.7456]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-02', score: 79, slotStart: '3:00 PM', slotEnd: '4:00 PM',
|
||||||
|
factors: { proximity: 72, driveTime: 75, calendarFit: 82, weather: 80, skillMatch: 85 },
|
||||||
|
reasons: ['Available at 3 PM', '18 min drive — moderate traffic', 'Roof inspection certified'],
|
||||||
|
stormReasons: ['Afternoon slot carries weather risk — monitor closely'],
|
||||||
|
routePath: [[33.0812, -96.7234], [33.0760, -96.7345], [33.0708, -96.7456]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-01', score: 71, slotStart: '4:30 PM', slotEnd: '5:30 PM',
|
||||||
|
factors: { proximity: 62, driveTime: 60, calendarFit: 78, weather: 70, skillMatch: 100 },
|
||||||
|
reasons: ['Available later in the day', '24 min drive from current location', 'High performer — score 94'],
|
||||||
|
stormReasons: ['Late afternoon — weather conditions improving'],
|
||||||
|
routePath: [[33.0612, -96.7891], [33.0660, -96.7674], [33.0708, -96.7456]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1002': [
|
||||||
|
{
|
||||||
|
repId: 'REP-01', score: 88, slotStart: '2:00 PM', slotEnd: '2:45 PM',
|
||||||
|
factors: { proximity: 92, driveTime: 88, calendarFit: 85, weather: 88, skillMatch: 100 },
|
||||||
|
reasons: ['Closest rep — 1.4 mi from lead location', '8 min drive time', 'Buffer maintained after current job'],
|
||||||
|
stormReasons: ['Weather clear until 3 PM — slot timing is safe', 'Rep familiar with West Plano territory'],
|
||||||
|
routePath: [[33.0612, -96.7891], [33.0632, -96.7956], [33.0652, -96.8021]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-04', score: 74, slotStart: '3:30 PM', slotEnd: '4:15 PM',
|
||||||
|
factors: { proximity: 70, driveTime: 68, calendarFit: 85, weather: 72, skillMatch: 100 },
|
||||||
|
reasons: ['Available at 3:30 PM', '22 min drive from current position', 'Insurance claim certified'],
|
||||||
|
stormReasons: ['Afternoon slot — weather risk moderate after 3 PM'],
|
||||||
|
routePath: [[33.0516, -96.7512], [33.0584, -96.7767], [33.0652, -96.8021]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1003': [
|
||||||
|
{
|
||||||
|
repId: 'REP-01', score: 87, slotStart: '11:00 AM', slotEnd: '11:45 AM',
|
||||||
|
factors: { proximity: 85, driveTime: 88, calendarFit: 90, weather: 85, skillMatch: 100 },
|
||||||
|
reasons: ['Currently assigned — optimal for continuity', '11 min drive from current stop', 'Insurance claim specialist'],
|
||||||
|
stormReasons: ['Morning slot — no weather concerns'],
|
||||||
|
routePath: [[33.0612, -96.7891], [33.0517, -96.7515], [33.0421, -96.7138]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-05', score: 67, slotStart: '1:00 PM', slotEnd: '1:45 PM',
|
||||||
|
factors: { proximity: 65, driveTime: 62, calendarFit: 72, weather: 75, skillMatch: 60 },
|
||||||
|
reasons: ['Available afternoon slot', '19 min drive', 'Less specialized — retail estimate primary skill'],
|
||||||
|
stormReasons: ['Afternoon — monitor weather after 1 PM'],
|
||||||
|
routePath: [[33.0198, -96.7234], [33.0310, -96.7186], [33.0421, -96.7138]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1004': [
|
||||||
|
{
|
||||||
|
repId: 'REP-03', score: 95, slotStart: '10:30 AM', slotEnd: '11:00 AM',
|
||||||
|
factors: { proximity: 98, driveTime: 97, calendarFit: 88, weather: 95, skillMatch: 100 },
|
||||||
|
reasons: ['0.6 mi away — optimal proximity', '4 min drive time', 'Retail estimate specialist'],
|
||||||
|
stormReasons: ['Morning slot — conditions favorable'],
|
||||||
|
routePath: [[33.0389, -96.7401], [33.0389, -96.7368], [33.0389, -96.7334]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-05', score: 73, slotStart: '2:00 PM', slotEnd: '2:30 PM',
|
||||||
|
factors: { proximity: 70, driveTime: 68, calendarFit: 80, weather: 72, skillMatch: 80 },
|
||||||
|
reasons: ['Available afternoon', '16 min drive', 'Retail estimate capable'],
|
||||||
|
stormReasons: ['Afternoon slot — watch post-2 PM weather'],
|
||||||
|
routePath: [[33.0198, -96.7234], [33.0294, -96.7284], [33.0389, -96.7334]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1005': [
|
||||||
|
{
|
||||||
|
repId: 'REP-02', score: 94, slotStart: '12:00 PM', slotEnd: '12:45 PM',
|
||||||
|
factors: { proximity: 97, driveTime: 96, calendarFit: 90, weather: 92, skillMatch: 95 },
|
||||||
|
reasons: ['0.3 mi from lead — walking distance', '3 min drive time', 'Roof inspection specialist', 'Low daily load — 2 of 6 slots used'],
|
||||||
|
stormReasons: ['Midday window — weather acceptable', 'Rep carries safety equipment'],
|
||||||
|
routePath: [[33.0812, -96.7234], [33.0812, -96.7378], [33.0812, -96.7521]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-04', score: 77, slotStart: '2:30 PM', slotEnd: '3:15 PM',
|
||||||
|
factors: { proximity: 72, driveTime: 70, calendarFit: 88, weather: 78, skillMatch: 90 },
|
||||||
|
reasons: ['Available at 2:30 PM', '17 min drive', 'Roof inspection certified'],
|
||||||
|
stormReasons: ['Afternoon — post-3 PM weather worsening, start punctually'],
|
||||||
|
routePath: [[33.0516, -96.7512], [33.0664, -96.7517], [33.0812, -96.7521]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1006': [
|
||||||
|
{
|
||||||
|
repId: 'REP-01', score: 83, slotStart: '3:00 PM', slotEnd: '4:00 PM',
|
||||||
|
factors: { proximity: 80, driveTime: 78, calendarFit: 85, weather: 82, skillMatch: 100 },
|
||||||
|
reasons: ['Best available slot — 3 PM', '14 min drive from current route', 'Insurance claim specialist'],
|
||||||
|
stormReasons: ['Wind advisory active — exterior inspection may need delay', 'Insurance claim can proceed indoors'],
|
||||||
|
routePath: [[33.0612, -96.7891], [33.0773, -96.7947], [33.0934, -96.8002]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-02', score: 69, slotStart: '4:30 PM', slotEnd: '5:30 PM',
|
||||||
|
factors: { proximity: 65, driveTime: 62, calendarFit: 75, weather: 68, skillMatch: 85 },
|
||||||
|
reasons: ['Late afternoon availability', '26 min drive', 'Roof inspection skilled'],
|
||||||
|
stormReasons: ['Late afternoon — conditions deteriorating, not recommended'],
|
||||||
|
routePath: [[33.0812, -96.7234], [33.0873, -96.7618], [33.0934, -96.8002]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1007': [
|
||||||
|
{
|
||||||
|
repId: 'REP-04', score: 87, slotStart: '11:30 AM', slotEnd: '12:00 PM',
|
||||||
|
factors: { proximity: 90, driveTime: 88, calendarFit: 92, weather: 90, skillMatch: 80 },
|
||||||
|
reasons: ['1.5 mi away — close proximity', '9 min drive time', 'Low daily load', 'Buffer before afternoon appointments'],
|
||||||
|
stormReasons: ['Morning window — safe conditions'],
|
||||||
|
routePath: [[33.0516, -96.7512], [33.0516, -96.7401], [33.0516, -96.7289]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-03', score: 78, slotStart: '1:30 PM', slotEnd: '2:00 PM',
|
||||||
|
factors: { proximity: 78, driveTime: 75, calendarFit: 70, weather: 82, skillMatch: 100 },
|
||||||
|
reasons: ['Retail estimate specialist', '12 min drive', 'Near current work site'],
|
||||||
|
stormReasons: ['Early afternoon — conditions acceptable'],
|
||||||
|
routePath: [[33.0389, -96.7401], [33.0453, -96.7345], [33.0516, -96.7289]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1008': [
|
||||||
|
{
|
||||||
|
repId: 'REP-04', score: 86, slotStart: '2:00 PM', slotEnd: '2:45 PM',
|
||||||
|
factors: { proximity: 84, driveTime: 85, calendarFit: 90, weather: 85, skillMatch: 95 },
|
||||||
|
reasons: ['2.7 mi from lead — manageable drive', '15 min estimated travel', 'Roof inspection certified', 'Open afternoon slot'],
|
||||||
|
stormReasons: ['Weather acceptable until 3:30 PM', 'Book for 2 PM to maximize safe window'],
|
||||||
|
routePath: [[33.0516, -96.7512], [33.0630, -96.7562], [33.0744, -96.7612]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-02', score: 75, slotStart: '3:30 PM', slotEnd: '4:15 PM',
|
||||||
|
factors: { proximity: 72, driveTime: 70, calendarFit: 82, weather: 74, skillMatch: 90 },
|
||||||
|
reasons: ['Available at 3:30 PM', '19 min drive', 'Roof inspection specialist'],
|
||||||
|
stormReasons: ['Late afternoon — weather risk elevated after 3 PM'],
|
||||||
|
routePath: [[33.0812, -96.7234], [33.0778, -96.7423], [33.0744, -96.7612]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-01', score: 68, slotStart: '5:00 PM', slotEnd: '5:45 PM',
|
||||||
|
factors: { proximity: 62, driveTime: 58, calendarFit: 70, weather: 68, skillMatch: 80 },
|
||||||
|
reasons: ['End of day slot only', '22 min drive', 'High performer — score 94'],
|
||||||
|
stormReasons: ['Evening — conditions improving but daylight limited'],
|
||||||
|
routePath: [[33.0612, -96.7891], [33.0678, -96.7752], [33.0744, -96.7612]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1009': [
|
||||||
|
{
|
||||||
|
repId: 'REP-05', score: 97, slotStart: '11:00 AM', slotEnd: '12:30 PM',
|
||||||
|
factors: { proximity: 99, driveTime: 98, calendarFit: 95, weather: 95, skillMatch: 90 },
|
||||||
|
reasons: ['0.8 mi away — nearest rep on site', '5 min drive time', 'Emergency certified', 'PRIORITY: emergency tarp needed'],
|
||||||
|
stormReasons: ['EMERGENCY — dispatch immediately', 'Rep stocked with emergency tarp materials'],
|
||||||
|
routePath: [[33.0198, -96.7234], [33.0198, -96.7112], [33.0198, -96.6989]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-03', score: 71, slotStart: '1:00 PM', slotEnd: '2:30 PM',
|
||||||
|
factors: { proximity: 65, driveTime: 62, calendarFit: 75, weather: 72, skillMatch: 75 },
|
||||||
|
reasons: ['Backup option — 5 appointments today', '21 min drive', 'Roof inspection capable'],
|
||||||
|
stormReasons: ['Secondary option — use only if REP-05 unavailable'],
|
||||||
|
routePath: [[33.0389, -96.7401], [33.0294, -96.7195], [33.0198, -96.6989]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1010': [
|
||||||
|
{
|
||||||
|
repId: 'REP-01', score: 84, slotStart: '2:30 PM', slotEnd: '3:00 PM',
|
||||||
|
factors: { proximity: 82, driveTime: 80, calendarFit: 88, weather: 84, skillMatch: 80 },
|
||||||
|
reasons: ['Best proximity match — 2.9 mi', '16 min drive', 'High performer — score 94', 'Manageable daily load'],
|
||||||
|
stormReasons: ['Afternoon slot — monitor weather', 'Retail estimate can proceed partially indoors'],
|
||||||
|
routePath: [[33.0612, -96.7891], [33.0750, -96.7824], [33.0888, -96.7756]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-02', score: 72, slotStart: '4:00 PM', slotEnd: '4:30 PM',
|
||||||
|
factors: { proximity: 70, driveTime: 68, calendarFit: 78, weather: 70, skillMatch: 80 },
|
||||||
|
reasons: ['Available late afternoon', '22 min drive', 'Roof inspection certified'],
|
||||||
|
stormReasons: ['4 PM slot — weather conditions deteriorating, not ideal'],
|
||||||
|
routePath: [[33.0812, -96.7234], [33.0850, -96.7495], [33.0888, -96.7756]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1011': [
|
||||||
|
{
|
||||||
|
repId: 'REP-05', score: 89, slotStart: '12:30 PM', slotEnd: '1:30 PM',
|
||||||
|
factors: { proximity: 90, driveTime: 88, calendarFit: 85, weather: 88, skillMatch: 90 },
|
||||||
|
reasons: ['Closest rep — 1.6 mi', '9 min drive time', 'Insurance claim proficient', 'Midday slot timing is optimal'],
|
||||||
|
stormReasons: ['Midday window — acceptable conditions', 'Insurance claim can be initiated outdoors then moved inside'],
|
||||||
|
routePath: [[33.0198, -96.7234], [33.0233, -96.7168], [33.0267, -96.7102]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-03', score: 76, slotStart: '2:00 PM', slotEnd: '3:00 PM',
|
||||||
|
factors: { proximity: 74, driveTime: 70, calendarFit: 78, weather: 76, skillMatch: 80 },
|
||||||
|
reasons: ['Available at 2 PM', '18 min drive', 'Retail estimate skilled'],
|
||||||
|
stormReasons: ['Afternoon slot — conditions marginal after 2:30 PM'],
|
||||||
|
routePath: [[33.0389, -96.7401], [33.0328, -96.7252], [33.0267, -96.7102]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'LD-1012': [
|
||||||
|
{
|
||||||
|
repId: 'REP-01', score: 92, slotStart: '10:00 AM', slotEnd: '10:45 AM',
|
||||||
|
factors: { proximity: 94, driveTime: 92, calendarFit: 88, weather: 92, skillMatch: 95 },
|
||||||
|
reasons: ['Currently assigned — optimal', '1.7 mi from current position', 'Morning slot — ideal weather window', 'Insurance + retail estimate certified'],
|
||||||
|
stormReasons: ['Morning slot — safe window before any afternoon weather'],
|
||||||
|
routePath: [[33.0612, -96.7891], [33.0612, -96.8018], [33.0612, -96.8145]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repId: 'REP-04', score: 70, slotStart: '1:00 PM', slotEnd: '1:45 PM',
|
||||||
|
factors: { proximity: 65, driveTime: 62, calendarFit: 82, weather: 70, skillMatch: 85 },
|
||||||
|
reasons: ['Afternoon availability', '24 min drive from current location', 'Roof inspection certified'],
|
||||||
|
stormReasons: ['Afternoon — conditions acceptable but watch post-2 PM'],
|
||||||
|
routePath: [[33.0516, -96.7512], [33.0564, -96.7829], [33.0612, -96.8145]],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// --- CONTEXT SETUP ---
|
// --- CONTEXT SETUP ---
|
||||||
|
|
||||||
const MockStoreContext = createContext();
|
const MockStoreContext = createContext();
|
||||||
@@ -2739,6 +3072,7 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
const [orders, setOrders] = useState(MOCK_ORDERS);
|
const [orders, setOrders] = useState(MOCK_ORDERS);
|
||||||
const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES);
|
const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES);
|
||||||
const [leads, setLeads] = useState([]);
|
const [leads, setLeads] = useState([]);
|
||||||
|
const [dispatchLeads, setDispatchLeads] = useState(DISPATCH_LEADS_INITIAL);
|
||||||
|
|
||||||
// Initialize properties once
|
// Initialize properties once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2839,12 +3173,19 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
return newLead;
|
return newLead;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// LynkDispatch
|
||||||
|
dispatchLeads,
|
||||||
|
assignDispatchLead: (leadId, repId) => {
|
||||||
|
setDispatchLeads(prev => prev.map(l =>
|
||||||
|
l.id === leadId ? { ...l, assignedRepId: repId, status: 'confirmed' } : l
|
||||||
|
));
|
||||||
|
},
|
||||||
|
|
||||||
updatePropertyStatus,
|
updatePropertyStatus,
|
||||||
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,
|
assignAgent,
|
||||||
markLeadContacted,
|
markLeadContacted,
|
||||||
sendReminder
|
sendReminder
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panel — themed container used by all 3 side panels
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const Panel = ({ children, className = '' }) => (
|
||||||
|
<div className={`flex flex-col rounded-2xl border overflow-hidden bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06] ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PanelHeader = ({ title, subtitle, accent, right }) => (
|
||||||
|
<div
|
||||||
|
className="shrink-0 flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06]"
|
||||||
|
style={{ borderTopColor: accent, borderTopWidth: 2 }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold uppercase tracking-widest text-zinc-800 dark:text-white">{title}</h2>
|
||||||
|
{subtitle && <p className="text-[11px] text-zinc-400 mt-0.5">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{right && <div className="shrink-0">{right}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// KPI Card — bottom bar metric card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const KpiCard = ({ label, value, icon: Icon, accent, stormMode, pulse }) => (
|
||||||
|
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 border transition-all duration-500
|
||||||
|
${stormMode && pulse
|
||||||
|
? 'bg-amber-50 border-amber-300 dark:bg-amber-500/10 dark:border-amber-500/40 shadow-amber-200/50 dark:shadow-amber-500/10 shadow-md'
|
||||||
|
: 'bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400 truncate">{label}</p>
|
||||||
|
<p className="text-xl font-black font-mono leading-tight text-zinc-900 dark:text-white">{value}</p>
|
||||||
|
</div>
|
||||||
|
{stormMode && pulse && (
|
||||||
|
<span className="ml-auto w-2 h-2 rounded-full bg-amber-500 animate-pulse shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const LynkDispatchPage = () => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const { dispatchLeads } = useMockStore();
|
||||||
|
|
||||||
|
const [selectedLead, setSelectedLead] = useState(null);
|
||||||
|
const [stormMode, setStormMode] = useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
const accent = stormMode ? '#F59E0B' : '#3B82F6';
|
||||||
|
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col h-full overflow-hidden bg-zinc-50 dark:bg-[#09090b] ${stormMode ? 'storm-mode' : ''}`}>
|
||||||
|
|
||||||
|
{/* Storm Banner */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{stormMode && (
|
||||||
|
<motion.div
|
||||||
|
key="storm-banner"
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="overflow-hidden shrink-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2 bg-amber-500 px-4 py-2 text-xs font-bold uppercase tracking-widest text-white">
|
||||||
|
<CloudLightning size={14} className="animate-pulse" />
|
||||||
|
Storm Mode Active — Exterior Inspections Restricted — Route Planning Adjusted
|
||||||
|
<CloudLightning size={14} className="animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||||
|
<header className="shrink-0 flex items-center justify-between gap-4 px-4 pt-4 pb-3">
|
||||||
|
{/* Branding */}
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-colors duration-500"
|
||||||
|
style={{ backgroundColor: `${accent}20`, color: accent }}
|
||||||
|
>
|
||||||
|
<Zap size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-lg font-black uppercase tracking-tight leading-none text-zinc-900 dark:text-white font-[Barlow_Condensed,sans-serif]">
|
||||||
|
LynkDispatch
|
||||||
|
</h1>
|
||||||
|
<p className="text-[11px] text-zinc-400 tracking-wide">AI Dispatch Engine</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right controls */}
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
{/* LIVE badge */}
|
||||||
|
<div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400">Live</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Storm Mode Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setStormMode(v => !v)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-bold uppercase tracking-wider border transition-all duration-300 ${
|
||||||
|
stormMode
|
||||||
|
? 'bg-amber-500/15 border-amber-400/50 text-amber-600 dark:text-amber-400 shadow-amber-200/40 dark:shadow-amber-500/10 shadow-md'
|
||||||
|
: 'bg-zinc-100 border-zinc-200 text-zinc-500 dark:bg-zinc-800/60 dark:border-white/10 dark:text-zinc-400 hover:border-amber-300 dark:hover:border-amber-500/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CloudLightning size={13} className={stormMode ? 'text-amber-500' : ''} />
|
||||||
|
<span className="hidden sm:inline">Storm Mode</span>
|
||||||
|
{stormMode && <span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── 3-Panel Grid ───────────────────────────────────────────────── */}
|
||||||
|
{/* Desktop: [320px fixed] [flex-1 map] [340px fixed] */}
|
||||||
|
{/* Mobile: single column, scrollable */}
|
||||||
|
<div className="flex-1 grid grid-cols-1 lg:grid-cols-[320px_1fr_340px] gap-3 px-4 pb-3 overflow-hidden lg:overflow-hidden">
|
||||||
|
|
||||||
|
{/* LEFT — Lead Queue */}
|
||||||
|
<Panel className="lg:overflow-hidden">
|
||||||
|
<PanelHeader
|
||||||
|
title="Lead Queue"
|
||||||
|
subtitle={`${unassigned} unassigned · ${dispatchLeads.length} total`}
|
||||||
|
accent={accent}
|
||||||
|
right={
|
||||||
|
<span
|
||||||
|
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||||
|
>
|
||||||
|
{dispatchLeads.length}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Placeholder — Phase 2 builds this */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
|
||||||
|
<Inbox size={22} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">{dispatchLeads.length} leads loaded</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-1">Lead queue renders in Phase 2</p>
|
||||||
|
</div>
|
||||||
|
{/* Quick stat pills */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||||
|
{[
|
||||||
|
{ label: 'Unassigned', count: dispatchLeads.filter(l => l.status === 'unassigned').length, color: '#EF4444' },
|
||||||
|
{ label: 'At Risk', count: dispatchLeads.filter(l => l.status === 'at_risk').length, color: '#F59E0B' },
|
||||||
|
{ label: 'Confirmed', count: dispatchLeads.filter(l => l.status === 'confirmed').length, color: '#10B981' },
|
||||||
|
].map(s => (
|
||||||
|
<span
|
||||||
|
key={s.label}
|
||||||
|
className="text-[11px] font-semibold px-2.5 py-1 rounded-full"
|
||||||
|
style={{ backgroundColor: `${s.color}15`, color: s.color }}
|
||||||
|
>
|
||||||
|
{s.count} {s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* CENTER — Map */}
|
||||||
|
<Panel className="lg:overflow-hidden min-h-[280px]">
|
||||||
|
<PanelHeader
|
||||||
|
title="Territory Map"
|
||||||
|
subtitle="Plano, TX — Rep routes & lead pins"
|
||||||
|
accent={accent}
|
||||||
|
right={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||||
|
<span className="text-[10px] text-zinc-400 font-medium">{DISPATCH_REPS.length} reps active</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Placeholder — Phase 4 builds Leaflet map here */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 bg-zinc-50 dark:bg-zinc-950/40 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 flex items-center justify-center">
|
||||||
|
<Radio size={28} className="text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">Interactive Map</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-1">Leaflet map with rep markers & route polylines — Phase 4</p>
|
||||||
|
</div>
|
||||||
|
{/* Rep status pills */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||||
|
{[
|
||||||
|
{ label: 'Available', count: DISPATCH_REPS.filter(r => r.status === 'available').length, color: '#10B981' },
|
||||||
|
{ label: 'En Route', count: DISPATCH_REPS.filter(r => r.status === 'en_route').length, color: '#3B82F6' },
|
||||||
|
{ label: 'Busy', count: DISPATCH_REPS.filter(r => r.status === 'busy').length, color: '#F59E0B' },
|
||||||
|
].map(s => (
|
||||||
|
<span
|
||||||
|
key={s.label}
|
||||||
|
className="text-[11px] font-semibold px-2.5 py-1 rounded-full"
|
||||||
|
style={{ backgroundColor: `${s.color}15`, color: s.color }}
|
||||||
|
>
|
||||||
|
{s.count} {s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* RIGHT — AI Recommendation Drawer */}
|
||||||
|
<Panel className="lg:overflow-hidden">
|
||||||
|
<PanelHeader
|
||||||
|
title="AI Recommendations"
|
||||||
|
subtitle={selectedLead ? `Analyzing ${selectedLead.id}` : 'Select a lead to begin'}
|
||||||
|
accent={accent}
|
||||||
|
right={
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||||
|
>
|
||||||
|
<Bot size={13} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Empty state — Phase 3 builds this */}
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<motion.div
|
||||||
|
animate={{ scale: [1, 1.05, 1] }}
|
||||||
|
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
className="w-14 h-14 rounded-2xl border-2 border-dashed border-zinc-200 dark:border-zinc-700 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Bot size={24} className="text-zinc-300 dark:text-zinc-600" />
|
||||||
|
</motion.div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-zinc-500 dark:text-zinc-400">
|
||||||
|
{selectedLead ? 'Processing...' : 'No lead selected'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-1">
|
||||||
|
Click a lead in the queue to see AI-scored rep recommendations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-zinc-300 dark:text-zinc-700 mt-2 font-medium uppercase tracking-widest">
|
||||||
|
Recommendation engine — Phase 3
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPI Bar ────────────────────────────────────────────────────── */}
|
||||||
|
<div className="shrink-0 grid grid-cols-2 lg:grid-cols-4 gap-3 px-4 pb-4">
|
||||||
|
<KpiCard
|
||||||
|
label="Leads in Queue"
|
||||||
|
value={unassigned}
|
||||||
|
icon={Inbox}
|
||||||
|
accent={stormMode ? '#F59E0B' : '#3B82F6'}
|
||||||
|
stormMode={stormMode}
|
||||||
|
pulse={false}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Assigned Today"
|
||||||
|
value={28}
|
||||||
|
icon={CheckCircle}
|
||||||
|
accent="#10B981"
|
||||||
|
stormMode={stormMode}
|
||||||
|
pulse={false}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Avg Response"
|
||||||
|
value="4.2 min"
|
||||||
|
icon={Clock}
|
||||||
|
accent="#8B5CF6"
|
||||||
|
stormMode={stormMode}
|
||||||
|
pulse={false}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Storm Alerts"
|
||||||
|
value={stormMode ? 3 : 2}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
accent={stormMode ? '#F59E0B' : '#6B7280'}
|
||||||
|
stormMode={stormMode}
|
||||||
|
pulse={stormMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LynkDispatchPage;
|
||||||
Reference in New Issue
Block a user