diff --git a/src/components/dispatch/DispatchMapPanel.jsx b/src/components/dispatch/DispatchMapPanel.jsx index 35151f5..e502243 100644 --- a/src/components/dispatch/DispatchMapPanel.jsx +++ b/src/components/dispatch/DispatchMapPanel.jsx @@ -4,6 +4,9 @@ import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { useTheme } from '../../context/ThemeContext'; import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore'; +// Shared color taxonomy — same source of truth the Territory Map reads from, +// so pin colors stay consistent across the app. +import { REP_STATUS_COLORS, REP_STATUS_LABELS, URGENCY_COLORS } from '../../data/statusTaxonomy'; // --------------------------------------------------------------------------- // Constants @@ -11,10 +14,6 @@ import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore'; const PLANO_CENTER = [33.055, -96.752]; const DEFAULT_ZOOM = 12; -const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' }; -const REP_STATUS_LABELS = { available: 'Available', en_route: 'En Route', busy: 'Busy' }; -const URGENCY_COLORS = { emergency: '#EF4444', high: '#F59E0B', standard: '#6B7280' }; - // Storm polygon — northeast Plano, sweeping toward central const STORM_POLYGON = [ [33.105, -96.745], [33.100, -96.695], [33.078, -96.682], @@ -360,8 +359,9 @@ const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, s )} - {/* ── Legend overlay — bottom-left ── */} -
+ {/* ── 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 +

+
+
+

Score

+

{rec.score}

+
+
+ {/* 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 = () => {
-
-
- Hot Lead -
-
-
- Customer -
-
-
- Renovated -
-
-
- Neutral -
-
-
- Not Interested -
+ {CANVASSING_STATUS_ORDER.map(status => ( +
+
+ {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 (