lynkedDispatch and territory map pages use same same status taxonomy also legend visible in lynked Dispatch now
This commit is contained in:
@@ -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 }) => (
|
||||
</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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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 = ({
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
/* ── PROACTIVE DEFAULT (ambient AI) ── */
|
||||
) : suggestion ? (
|
||||
<ProactiveSuggestion
|
||||
key="proactive"
|
||||
suggestion={suggestion}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
onDispatch={handleAssign}
|
||||
onReview={onSelectLead}
|
||||
onViewDetails={onViewDetails}
|
||||
/>
|
||||
|
||||
/* ── EMPTY STATE ── */
|
||||
) : (
|
||||
<motion.div
|
||||
|
||||
Reference in New Issue
Block a user