+ {/* ── Legend overlay — top-left (kept in view; the desktop map is
+ ~1190px tall, so a bottom-anchored legend sat far below the fold) ── */}
+
{/* Rep status legend */}
Reps
diff --git a/src/components/dispatch/DispatchRecommendationDrawer.jsx b/src/components/dispatch/DispatchRecommendationDrawer.jsx
index 02fc770..5a58fc2 100644
--- a/src/components/dispatch/DispatchRecommendationDrawer.jsx
+++ b/src/components/dispatch/DispatchRecommendationDrawer.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Bot, CheckCircle, X, Star, Clock,
@@ -574,6 +574,87 @@ const RepPickerModal = ({ onClose, onSelect, accent, reps }) => (
);
+// ---------------------------------------------------------------------------
+// Proactive (ambient-AI) default — shown when no lead is selected so the panel
+// is useful on load: surfaces the highest-priority unassigned lead and its
+// best-fit rep with a one-tap dispatch.
+// ---------------------------------------------------------------------------
+const URGENCY_RANK = { emergency: 0, high: 1, standard: 2 };
+
+const ProactiveSuggestion = ({ suggestion, stormMode, accent, onDispatch, onReview, onViewDetails }) => {
+ const { lead, rec, rep } = suggestion;
+ const repColor = REP_STATUS_COLORS[rep.status] || '#6B7280';
+ const scoreCol = scoreColor(rec.score);
+ const topReasons = rec.reasons.slice(0, 3);
+
+ return (
+
+ {/* Ambient-AI banner */}
+
+
+ Ambient AI · Suggested Dispatch
+
+
+ Your highest-priority unassigned lead with a ready best-fit rep. Dispatch in one tap, or review the full slate.
+
+
+
+
+ {/* Best-fit rep */}
+
+
+
+
+
{rep.name}
+
+ {REP_STATUS_LABELS[rep.status]} · {rec.distanceMi} mi · {rep.closeRate}% close
+
+
+
+
+ {/* Top reasons */}
+
+ {topReasons.map((r, i) => (
+
+ {r}
+
+ ))}
+
+
+
+ {/* Actions */}
+
+ onDispatch(rep, false, lead)}
+ className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-bold uppercase tracking-wider text-white transition-all duration-200 shadow-sm"
+ style={{ backgroundColor: accent }}
+ >
+
+ Dispatch {rep.name.split(' ')[0]}
+
+
+
+
+ );
+};
+
// ---------------------------------------------------------------------------
// Main Drawer
// ---------------------------------------------------------------------------
@@ -581,9 +662,11 @@ const DispatchRecommendationDrawer = ({
selectedLead,
isProcessing,
reps = DISPATCH_REPS,
+ leads = [],
stormMode,
accent,
onAssign,
+ onSelectLead,
onDismissAfterAssign,
onViewDetails,
isDesktop,
@@ -593,27 +676,43 @@ const DispatchRecommendationDrawer = ({
const recommendations = selectedLead ? (DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? []) : [];
+ // Ambient-AI default — when nothing is selected, surface the top unassigned
+ // lead (by urgency, then longest-waiting) and its best-fit rep.
+ const suggestion = useMemo(() => {
+ if (selectedLead) return null;
+ const lead = (leads || [])
+ .filter(l => l.status === 'unassigned' && (DISPATCH_RECOMMENDATIONS[l.id]?.length))
+ .sort((a, b) =>
+ (URGENCY_RANK[a.urgency] ?? 9) - (URGENCY_RANK[b.urgency] ?? 9)
+ || (b.arrivedMinsAgo || 0) - (a.arrivedMinsAgo || 0)
+ )[0];
+ if (!lead) return null;
+ const rec = DISPATCH_RECOMMENDATIONS[lead.id][0];
+ const rep = reps.find(r => r.id === rec.repId);
+ return rep ? { lead, rec, rep } : null;
+ }, [selectedLead, leads, reps]);
+
// Clear dispatched overlay when selectedLead changes
useEffect(() => {
setDispatchedInfo(null);
}, [selectedLead?.id]);
- const handleAssign = useCallback((rep, isManual = false) => {
- if (!selectedLead) return;
+ const handleAssign = useCallback((rep, isManual = false, lead = selectedLead) => {
+ if (!lead) return;
- const recs = DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? [];
+ const recs = DISPATCH_RECOMMENDATIONS[lead.id] ?? [];
const repRec = recs.find(r => r.repId === rep.id);
- onAssign(selectedLead.id, rep.id, isManual);
+ onAssign(lead.id, rep.id, isManual);
toast.success(`${rep.name.split(' ')[0]} dispatched`, {
- description: `${selectedLead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
+ description: `${lead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
duration: 4000,
});
setDispatchedInfo({
repFirstName: rep.name.split(' ')[0],
- address: `${selectedLead.property.address}, ${selectedLead.property.city}`,
+ address: `${lead.property.address}, ${lead.property.city}`,
slot: repRec?.slotStart ?? null,
});
@@ -780,6 +879,18 @@ const DispatchRecommendationDrawer = ({
+ /* ── PROACTIVE DEFAULT (ambient AI) ── */
+ ) : suggestion ? (
+
+
/* ── EMPTY STATE ── */
) : (
+ {/* Drag handle affordance — signals the card is draggable to advance stages.
+ Subtle by default, strengthens on hover/drag. Whole card stays draggable. */}
+
+
+
+
- {/* Name row */}
-
+ {/* Name row (right padding reserves space for the drag handle) */}
+
{initials}
diff --git a/src/data/statusTaxonomy.js b/src/data/statusTaxonomy.js
new file mode 100644
index 0000000..965af38
--- /dev/null
+++ b/src/data/statusTaxonomy.js
@@ -0,0 +1,61 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// Single source of truth for map status taxonomies & color tokens.
+//
+// The Territory Map (property canvassing lifecycle) and LynkDispatch (live
+// dispatch ops) track different entities, so they keep separate taxonomies —
+// but BOTH read their colors from the shared hue tokens below, so the palette
+// never drifts or contradicts itself across the app.
+//
+// One hue = one meaning:
+// red → urgent / hot amber → needs attention / busy
+// emerald→ positive / converted blue → in progress / active
+// violet → undecided / neutral slate → inactive / declined
+// gray → low priority / idle
+// ─────────────────────────────────────────────────────────────────────────────
+
+export const STATUS_HUES = {
+ red: '#ef4444',
+ amber: '#f59e0b',
+ emerald: '#10b981',
+ blue: '#3b82f6',
+ violet: '#a78bfa',
+ slate: '#cbd5e1',
+ gray: '#6b7280',
+};
+
+// ── Territory Map — property canvassing lifecycle ───────────────────────────
+// `color` — marker/polygon stroke & fill (hex)
+// `fillOpacity` — polygon fill alpha
+// `badge` — Tailwind classes for the status pill
+// `dot` — Tailwind bg class for the legend swatch
+export const CANVASSING_STATUS = {
+ 'Hot Lead': { color: STATUS_HUES.red, fillOpacity: 0.40, badge: 'bg-red-500 text-white', dot: 'bg-red-500' },
+ 'Customer': { color: STATUS_HUES.emerald, fillOpacity: 0.30, badge: 'bg-emerald-500 text-white', dot: 'bg-emerald-500' },
+ 'Renovated': { color: STATUS_HUES.blue, fillOpacity: 0.20, badge: 'bg-blue-500 text-white', dot: 'bg-blue-500' },
+ 'Neutral': { color: STATUS_HUES.violet, fillOpacity: 0.20, badge: 'bg-violet-400 text-white', dot: 'bg-violet-400' },
+ 'Not Interested': { color: STATUS_HUES.slate, fillOpacity: 0.35, badge: 'bg-slate-300 text-slate-900', dot: 'bg-slate-300' },
+};
+
+// Render/legend order for the canvassing statuses.
+export const CANVASSING_STATUS_ORDER = ['Hot Lead', 'Customer', 'Renovated', 'Neutral', 'Not Interested'];
+
+// Fallback used when a property has no (or an unknown) canvassing status.
+export const CANVASSING_STATUS_DEFAULT = CANVASSING_STATUS.Neutral;
+
+// ── LynkDispatch — live dispatch operations ─────────────────────────────────
+export const REP_STATUS = {
+ available: { color: STATUS_HUES.emerald, label: 'Available' },
+ en_route: { color: STATUS_HUES.blue, label: 'En Route' },
+ busy: { color: STATUS_HUES.amber, label: 'Busy' },
+};
+
+export const LEAD_URGENCY = {
+ emergency: { color: STATUS_HUES.red, label: 'Emergency' },
+ high: { color: STATUS_HUES.amber, label: 'High' },
+ standard: { color: STATUS_HUES.gray, label: 'Standard' },
+};
+
+// Flat id→value maps for existing call sites that index by status/urgency key.
+export const REP_STATUS_COLORS = Object.fromEntries(Object.entries(REP_STATUS).map(([k, v]) => [k, v.color]));
+export const REP_STATUS_LABELS = Object.fromEntries(Object.entries(REP_STATUS).map(([k, v]) => [k, v.label]));
+export const URGENCY_COLORS = Object.fromEntries(Object.entries(LEAD_URGENCY).map(([k, v]) => [k, v.color]));
diff --git a/src/pages/Maps.jsx b/src/pages/Maps.jsx
index c2c2165..e5b124c 100644
--- a/src/pages/Maps.jsx
+++ b/src/pages/Maps.jsx
@@ -3,6 +3,7 @@ import { MapContainer, TileLayer, Polygon, useMapEvents, Marker, Popup } from 'r
import { X, Home, DollarSign, User, Info, Edit2, Save, Check, MapPin, Loader2, Plus, AlertCircle, ShieldCheck } from 'lucide-react';
import 'leaflet/dist/leaflet.css';
import { useMockStore } from '../data/mockStore';
+import { CANVASSING_STATUS, CANVASSING_STATUS_ORDER, CANVASSING_STATUS_DEFAULT } from '../data/statusTaxonomy';
import { useAuth } from '../context/AuthContext';
import { SpotlightCard } from '../components/SpotlightCard';
import { useTheme } from '../context/ThemeContext';
@@ -31,15 +32,9 @@ L.Marker.prototype.options.icon = DefaultIcon;
// --- COMPONENTS ---
const StatusBadge = ({ status }) => {
- const colors = {
- "Neutral": "bg-violet-400",
- "Hot Lead": "bg-red-500",
- "Renovated": "bg-blue-500",
- "Customer": "bg-emerald-500",
- "Not Interested": "bg-white border border-zinc-200 text-zinc-900 shadow-sm"
- };
+ const cfg = CANVASSING_STATUS[status];
return (
-
+
{status}
);
@@ -77,26 +72,12 @@ const MapLegend = () => {
-
-
-
-
-
+ {CANVASSING_STATUS_ORDER.map(status => (
+
+ ))}
>
@@ -150,29 +131,9 @@ const MapView = ({ data, onSelect, onMapCreate }) => {
{data.map((item) => {
- let color = "#a78bfa"; // Violet-400 (New Neutral)
- let fillOpacity = 0.2;
-
- switch (item.canvassingStatus) {
- case "Hot Lead":
- color = "#ef4444"; // Red-500
- fillOpacity = 0.4;
- break;
- case "Customer":
- color = "#10b981"; // Emerald-500
- fillOpacity = 0.3;
- break;
- case "Renovated":
- color = "#3b82f6"; // Blue-500
- break;
- case "Not Interested":
- color = "#ffffff"; // Bright White
- fillOpacity = 0.4; // Higher opacity for white visibility
- break;
- default:
- // Neutral (Violet)
- break;
- }
+ const cfg = CANVASSING_STATUS[item.canvassingStatus] || CANVASSING_STATUS_DEFAULT;
+ const color = cfg.color;
+ const fillOpacity = cfg.fillOpacity;
return (