lynkedDispatch and territory map pages use same same status taxonomy also legend visible in lynked Dispatch now
This commit is contained in:
@@ -4,6 +4,9 @@ import L from 'leaflet';
|
|||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useTheme } from '../../context/ThemeContext';
|
import { useTheme } from '../../context/ThemeContext';
|
||||||
import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
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
|
// Constants
|
||||||
@@ -11,10 +14,6 @@ import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
|||||||
const PLANO_CENTER = [33.055, -96.752];
|
const PLANO_CENTER = [33.055, -96.752];
|
||||||
const DEFAULT_ZOOM = 12;
|
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
|
// Storm polygon — northeast Plano, sweeping toward central
|
||||||
const STORM_POLYGON = [
|
const STORM_POLYGON = [
|
||||||
[33.105, -96.745], [33.100, -96.695], [33.078, -96.682],
|
[33.105, -96.745], [33.100, -96.695], [33.078, -96.682],
|
||||||
@@ -360,8 +359,9 @@ const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, s
|
|||||||
)}
|
)}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
{/* ── Legend overlay — bottom-left ── */}
|
{/* ── Legend overlay — top-left (kept in view; the desktop map is
|
||||||
<div className="absolute bottom-3 left-3 z-[1000] flex flex-col gap-1.5 pointer-events-none">
|
~1190px tall, so a bottom-anchored legend sat far below the fold) ── */}
|
||||||
|
<div className="absolute top-3 left-3 z-[1000] flex flex-col gap-1.5 pointer-events-none">
|
||||||
{/* Rep status legend */}
|
{/* Rep status legend */}
|
||||||
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
|
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Reps</p>
|
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Reps</p>
|
||||||
|
|||||||
@@ -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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Bot, CheckCircle, X, Star, Clock,
|
Bot, CheckCircle, X, Star, Clock,
|
||||||
@@ -574,6 +574,87 @@ const RepPickerModal = ({ onClose, onSelect, accent, reps }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 (
|
||||||
|
<motion.div
|
||||||
|
key="proactive"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="p-3 space-y-3"
|
||||||
|
>
|
||||||
|
{/* Ambient-AI banner */}
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full w-fit" style={{ backgroundColor: `${accent}14`, color: accent }}>
|
||||||
|
<Sparkles size={11} className="shrink-0" />
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-widest">Ambient AI · Suggested Dispatch</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 leading-snug -mt-1">
|
||||||
|
Your highest-priority unassigned lead with a ready best-fit rep. Dispatch in one tap, or review the full slate.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<LeadSummary lead={lead} stormMode={stormMode} onViewDetails={onViewDetails} />
|
||||||
|
|
||||||
|
{/* Best-fit rep */}
|
||||||
|
<div className="rounded-xl border px-3 py-2.5 space-y-2" style={{ backgroundColor: `${accent}0D`, borderColor: `${accent}30` }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RepAvatar initials={rep.initials} status={rep.status} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[12px] font-bold text-zinc-900 dark:text-white leading-tight truncate">{rep.name}</p>
|
||||||
|
<p className="text-[10px] truncate" style={{ color: repColor }}>
|
||||||
|
{REP_STATUS_LABELS[rep.status]} · {rec.distanceMi} mi · {rep.closeRate}% close
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-[9px] font-bold uppercase tracking-widest text-zinc-400 leading-none mb-0.5">Score</p>
|
||||||
|
<p className="text-[1.4rem] font-black font-mono leading-none" style={{ color: scoreCol }}>{rec.score}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Top reasons */}
|
||||||
|
<div className="flex flex-wrap gap-1 pt-2 border-t" style={{ borderColor: `${accent}25` }}>
|
||||||
|
{topReasons.map((r, i) => (
|
||||||
|
<span key={i} className="inline-flex items-center text-[10px] font-medium px-2 py-0.5 rounded-full bg-white/70 dark:bg-zinc-800/70 text-zinc-600 dark:text-zinc-300">
|
||||||
|
{r}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ opacity: 0.9 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
onClick={() => 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 }}
|
||||||
|
>
|
||||||
|
<Zap size={12} />
|
||||||
|
Dispatch {rep.name.split(' ')[0]}
|
||||||
|
</motion.button>
|
||||||
|
<button
|
||||||
|
onClick={() => onReview(lead)}
|
||||||
|
className="px-3 py-2 rounded-xl text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 border border-zinc-200 dark:border-white/[0.1] hover:border-zinc-300 dark:hover:border-white/[0.18] hover:text-zinc-700 dark:hover:text-zinc-200 transition-all duration-200"
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main Drawer
|
// Main Drawer
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -581,9 +662,11 @@ const DispatchRecommendationDrawer = ({
|
|||||||
selectedLead,
|
selectedLead,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
reps = DISPATCH_REPS,
|
reps = DISPATCH_REPS,
|
||||||
|
leads = [],
|
||||||
stormMode,
|
stormMode,
|
||||||
accent,
|
accent,
|
||||||
onAssign,
|
onAssign,
|
||||||
|
onSelectLead,
|
||||||
onDismissAfterAssign,
|
onDismissAfterAssign,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
isDesktop,
|
isDesktop,
|
||||||
@@ -593,27 +676,43 @@ const DispatchRecommendationDrawer = ({
|
|||||||
|
|
||||||
const recommendations = selectedLead ? (DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? []) : [];
|
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
|
// Clear dispatched overlay when selectedLead changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDispatchedInfo(null);
|
setDispatchedInfo(null);
|
||||||
}, [selectedLead?.id]);
|
}, [selectedLead?.id]);
|
||||||
|
|
||||||
const handleAssign = useCallback((rep, isManual = false) => {
|
const handleAssign = useCallback((rep, isManual = false, lead = selectedLead) => {
|
||||||
if (!selectedLead) return;
|
if (!lead) return;
|
||||||
|
|
||||||
const recs = DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? [];
|
const recs = DISPATCH_RECOMMENDATIONS[lead.id] ?? [];
|
||||||
const repRec = recs.find(r => r.repId === rep.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`, {
|
toast.success(`${rep.name.split(' ')[0]} dispatched`, {
|
||||||
description: `${selectedLead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
|
description: `${lead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setDispatchedInfo({
|
setDispatchedInfo({
|
||||||
repFirstName: rep.name.split(' ')[0],
|
repFirstName: rep.name.split(' ')[0],
|
||||||
address: `${selectedLead.property.address}, ${selectedLead.property.city}`,
|
address: `${lead.property.address}, ${lead.property.city}`,
|
||||||
slot: repRec?.slotStart ?? null,
|
slot: repRec?.slotStart ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -780,6 +879,18 @@ const DispatchRecommendationDrawer = ({
|
|||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
/* ── PROACTIVE DEFAULT (ambient AI) ── */
|
||||||
|
) : suggestion ? (
|
||||||
|
<ProactiveSuggestion
|
||||||
|
key="proactive"
|
||||||
|
suggestion={suggestion}
|
||||||
|
stormMode={stormMode}
|
||||||
|
accent={accent}
|
||||||
|
onDispatch={handleAssign}
|
||||||
|
onReview={onSelectLead}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
/>
|
||||||
|
|
||||||
/* ── EMPTY STATE ── */
|
/* ── EMPTY STATE ── */
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { MapPin, User, Clock, CloudLightning } from 'lucide-react';
|
import { MapPin, User, Clock, CloudLightning, GripVertical } from 'lucide-react';
|
||||||
import { DEMO_TODAY } from '../../data/mockStore';
|
import { DEMO_TODAY } from '../../data/mockStore';
|
||||||
|
|
||||||
function getInitials(name) {
|
function getInitials(name) {
|
||||||
@@ -54,9 +54,23 @@ export function KanbanCardDisplay({ lead, columnColor, isOverlay = false, onClic
|
|||||||
style={{ backgroundColor: columnColor }}
|
style={{ backgroundColor: columnColor }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Drag handle affordance — signals the card is draggable to advance stages.
|
||||||
|
Subtle by default, strengthens on hover/drag. Whole card stays draggable. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
title="Drag to move stage"
|
||||||
|
className={`absolute top-2 right-1.5 pointer-events-none transition-colors ${
|
||||||
|
isOverlay
|
||||||
|
? 'text-blue-400 dark:text-blue-500'
|
||||||
|
: 'text-zinc-300 dark:text-zinc-600 group-hover:text-zinc-500 dark:group-hover:text-zinc-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pl-4 pr-3 py-3">
|
<div className="pl-4 pr-3 py-3">
|
||||||
{/* Name row */}
|
{/* Name row (right padding reserves space for the drag handle) */}
|
||||||
<div className="flex items-start gap-2 mb-2">
|
<div className="flex items-start gap-2 mb-2 pr-5">
|
||||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-bold shrink-0 mt-0.5 ${avatarCls}`}>
|
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-bold shrink-0 mt-0.5 ${avatarCls}`}>
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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]));
|
||||||
+12
-51
@@ -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 { X, Home, DollarSign, User, Info, Edit2, Save, Check, MapPin, Loader2, Plus, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useMockStore } from '../data/mockStore';
|
import { useMockStore } from '../data/mockStore';
|
||||||
|
import { CANVASSING_STATUS, CANVASSING_STATUS_ORDER, CANVASSING_STATUS_DEFAULT } from '../data/statusTaxonomy';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { SpotlightCard } from '../components/SpotlightCard';
|
import { SpotlightCard } from '../components/SpotlightCard';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
@@ -31,15 +32,9 @@ L.Marker.prototype.options.icon = DefaultIcon;
|
|||||||
// --- COMPONENTS ---
|
// --- COMPONENTS ---
|
||||||
|
|
||||||
const StatusBadge = ({ status }) => {
|
const StatusBadge = ({ status }) => {
|
||||||
const colors = {
|
const cfg = CANVASSING_STATUS[status];
|
||||||
"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"
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<span className={`px-2 py-1 rounded text-[10px] uppercase font-bold text-white shadow-sm ${colors[status] || "bg-zinc-400"}`}>
|
<span className={`px-2 py-1 rounded text-[10px] uppercase font-bold shadow-sm ${cfg ? cfg.badge : 'bg-zinc-400 text-white'}`}>
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -77,26 +72,12 @@ const MapLegend = () => {
|
|||||||
<button onClick={() => setIsOpen(false)} className="md:hidden ml-4"><X size={14} /></button>
|
<button onClick={() => setIsOpen(false)} className="md:hidden ml-4"><X size={14} /></button>
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
{CANVASSING_STATUS_ORDER.map(status => (
|
||||||
<div className="w-3 h-3 rounded-full bg-red-500 shadow-sm shadow-red-500/50"></div>
|
<div key={status} className="flex items-center space-x-2">
|
||||||
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Hot Lead</span>
|
<div className={`w-3 h-3 rounded-full shadow-sm ring-1 ring-black/5 dark:ring-white/10 ${CANVASSING_STATUS[status].dot}`}></div>
|
||||||
</div>
|
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">{status}</span>
|
||||||
<div className="flex items-center space-x-2">
|
</div>
|
||||||
<div className="w-3 h-3 rounded-full bg-emerald-500 shadow-sm shadow-emerald-500/50"></div>
|
))}
|
||||||
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Customer</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-blue-500 shadow-sm shadow-blue-500/50"></div>
|
|
||||||
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Renovated</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-violet-400 shadow-sm shadow-violet-400/50"></div>
|
|
||||||
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Neutral</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-white border-2 border-zinc-300 dark:border-zinc-500 shadow-sm"></div>
|
|
||||||
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Not Interested</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -150,29 +131,9 @@ const MapView = ({ data, onSelect, onMapCreate }) => {
|
|||||||
<MapInteraction onMapClick={onMapCreate} />
|
<MapInteraction onMapClick={onMapCreate} />
|
||||||
|
|
||||||
{data.map((item) => {
|
{data.map((item) => {
|
||||||
let color = "#a78bfa"; // Violet-400 (New Neutral)
|
const cfg = CANVASSING_STATUS[item.canvassingStatus] || CANVASSING_STATUS_DEFAULT;
|
||||||
let fillOpacity = 0.2;
|
const color = cfg.color;
|
||||||
|
const fillOpacity = cfg.fillOpacity;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Polygon
|
<Polygon
|
||||||
|
|||||||
Reference in New Issue
Block a user