Compare commits

...

10 Commits

Author SHA1 Message Date
Mayur7887 4da2bdf168 Merge pull request 'Diy estimate presentation module' (#3) from diy-estimate-presentation-module into revamp
Reviewed-on: AITeam/LynkedUpPro_CRM_Demo_Old#3
2026-06-19 06:54:35 +00:00
Mayur Shinde a88e45542f added legend on team shedule page 2026-06-18 18:59:31 +05:30
Mayur Shinde 1dff3943a0 lynkedDispatch and territory map pages use same same status taxonomy also legend visible in lynked Dispatch now 2026-06-18 18:26:09 +05:30
Mayur Shinde d254fa0cec added info badge to show why ai distpatch shows 89% on lynkeddispatch page 2026-06-18 16:37:56 +05:30
Mayur Shinde 0d1dea5703 addedvariance , health legends 2026-06-18 16:11:32 +05:30
Mayur Shinde 50d329f645 leaderboard column colours changed 2026-06-18 15:24:26 +05:30
Mayur Shinde 92a7faef32 mobile responsive owners box 2026-06-18 15:10:22 +05:30
Mayur Shinde 484d528e68 change layout/design of login page 2026-06-16 20:51:23 +05:30
Mayur Shinde c8865e7d4c hide chatboat icon ,mobile responsive drawer 2026-06-15 11:51:59 +05:30
Mayur Shinde 11d5662a92 demo bug fixes 2026-06-12 22:45:00 +05:30
18 changed files with 806 additions and 245 deletions
+6 -5
View File
@@ -112,8 +112,7 @@ const Layout = () => {
}, []);
// Determine standard layout vs full screen for Public pages
const isPublic = ['/', '/login', '/estimate'].includes(location.pathname)
|| location.pathname.startsWith('/ads/');
const isPublic = ['/', '/login'].includes(location.pathname);
if (isPublic) {
return (
@@ -155,12 +154,12 @@ const Layout = () => {
{ to: "/admin/schedule", icon: Calendar, label: "Team Schedule" },
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
{ to: "/owner/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
{ to: "/owner/payments", icon: CreditCard, label: "Payment Management" },
{ to: "/owner/people", icon: Users, label: "People" },
{ to: "/owner/settings", icon: Settings, label: "Org Settings" },
...commonItems,
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
{ to: "/owner/payments", icon: CreditCard, label: "Manage Payments" },
{ type: "section", label: "Upcoming Features" },
{ to: "/campaignx", icon: Rocket, label: <span>Campaign<span className="text-amber-500">X</span></span> },
];
@@ -182,11 +181,11 @@ const Layout = () => {
},
{ to: "/admin/estimates", icon: Calculator, label: "Estimates" },
{ to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" },
{ to: "/owner/payments", icon: CreditCard, label: "Payment Management" },
{ to: "/admin/settings", icon: Settings, label: "Org Settings" },
...commonItems,
{ to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" },
{ to: "/marketing", icon: Megaphone, label: "Ad Engine" },
{ to: "/owner/payments", icon: CreditCard, label: "Manage Payments" },
{ type: "section", label: "Upcoming Features" },
{ to: "/campaignx", icon: Rocket, label: <span>Campaign<span className="text-amber-500">X</span></span> },
];
@@ -385,7 +384,9 @@ const Layout = () => {
</PageTransition>
</main>
<Chatbot />
{/* Hide the floating chat on full-page flows that have their own bottom CTAs
(estimate wizard, ad preview form, Ad Engine) so it doesn't overlap their buttons. */}
{!(location.pathname === '/estimate' || location.pathname === '/marketing' || location.pathname.startsWith('/ads/')) && <Chatbot />}
</div>
);
};
+6 -6
View File
@@ -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
)}
</MapContainer>
{/* ── Legend overlay — bottom-left ── */}
<div className="absolute bottom-3 left-3 z-[1000] flex flex-col gap-1.5 pointer-events-none">
{/* ── Legend overlay — top-left (kept in view; the desktop map is
~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 */}
<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>
@@ -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
+17 -3
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';
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';
function getInitials(name) {
@@ -54,9 +54,23 @@ export function KanbanCardDisplay({ lead, columnColor, isOverlay = false, onClic
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">
{/* Name row */}
<div className="flex items-start gap-2 mb-2">
{/* Name row (right padding reserves space for the drag handle) */}
<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}`}>
{initials}
</div>
+6 -3
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { X, Home, DollarSign, User, Edit2, Save, Info, AlertCircle, ShieldCheck, Loader2, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import { SpotlightCard } from '../SpotlightCard';
@@ -309,7 +310,7 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
);
};
return (
return createPortal(
<div
className={`fixed z-[1000] flex flex-col transition-transform duration-300 ease-out inset-0 md:inset-auto md:top-4 md:right-4 md:bottom-4 md:w-[500px] ${isOpen ? 'translate-x-0' : 'translate-x-full md:translate-x-[calc(100%+2rem)]'}`}
role="dialog"
@@ -325,7 +326,8 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
<button
ref={closeButtonRef}
onClick={onClose}
className="absolute top-4 right-4 p-2 bg-white/50 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 rounded-full transition-all backdrop-blur-md text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-zinc-500 z-50 cursor-pointer shadow-sm border border-black/5 dark:border-white/5"
style={{ top: 'max(1rem, env(safe-area-inset-top))' }}
className="absolute right-4 p-2 bg-white/50 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 rounded-full transition-all backdrop-blur-md text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-zinc-500 z-50 cursor-pointer shadow-sm border border-black/5 dark:border-white/5"
aria-label="Close property details"
>
<X size={18} />
@@ -710,7 +712,8 @@ const PropertyDetailDrawer = ({ isOpen, onClose, data, newLocation, onSave, load
</div>
)}
</div>
</div>,
document.body
);
};
@@ -390,7 +390,9 @@ const CommissionDistributionPanel = ({
<p className="text-xs text-zinc-400 mt-1">Add team members with commission rules to see the distribution.</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
<>
{/* ── Desktop table (sm+) ── */}
<div className="hidden sm:block overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
<table className="w-full text-left min-w-[600px]">
<thead>
<tr className="border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
@@ -465,6 +467,65 @@ const CommissionDistributionPanel = ({
</tfoot>
</table>
</div>
{/* ── Mobile cards (< sm) ── */}
<div className="sm:hidden space-y-2.5">
{filteredRows.length === 0 ? (
<div className="rounded-xl border border-zinc-200 dark:border-white/5 py-10 text-center">
<Users size={22} className="mx-auto mb-2 text-zinc-400" />
<p className="text-sm text-zinc-500">No commission entries match the current filters.</p>
<button
type="button"
onClick={clearFilters}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 transition-colors"
>
<FilterX size={12} /> Clear Filters
</button>
</div>
) : (
<>
{filteredRows.map((row) => (
<div key={row.key} className="rounded-xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-white/[0.02] p-3.5">
{/* Name + Amount */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-7 h-7 rounded-full bg-emerald-500/15 flex items-center justify-center text-[10px] font-black text-emerald-600 dark:text-emerald-400 flex-shrink-0">
{row.name?.[0] || '?'}
</div>
<span className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{row.name}</span>
</div>
<span className="font-mono text-sm font-bold text-green-600 dark:text-[#39ff14] shrink-0">{fmt(row.amount)}</span>
</div>
{/* Role */}
<div className="mt-2.5">
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border ${ROLE_COLORS[row.role] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
{row.role}
</span>
</div>
{/* Project / Type */}
<div className="grid grid-cols-2 gap-2 mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Project ID</span>
<span className="text-[11px] font-mono text-zinc-500 dark:text-zinc-400 truncate">{row.projectId}</span>
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Commission Type</span>
<span className="text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{row.commissionType}</span>
</div>
</div>
</div>
))}
{/* Total */}
<div className="rounded-xl border border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.03] px-3.5 py-3 flex items-center justify-between">
<span className="text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">
{hasActiveFilters ? 'Filtered Total' : 'Total'}
</span>
<span className="font-mono text-sm font-extrabold text-green-600 dark:text-[#39ff14]">{fmt(total)}</span>
</div>
</>
)}
</div>
</>
)}
</div>
);
+64 -7
View File
@@ -393,8 +393,9 @@ const TeamManagementPanel = ({
transition={{ duration: 0.25 }}
className="overflow-hidden"
>
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
<table className="w-full text-left">
{/* ── Desktop table (sm+) ── */}
<div className="hidden sm:block overflow-x-auto rounded-xl border border-zinc-200 dark:border-white/5">
<table className="w-full text-left min-w-[560px]">
<thead>
<tr className="border-b border-zinc-200 dark:border-white/5 bg-zinc-50 dark:bg-white/[0.02]">
<th className="px-4 py-2.5 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Member</th>
@@ -419,17 +420,17 @@ const TeamManagementPanel = ({
</div>
</td>
<td className="px-4 py-3">
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border ${ROLE_COLORS[mc.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border whitespace-nowrap ${ROLE_COLORS[mc.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
{mc.jobRole}
</span>
</td>
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400">{COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-zinc-900 dark:text-white">
<td className="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-400 whitespace-nowrap">{COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-zinc-900 dark:text-white whitespace-nowrap">
{mc.resolved.type === 'flat' || mc.resolved.type === 'custom'
? formatCurrency(mc.resolved.rate)
: `${mc.resolved.rate}%`}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14]">
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14] whitespace-nowrap">
{formatCurrency(mc.value)}
</td>
<td className="px-4 py-3 text-center">
@@ -445,7 +446,7 @@ const TeamManagementPanel = ({
<tfoot className="border-t-2 border-zinc-300 dark:border-white/10">
<tr className="bg-zinc-50 dark:bg-white/[0.03]">
<td colSpan={4} className="px-4 py-3 text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Total Distribution</td>
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14]">
<td className="px-4 py-3 text-right font-mono text-xs font-bold text-green-600 dark:text-[#39ff14] whitespace-nowrap">
{formatCurrency(memberCommissions.filter(m => m.status === 'active').reduce((s, mc) => s + mc.value, 0))}
</td>
<td />
@@ -453,6 +454,62 @@ const TeamManagementPanel = ({
</tfoot>
</table>
</div>
{/* ── Mobile cards (< sm) ── */}
<div className="sm:hidden space-y-2.5">
{memberCommissions.filter(m => m.status === 'active').map((mc) => {
const srcCfg = COMMISSION_SOURCE_CFG[mc.resolved.source] || COMMISSION_SOURCE_CFG.org;
return (
<div key={mc.id} className="rounded-xl border border-zinc-200 dark:border-white/5 bg-white dark:bg-white/[0.02] p-3.5">
{/* Member + source */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-7 h-7 rounded-full bg-violet-500/15 flex items-center justify-center text-[11px] font-black text-violet-400 flex-shrink-0">
{mc.name?.[0]}
</div>
<span className="text-sm font-semibold text-zinc-900 dark:text-white truncate">{mc.name}</span>
</div>
<span className={`inline-flex items-center gap-1 text-[10px] font-bold rounded-full px-2 py-0.5 border shrink-0 ${srcCfg.bg}`}>
<span className={`w-1.5 h-1.5 rounded-full ${srcCfg.dot}`} />
<span className={srcCfg.color}>{srcCfg.label}</span>
</span>
</div>
{/* Role */}
<div className="mt-2.5">
<span className={`text-[11px] font-bold rounded-md px-1.5 py-0.5 border ${ROLE_COLORS[mc.jobRole] || 'text-zinc-400 bg-zinc-500/10 border-zinc-500/20'}`}>
{mc.jobRole}
</span>
</div>
{/* Type / Rate / Value */}
<div className="grid grid-cols-3 gap-2 mt-3 pt-3 border-t border-zinc-100 dark:border-white/5">
<div className="flex flex-col gap-0.5">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Type</span>
<span className="text-[11px] text-zinc-700 dark:text-zinc-300">{COMMISSION_TYPES[mc.resolved.type] || mc.resolved.type}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Rate</span>
<span className="font-mono text-[11px] text-zinc-900 dark:text-white">
{mc.resolved.type === 'flat' || mc.resolved.type === 'custom'
? formatCurrency(mc.resolved.rate)
: `${mc.resolved.rate}%`}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[9px] font-mono font-bold text-zinc-400 dark:text-zinc-500 uppercase tracking-widest">Value</span>
<span className="font-mono text-[11px] font-bold text-green-600 dark:text-[#39ff14]">{formatCurrency(mc.value)}</span>
</div>
</div>
</div>
);
})}
{/* Total */}
<div className="rounded-xl border border-zinc-300 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.03] px-3.5 py-3 flex items-center justify-between">
<span className="text-[10px] font-mono font-bold text-zinc-500 uppercase tracking-widest">Total Distribution</span>
<span className="font-mono text-sm font-bold text-green-600 dark:text-[#39ff14]">
{formatCurrency(memberCommissions.filter(m => m.status === 'active').reduce((s, mc) => s + mc.value, 0))}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
+1 -5
View File
@@ -9,12 +9,8 @@ export const ThemeProvider = ({ children }) => {
if (savedTheme) {
return savedTheme;
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
}
return 'light'; // Default to light if no preference
return 'dark'; // Default to dark for first-time visitors
});
useEffect(() => {
+21 -5
View File
@@ -3613,9 +3613,9 @@ const MOCK_PROJECTS = [
budget: 19600,
approvedBudget: 19600,
committedCost: 18100,
actualCost: 0,
spent: 0,
variancePercent: -100.0,
actualCost: 18100,
spent: 18100,
variancePercent: -7.7,
margin: 0.08,
commissionType: 'percent_gross',
commissionRate: 10,
@@ -3632,6 +3632,22 @@ const MOCK_PROJECTS = [
{ name: 'City of Plano', category: 'Permits', allocated: 600, committed: 600, actual: 0 },
{ name: 'Reserve Fund', category: 'Contingency', allocated: 1500, committed: 0, actual: 0 },
],
expenses: [
{ id: 'exp_017_1', type: 'material', name: 'Owens Corning', category: 'Materials', allocated: 10500, actual: 10500, paid: true, paymentRequests: [
{ id: 'req_017_1_1', requestDate: '2026-04-02', requestedAmount: 10500, amountPaid: 10500, paymentDate: '2026-04-04' }
] },
{ id: 'exp_017_2', type: 'labor', name: 'Plano Roofing Crew C', category: 'Labor', allocated: 7000, actual: 7000, paid: true, paymentRequests: [
{ id: 'req_017_2_1', requestDate: '2026-04-22', requestedAmount: 7000, amountPaid: 7000, paymentDate: '2026-04-24' }
] },
{ id: 'exp_017_3', type: 'other', name: 'City of Plano', category: 'Permits', allocated: 600, actual: 600, paid: true, paymentRequests: [
{ id: 'req_017_3_1', requestDate: '2026-04-10', requestedAmount: 600, amountPaid: 600, paymentDate: '2026-04-10' }
] }
],
paymentsReceived: [
{ id: 'rcv_017_1', from: 'Adriana Voss', method: 'Check', amount: 5880, date: '2026-03-28', refNumber: 'CHK-2017-01', memo: 'Deposit — 30%' },
{ id: 'rcv_017_2', from: 'Farmers Insurance', method: 'ACH', amount: 7840, date: '2026-04-25', refNumber: 'ACH-2017-02', memo: 'Progress payment — 40%' },
{ id: 'rcv_017_3', from: 'Farmers Insurance', method: 'ACH', amount: 5880, date: '2026-04-28', refNumber: 'ACH-2017-03', memo: 'Final payment — 30%' },
],
changeOrders: [
{ id: 'CO-017-01', title: 'Supplemental — Ice & Water Barrier', amount: 1800, status: 'approved', description: 'Insurance adjuster agreed to add ice/water shield on all valleys and eaves.' },
],
@@ -3640,8 +3656,8 @@ const MOCK_PROJECTS = [
issueLog: [],
paymentSchedule: [
{ id: 'ps_017_1', milestone: 'Deposit — 30%', amount: 5880, dueDate: '2026-03-28', status: 'paid', paidDate: '2026-03-28' },
{ id: 'ps_017_2', milestone: 'Progress — 40%', amount: 7840, dueDate: '2026-04-25', status: 'pending' },
{ id: 'ps_017_3', milestone: 'Final — 30%', amount: 5880, dueDate: '2026-04-28', status: 'pending' },
{ id: 'ps_017_2', milestone: 'Progress — 40%', amount: 7840, dueDate: '2026-04-25', status: 'paid', paidDate: '2026-04-25' },
{ id: 'ps_017_3', milestone: 'Final — 30%', amount: 5880, dueDate: '2026-04-28', status: 'paid', paidDate: '2026-04-28' },
],
invoices: [],
milestones: [
+77
View File
@@ -0,0 +1,77 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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]));
// ── Team Schedule — appointment / meeting status ────────────────────────────
// `badge` — Tailwind classes for the status pill
// `dot` — Tailwind bg class for the legend swatch
// `desc` — one-line meaning shown in the legend (so Converted vs Scheduled is clear)
export const MEETING_STATUS = {
Scheduled: { label: 'Scheduled', badge: 'bg-blue-500/20 text-blue-600 dark:text-blue-400', dot: 'bg-blue-500', desc: 'Upcoming appointment' },
Rescheduled: { label: 'Rescheduled', badge: 'bg-amber-500/20 text-amber-600 dark:text-amber-400', dot: 'bg-amber-500', desc: 'Moved to a new date or time' },
Completed: { label: 'Completed', badge: 'bg-emerald-500/20 text-emerald-600 dark:text-emerald-400', dot: 'bg-emerald-500', desc: 'Visit took place' },
Converted: { label: 'Converted', badge: 'bg-violet-500/20 text-violet-600 dark:text-violet-400', dot: 'bg-violet-500', desc: 'Won — became a signed job' },
Cancelled: { label: 'Cancelled', badge: 'bg-slate-500/20 text-slate-600 dark:text-slate-400', dot: 'bg-slate-400', desc: 'Called off' },
};
export const MEETING_STATUS_ORDER = ['Scheduled', 'Rescheduled', 'Completed', 'Converted', 'Cancelled'];
export const MEETING_STATUS_DEFAULT = { label: 'Unknown', badge: 'bg-zinc-400/20 text-zinc-500 dark:text-zinc-400', dot: 'bg-zinc-400', desc: '' };
@@ -79,9 +79,6 @@ const DemoCheckout = () => {
<Row label="CVC" value="123" />
</div>
</div>
<p className="flex items-center gap-1.5 text-[11px] text-emerald-600 dark:text-emerald-400 mt-2">
<CheckCircle2 size={12} /> Test card pre-filled for the demo just press Pay.
</p>
<button
onClick={handlePay}
@@ -9,9 +9,9 @@
* closing the spec's loop (§5). Front-end demo only no real platform or backend.
*/
import React, { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import {
ShieldCheck, CheckCircle2, ArrowRight, Loader2, Megaphone, Lock, Phone,
ShieldCheck, CheckCircle2, ArrowRight, ArrowLeft, Loader2, Megaphone, Lock, Phone,
} from 'lucide-react';
import { toast } from 'sonner';
import { BLUEPRINT_BY_ID, BLUEPRINTS } from './data/blueprints.js';
@@ -54,6 +54,11 @@ const inputCls =
export default function PublicAdLanding() {
const [params] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// Only show the "Back" control when this page was reached via in-app navigation
// (i.e. a preview opened from the Ad Engine) a real ad click lands with no app history.
const cameFromApp = location.key !== 'default';
const blueprint = BLUEPRINT_BY_ID[params.get('c')] || BLUEPRINTS[0];
const platformId = getPlatform(params.get('src')) ? params.get('src') : 'meta';
@@ -113,6 +118,14 @@ export default function PublicAdLanding() {
<p className="text-[11px] text-zinc-400 mt-4 flex items-center justify-center gap-1">
<ShieldCheck size={12} /> Your information is kept private and used only to contact you about this request.
</p>
{cameFromApp && (
<button
onClick={() => navigate(-1)}
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ArrowLeft size={16} /> Back to Ad Engine
</button>
)}
</div>
</div>
);
@@ -120,6 +133,18 @@ export default function PublicAdLanding() {
return (
<div className="min-h-[100dvh] w-full bg-zinc-50 dark:bg-[#09090b]">
{/* Back to Ad Engine — only when opened as a preview from inside the CRM */}
{cameFromApp && (
<div className="max-w-2xl mx-auto px-4 pt-4">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-1.5 text-sm font-semibold text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ArrowLeft size={16} /> Back to Ad Engine
</button>
</div>
)}
{/* Sponsored banner — simulates the ad context */}
<div className="w-full bg-zinc-900 text-white/80 text-[11px] py-1.5 px-4 flex items-center justify-center gap-1.5">
<Megaphone size={12} />
+56 -8
View File
@@ -1,7 +1,8 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useMockStore } from '../data/mockStore';
import { MEETING_STATUS, MEETING_STATUS_ORDER, MEETING_STATUS_DEFAULT } from '../data/statusTaxonomy';
import { useTheme } from '../context/ThemeContext';
import { Calendar, User, Clock, MapPin, MoreHorizontal, Filter, X, ChevronDown } from 'lucide-react';
import { Calendar, User, Clock, MapPin, MoreHorizontal, Filter, X, ChevronDown, Info } from 'lucide-react';
import { SpotlightCard } from '../components/SpotlightCard';
const AdminSchedule = () => {
@@ -11,6 +12,10 @@ const AdminSchedule = () => {
// Refs for click-outside detection
const dateDropdownRef = useRef(null);
const statusDropdownRef = useRef(null);
const legendRef = useRef(null);
// Status legend popover (opens as a card from the Info button)
const [showLegend, setShowLegend] = useState(false);
// Filter states
const [dateFilter, setDateFilter] = useState('all'); // all, today, week, month, quarter, custom
@@ -29,6 +34,9 @@ const AdminSchedule = () => {
if (statusDropdownRef.current && !statusDropdownRef.current.contains(event.target)) {
setShowStatusDropdown(false);
}
if (legendRef.current && !legendRef.current.contains(event.target)) {
setShowLegend(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
@@ -314,11 +322,56 @@ const AdminSchedule = () => {
</div>
<SpotlightCard className="overflow-hidden">
<div className="p-6 border-b border-zinc-200 dark:border-zinc-800">
<div className="p-6 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between gap-3">
<h2 className="text-xl font-bold text-zinc-900 dark:text-white flex items-center">
<Calendar size={20} className="text-blue-500 mr-2" />
{viewMode === 'list' ? 'All Active Appointments' : 'Calendar Overview'}
</h2>
{/* Status legend opens as a card from the Info button; labels the
badge colors (Converted vs Scheduled, etc.). Responsive on all sizes. */}
{viewMode === 'list' && (
<div className="relative shrink-0" ref={legendRef}>
<button
type="button"
onClick={() => setShowLegend(o => !o)}
aria-expanded={showLegend}
aria-label="Status legend"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-zinc-600 dark:text-zinc-300 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 transition-colors"
>
<Info size={16} />
<span className="hidden sm:inline">Legend</span>
</button>
{showLegend && (
<div className="absolute right-0 top-full mt-2 z-50 w-72 max-w-[calc(100vw-2rem)] rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 shadow-2xl shadow-black/10 dark:shadow-black/50 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-zinc-100 dark:border-white/[0.06]">
<span className="text-[11px] font-black uppercase tracking-widest text-zinc-500 dark:text-zinc-400">Status Legend</span>
<button
type="button"
onClick={() => setShowLegend(false)}
aria-label="Close legend"
className="p-1 rounded-md text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
>
<X size={14} />
</button>
</div>
<div className="p-3 space-y-2.5">
{MEETING_STATUS_ORDER.map(status => {
const cfg = MEETING_STATUS[status];
return (
<div key={status} className="flex items-start gap-2.5">
<span className={`w-2.5 h-2.5 rounded-full shrink-0 mt-1 ${cfg.dot}`} />
<div className="min-w-0">
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-100">{cfg.label}</p>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 leading-snug">{cfg.desc}</p>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
{viewMode === 'list' ? (
@@ -361,12 +414,7 @@ const AdminSchedule = () => {
</div>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs font-semibold rounded whitespace-nowrap ${meeting.status === 'Completed' ? 'bg-green-500/20 text-green-600 dark:text-green-400' :
meeting.status === 'Scheduled' ? 'bg-blue-500/20 text-blue-600 dark:text-blue-400' :
meeting.status === 'Converted' ? 'bg-purple-500/20 text-purple-600 dark:text-purple-400' :
meeting.status === 'Cancelled' ? 'bg-red-500/20 text-red-600 dark:text-red-400' :
'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400'
}`}>
<span className={`px-2 py-1 text-xs font-semibold rounded whitespace-nowrap ${(MEETING_STATUS[meeting.status] || MEETING_STATUS_DEFAULT).badge}`}>
{meeting.status}
</span>
</td>
+57 -21
View File
@@ -16,47 +16,85 @@ const rankBadge = (i) =>
i === 2 ? 'bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300' :
'text-zinc-500';
// Podium (shared)
const TopPodium = ({ data, metricKey, numberFormat = {}, accentColor = 'border-yellow-400' }) => {
if (data.length < 3) return null;
const [first, second, third] = data;
// Bars grow from a shared baseline at a constant speed, so the shortest (bronze)
// finishes first, then silver, and the tallest (gold) lands last.
const Step = ({ agent, rank, heightPx, color, glow }) => (
// Podium tier styling (gold / silver / bronze)
// Solid metal bars with dark, clearly-legible rank numerals. Height encodes rank:
// 1st is tallest, then 2nd, then 3rd.
const PODIUM_TIERS = {
1: {
heightPx: 200,
border: 'border-yellow-400',
avatarRing: 'border-yellow-400',
bar: 'bg-gradient-to-b from-yellow-300 to-yellow-500',
numeral: 'text-yellow-900/80',
text: 'text-yellow-600 dark:text-yellow-400',
glow: 'bg-yellow-500',
},
2: {
heightPx: 144,
border: 'border-zinc-300',
avatarRing: 'border-zinc-300',
bar: 'bg-gradient-to-b from-zinc-200 to-zinc-400',
numeral: 'text-zinc-700/80',
text: 'text-zinc-500 dark:text-zinc-300',
glow: 'bg-zinc-400',
},
3: {
heightPx: 100,
border: 'border-orange-400',
avatarRing: 'border-orange-400',
bar: 'bg-gradient-to-b from-orange-300 to-orange-500',
numeral: 'text-orange-900/80',
text: 'text-orange-600 dark:text-orange-400',
glow: 'bg-orange-500',
},
};
// Podium step (single column)
// Bars grow from a shared baseline at a constant speed, so the shortest (bronze)
// finishes first, then silver, and the tallest (gold) lands last.
const PodiumStep = ({ agent, rank, metricKey, numberFormat = {} }) => {
const tier = PODIUM_TIERS[rank];
return (
<div className="flex flex-col items-center z-10 mx-2 md:mx-4">
<div className="mb-4 text-center">
<div className="relative inline-block">
<div className={`w-16 h-16 md:w-20 md:h-20 rounded-full border-4 ${color} flex items-center justify-center bg-zinc-900/5 dark:bg-zinc-800 shadow-xl overflow-hidden mb-2 relative z-10`}>
<div className={`w-16 h-16 md:w-20 md:h-20 rounded-full border-4 ${tier.avatarRing} flex items-center justify-center bg-zinc-900/5 dark:bg-zinc-800 shadow-xl overflow-hidden mb-2 relative z-10`}>
<span className="text-xl font-bold text-zinc-700 dark:text-zinc-300">
{agent.name.split(' ').map(n => n[0]).join('')}
</span>
</div>
{rank === 1 && <Crown size={24} className="absolute -top-4 -right-2 text-yellow-400 fill-yellow-400 animate-bounce" />}
<div className={`absolute inset-0 rounded-full blur-xl opacity-40 ${glow}`} />
<div className={`absolute inset-0 rounded-full blur-xl opacity-40 ${tier.glow}`} />
</div>
<h3 className="font-bold text-sm md:text-base text-zinc-900 dark:text-white truncate max-w-[100px]">{agent.name}</h3>
<p className={`font-mono font-bold text-sm md:text-lg ${color.replace('border-', 'text-')}`}>
<p className={`font-mono font-bold text-sm md:text-lg ${tier.text}`}>
<AnimatedNumber value={agent[metricKey]} duration={1.4} {...numberFormat} />
</p>
</div>
<motion.div
initial={{ height: 0 }}
animate={{ height: heightPx }}
transition={{ duration: heightPx / 100, ease: 'easeOut' }}
className={`w-24 md:w-32 rounded-t-lg bg-gradient-to-b from-white/80 to-white/20 dark:from-white/10 dark:to-white/5 border-t border-x ${color} backdrop-blur-md relative overflow-hidden hover:brightness-110`}
animate={{ height: tier.heightPx }}
transition={{ duration: tier.heightPx / 100, ease: 'easeOut' }}
className={`w-24 md:w-32 rounded-t-lg ${tier.bar} border-t border-x ${tier.border} shadow-lg relative overflow-hidden hover:brightness-110`}
>
<div className="absolute inset-x-0 top-0 h-[1px] bg-white/50" />
<div className="w-full h-full flex items-end justify-center pb-4 text-4xl font-black text-black/5 dark:text-white/5 select-none">{rank}</div>
<div className="absolute inset-x-0 top-0 h-[2px] bg-white/50" />
<div className={`w-full h-full flex items-end justify-center pb-4 text-5xl font-black ${tier.numeral} select-none`}>{rank}</div>
</motion.div>
</div>
);
};
// Podium (shared)
const TopPodium = ({ data, metricKey, numberFormat = {} }) => {
if (data.length < 3) return null;
const [first, second, third] = data;
return (
// Fixed min-height reserves the full podium space so the shared baseline
// never shifts while the bars animate they grow upward from the floor.
<div className="flex items-end justify-center mb-12 pt-8 min-h-[360px]">
<Step agent={second} rank={2} heightPx={128} color="border-zinc-300" glow="bg-zinc-400" />
<Step agent={first} rank={1} heightPx={176} color={accentColor} glow="bg-yellow-500" />
<Step agent={third} rank={3} heightPx={96} color="border-orange-400" glow="bg-orange-500" />
<div className="flex items-end justify-center mb-12 pt-8 min-h-[380px]">
<PodiumStep agent={second} rank={2} metricKey={metricKey} numberFormat={numberFormat} />
<PodiumStep agent={first} rank={1} metricKey={metricKey} numberFormat={numberFormat} />
<PodiumStep agent={third} rank={3} metricKey={metricKey} numberFormat={numberFormat} />
</div>
);
};
@@ -262,7 +300,6 @@ const LeaderboardPage = () => {
volume: { suffix: ' Deals' },
winRate: { suffix: '%', decimals: 1 },
}[metric]}
accentColor="border-yellow-400"
/>
</div>
@@ -357,7 +394,6 @@ const LeaderboardPage = () => {
total: {},
convRate: { suffix: '%', decimals: 1 },
}[cvMetric]}
accentColor="border-emerald-400"
/>
</div>
+70 -54
View File
@@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { User, Briefcase, Lock, ArrowRight, AlertCircle, Eye, EyeOff } from 'lucide-react';
import { User, Briefcase, Lock, ArrowRight, AlertCircle, Eye, EyeOff, Zap } from 'lucide-react';
import { SpotlightCard } from '../components/SpotlightCard';
import Logo from '../assets/images/LynkedUp_Pro_F_logo_Y.png';
@@ -178,7 +178,7 @@ const Login = () => {
};
return (
<div className="min-h-screen bg-zinc-50 dark:bg-[#050505] flex items-center justify-center p-4 relative overflow-hidden selection:bg-blue-500/20 dark:selection:bg-white/20 transition-colors duration-300">
<div className="min-h-screen bg-zinc-50 dark:bg-[#050505] flex items-center justify-center p-4 py-8 sm:py-12 relative overflow-x-hidden selection:bg-blue-500/20 dark:selection:bg-white/20 transition-colors duration-300">
{/* Subtle Ambient Background */}
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-[0.03] dark:opacity-5 pointer-events-none mix-blend-overlay"></div>
<div className="absolute top-0 left-0 w-full h-full overflow-hidden z-0 pointer-events-none">
@@ -215,19 +215,73 @@ const Login = () => {
}
`}</style>
<div className="w-full max-w-lg z-10 relative">
<SpotlightCard className="bg-white/60 dark:bg-black/30 backdrop-blur-3xl border-0 ring-1 ring-black/5 dark:ring-white/5 shadow-2xl dark:shadow-2xl">
<div className="p-6 md:p-10"> {/* ADJUSTED PADDING FOR MOBILE */}
<div className="text-center mb-8 md:mb-10 pt-2">
<img src={Logo} alt="LynkedUp Pro" className="w-20 md:w-24 h-20 md:h-24 mx-auto mb-4 md:mb-6 object-contain drop-shadow-2xl" />
<h1 className="text-2xl md:text-3xl font-black text-zinc-900 dark:text-white mb-2 tracking-tighter">
<div className="w-full max-w-md lg:max-w-4xl z-10 relative">
<SpotlightCard className="bg-white/60 dark:bg-black/30 backdrop-blur-3xl border-0 ring-1 ring-black/5 dark:ring-white/5 shadow-2xl dark:shadow-2xl overflow-hidden">
{/* Header — full width across both panels */}
<div className="text-center px-5 sm:px-6 md:px-10 pt-4 sm:pt-5 md:pt-6 pb-1">
<img src={Logo} alt="LynkedUp Pro" className="w-24 sm:w-28 md:w-32 h-24 sm:h-28 md:h-32 mx-auto mb-0.5 sm:mb-1 object-contain drop-shadow-2xl" />
<h1 className="text-xl sm:text-2xl md:text-3xl font-black text-zinc-900 dark:text-white mb-1 tracking-tighter">
Welcome Back
</h1>
<p className="text-zinc-500 font-medium text-base md:text-lg">Sign in to your LynkedUp Pro account</p>
<p className="text-zinc-500 font-medium text-sm sm:text-base">Sign in to your LynkedUp Pro account</p>
</div>
{/* Role Tabs — 3-col grid on mobile, 6-col on desktop */}
<div className="grid grid-cols-3 md:grid-cols-6 gap-1.5 p-1.5 bg-zinc-100 dark:bg-black/40 rounded-xl mb-8 md:mb-10 border border-zinc-200 dark:border-white/5">
{/* Two-panel body: demo access + manual sign-in.
Mobile stacked, demo access shown FIRST (above the form).
Desktop (lg) side-by-side, sign-in left / demo access right. */}
<div className="flex flex-col lg:flex-row">
{/* Quick Demo Access — order-first on mobile so links are instantly visible */}
<div className="order-1 lg:order-2 lg:w-1/2 p-5 sm:p-6 md:p-8 mt-4 lg:mt-0 border-t lg:border-t-0 lg:border-l border-zinc-200 dark:border-white/5 bg-zinc-50/60 dark:bg-white/[0.02]">
<div className="flex items-center gap-2 mb-1">
<Zap size={15} className="text-amber-500 shrink-0" />
<p className="text-[11px] text-zinc-700 dark:text-zinc-200 uppercase tracking-widest font-bold">Quick Demo Access</p>
</div>
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 mb-4">Pick a role to sign in instantly no password needed.</p>
<div className="grid grid-cols-2 gap-2">
{Object.keys(DEMO_ACCOUNTS).map((roleKey) => {
const styles = DEMO_ROLE_STYLES[roleKey] || DEMO_ROLE_STYLES.customer;
const isExpanded = expandedDemoRole === roleKey;
const isSingle = DEMO_ACCOUNTS[roleKey].length === 1;
return (
<button
key={roleKey}
onClick={() => toggleDemoRole(roleKey)}
className={`flex items-center justify-center gap-1 text-[11px] font-bold uppercase tracking-wider border px-3 py-2.5 rounded-lg transition-all duration-200 ${isExpanded ? styles.active : styles.chip}`}
>
{DEMO_ROLE_LABELS[roleKey]}
{!isSingle && (
<span className={`transition-transform duration-200 inline-block ${isExpanded ? 'rotate-180' : ''}`}></span>
)}
</button>
);
})}
</div>
{/* Person picker — expands below chips when a multi-person role is selected */}
{expandedDemoRole && DEMO_ACCOUNTS[expandedDemoRole] && (
<div className="mt-2 p-2.5 bg-white dark:bg-zinc-900/60 border border-zinc-200 dark:border-white/5 rounded-xl flex flex-col gap-1">
{DEMO_ACCOUNTS[expandedDemoRole].map((account) => (
<button
key={account.id}
onClick={() => handleDemoLogin(account)}
className="w-full text-left text-[11px] font-semibold text-zinc-700 dark:text-zinc-300 px-3 py-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-white transition-colors flex items-center justify-between group"
>
<span>{account.name}</span>
<span className="text-zinc-400 dark:text-zinc-600 text-[10px] font-mono group-hover:text-zinc-500 dark:group-hover:text-zinc-400 transition-colors">{account.id}</span>
</button>
))}
</div>
)}
</div>
{/* Manual sign-in */}
<div className="order-2 lg:order-1 lg:w-1/2 p-5 sm:p-6 md:p-8 border-t lg:border-t-0 border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2 mb-1">
<Lock size={15} className="text-zinc-400 dark:text-zinc-500 shrink-0" />
<p className="text-[11px] text-zinc-700 dark:text-zinc-200 uppercase tracking-widest font-bold">Sign in with your account</p>
</div>
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 mb-4">Enter your username and password below.</p>
{/* Role Tabs — 3-col grid (temporarily disabled) */}
{/* <div className="grid grid-cols-3 gap-1.5 p-1.5 bg-zinc-100 dark:bg-black/40 rounded-xl mb-5 sm:mb-6 border border-zinc-200 dark:border-white/5">
{[
{ key: 'customer', label: 'Customer' },
{ key: 'employee', label: 'Employee' },
@@ -246,9 +300,9 @@ const Login = () => {
{label}
</button>
))}
</div>
</div> */}
<form onSubmit={handleLogin} className="space-y-6 md:space-y-8">
<form onSubmit={handleLogin} className="space-y-4 sm:space-y-5">
<div className="space-y-2">
<label htmlFor="identifier" className="text-xs font-bold text-zinc-500 ml-1 uppercase tracking-widest">
{loginType === 'employee' ? 'Employee ID' : 'Username'}
@@ -263,7 +317,7 @@ const Login = () => {
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
className="w-full bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-900 dark:text-white rounded-xl py-4 pl-12 pr-4 focus:outline-none focus:ring-1 focus:ring-black/10 dark:focus:ring-white/20 focus:bg-white dark:focus:bg-zinc-900 transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-700 font-medium relative z-0"
className="w-full bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-900 dark:text-white rounded-xl py-2.5 sm:py-3 pl-12 pr-4 focus:outline-none focus:ring-1 focus:ring-black/10 dark:focus:ring-white/20 focus:bg-white dark:focus:bg-zinc-900 transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-700 font-medium relative z-0"
placeholder={loginType === 'employee' ? "e.g., LUP-1040" : "e.g., username"}
/>
</div>
@@ -286,7 +340,7 @@ const Login = () => {
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-900 dark:text-white rounded-xl py-4 pl-12 pr-12 focus:outline-none focus:ring-1 focus:ring-black/10 dark:focus:ring-white/20 focus:bg-white dark:focus:bg-zinc-900 transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-700 font-medium relative z-0"
className="w-full bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-white/5 text-zinc-900 dark:text-white rounded-xl py-2.5 sm:py-3 pl-12 pr-12 focus:outline-none focus:ring-1 focus:ring-black/10 dark:focus:ring-white/20 focus:bg-white dark:focus:bg-zinc-900 transition-all placeholder:text-zinc-400 dark:placeholder:text-zinc-700 font-medium relative z-0"
placeholder="Enter your password"
/>
<button
@@ -306,49 +360,11 @@ const Login = () => {
</div>
)}
<RainbowButton type="submit" className="mt-4 text-white py-4 md:py-5 text-base md:text-lg">
<RainbowButton type="submit" className="mt-2 text-white py-3.5 sm:py-4 text-base md:text-lg">
<span>Sign In</span>
<ArrowRight size={20} />
</RainbowButton>
</form>
{/* Demo Quick Links — per-role person picker */}
<div className="mt-8 md:mt-10 pt-6 md:pt-8 border-t border-zinc-200 dark:border-white/5">
<p className="text-[10px] text-center text-zinc-500 uppercase tracking-widest font-bold mb-4">Quick Demo Access</p>
<div className="flex flex-wrap gap-2 justify-center mb-3">
{Object.keys(DEMO_ACCOUNTS).map((roleKey) => {
const styles = DEMO_ROLE_STYLES[roleKey] || DEMO_ROLE_STYLES.customer;
const isExpanded = expandedDemoRole === roleKey;
const isSingle = DEMO_ACCOUNTS[roleKey].length === 1;
return (
<button
key={roleKey}
onClick={() => toggleDemoRole(roleKey)}
className={`text-[10px] font-bold uppercase tracking-wider border px-3 py-2 rounded-lg transition-all duration-200 ${isExpanded ? styles.active : styles.chip}`}
>
{DEMO_ROLE_LABELS[roleKey]}
{!isSingle && (
<span className={`ml-1 transition-transform duration-200 inline-block ${isExpanded ? 'rotate-180' : ''}`}></span>
)}
</button>
);
})}
</div>
{/* Person picker — expands below chips when a multi-person role is selected */}
{expandedDemoRole && DEMO_ACCOUNTS[expandedDemoRole] && (
<div className="mt-2 p-2.5 bg-zinc-50 dark:bg-zinc-900/60 border border-zinc-200 dark:border-white/5 rounded-xl flex flex-col gap-1">
{DEMO_ACCOUNTS[expandedDemoRole].map((account) => (
<button
key={account.id}
onClick={() => handleDemoLogin(account)}
className="w-full text-left text-[11px] font-semibold text-zinc-700 dark:text-zinc-300 px-3 py-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-white transition-colors flex items-center justify-between group"
>
<span>{account.name}</span>
<span className="text-zinc-400 dark:text-zinc-600 text-[10px] font-mono group-hover:text-zinc-500 dark:group-hover:text-zinc-400 transition-colors">{account.id}</span>
</button>
))}
</div>
)}
</div>
</div>
</SpotlightCard>
+115 -5
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2 } from 'lucide-react';
import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2, Navigation, Trophy, Gauge, Sparkles, X, Info } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { toast } from 'sonner';
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
@@ -137,6 +137,87 @@ const CollapsePanel = ({ open, children }) => (
</AnimatePresence>
);
// ---------------------------------------------------------------------------
// AI Dispatch Explainer popover anchored to the "% AI-Dispatched" stat.
// Turns the standout number into a trust signal: shows WHY the engine picks a
// rep (drive time, win rate, capacity, ) instead of leaving owners guessing.
// ---------------------------------------------------------------------------
const DISPATCH_FACTORS = [
{ key: 'driveTime', icon: Navigation, label: 'Drive Time', weight: 30, desc: 'Closest rep with the shortest ETA — live route & traffic.' },
{ key: 'winRate', icon: Trophy, label: 'Win Rate', weight: 25, desc: 'Reps who close this lead type more often rank higher.' },
{ key: 'capacity', icon: Gauge, label: 'Capacity', weight: 20, desc: "Balances today's slots so no rep gets overbooked." },
{ key: 'skill', icon: Sparkles, label: 'Skill Match', weight: 15, desc: 'Certifications & job-type expertise for this lead.' },
{ key: 'weather', icon: CloudLightning, label: 'Weather', weight: 10, desc: 'Storm & hail safety windows adjust routing in Storm Mode.' },
];
const MAX_FACTOR_WEIGHT = Math.max(...DISPATCH_FACTORS.map(f => f.weight));
const AIDispatchExplainer = ({ efficiencyPct, aiCount, totalCount, accent, onClose }) => {
const overrides = totalCount - aiCount;
return (
<motion.div
initial={{ opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.97 }}
transition={{ duration: 0.16 }}
onClick={(e) => e.stopPropagation()}
className="absolute top-full left-0 mt-2 w-[320px] max-w-[88vw] z-[9999] rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 shadow-2xl shadow-black/10 dark:shadow-black/50 overflow-hidden"
>
{/* Header */}
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06]" style={{ borderTopWidth: 2, borderTopColor: accent }}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Bot size={14} style={{ color: accent }} />
<h3 className="text-xs font-black uppercase tracking-widest text-zinc-900 dark:text-white">Why AI-Dispatched</h3>
</div>
<button onClick={onClose} aria-label="Close" className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">
<X size={13} />
</button>
</div>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-1.5 leading-relaxed">
<span className="font-bold" style={{ color: accent }}>{efficiencyPct}%</span> of today's {totalCount} dispatches took the AI's top pick
{' · '}{overrides} manual override{overrides === 1 ? '' : 's'}.
</p>
</div>
{/* Factor weights */}
<div className="p-3 space-y-2.5">
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500">How a rep is chosen</p>
{DISPATCH_FACTORS.map((f, i) => (
<div key={f.key} className="flex items-start gap-2.5">
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5" style={{ backgroundColor: `${accent}14`, color: accent }}>
<f.icon size={13} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-bold text-zinc-800 dark:text-zinc-100">{f.label}</span>
<span className="text-[10px] font-mono font-bold text-zinc-400">{f.weight}%</span>
</div>
<div className="mt-1 h-1 rounded-full bg-zinc-100 dark:bg-zinc-700/50 overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: accent }}
initial={{ width: 0 }}
animate={{ width: `${(f.weight / MAX_FACTOR_WEIGHT) * 100}%` }}
transition={{ duration: 0.5, ease: 'easeOut', delay: 0.06 + i * 0.05 }}
/>
</div>
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-1 leading-snug">{f.desc}</p>
</div>
</div>
))}
</div>
{/* Footer */}
<div className="px-4 py-2.5 border-t border-zinc-100 dark:border-white/[0.06] bg-zinc-50 dark:bg-white/[0.02]">
<p className="text-[10px] text-zinc-400 leading-snug flex items-center gap-1.5">
<CheckCircle size={11} className="text-emerald-500 shrink-0" />
Every pick is logged &amp; fully overridable you stay in control.
</p>
</div>
</motion.div>
);
};
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
@@ -151,6 +232,18 @@ const LynkDispatchPage = () => {
const [chartModalOpen, setChartModalOpen] = useState(false);
const [filterHailOnly, setFilterHailOnly] = useState(false);
// "% AI-Dispatched" explainability popover
const [aiExplainerOpen, setAiExplainerOpen] = useState(false);
const explainerRef = useRef(null);
useEffect(() => {
if (!aiExplainerOpen) return;
const onClick = (e) => { if (explainerRef.current && !explainerRef.current.contains(e.target)) setAiExplainerOpen(false); };
const onKey = (e) => { if (e.key === 'Escape') setAiExplainerOpen(false); };
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey); };
}, [aiExplainerOpen]);
// Hail Recon load summaries for all leads in queue
const { summaries: hailSummaries, loading: hailLoading } = useHailSummaries(dispatchLeads);
@@ -372,19 +465,36 @@ const LynkDispatchPage = () => {
</AnimatePresence>
<AnimatePresence>
{totalCount > 0 && (
<motion.span
<div key="efficiency-wrap" className="relative" ref={explainerRef}>
<motion.button
key="efficiency"
type="button"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
title={`${efficiencyPct}% of dispatches used AI recommendation — ${100 - efficiencyPct}% were manual overrides`}
className="flex items-center gap-1 text-[10px] font-black cursor-default whitespace-nowrap"
onClick={() => setAiExplainerOpen(o => !o)}
aria-expanded={aiExplainerOpen}
title="See why the AI dispatched"
className="flex items-center gap-1 text-[10px] font-black cursor-pointer whitespace-nowrap rounded-full px-1.5 py-0.5 -ml-1.5 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
style={{ color: stormMode ? '#F59E0B' : '#10B981' }}
>
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: stormMode ? '#F59E0B' : '#10B981' }} />
{efficiencyPct}% AI-Dispatched
</motion.span>
<Info size={9} className="opacity-70" />
</motion.button>
<AnimatePresence>
{aiExplainerOpen && (
<AIDispatchExplainer
efficiencyPct={efficiencyPct}
aiCount={aiCount}
totalCount={totalCount}
accent={stormMode ? '#F59E0B' : '#10B981'}
onClose={() => setAiExplainerOpen(false)}
/>
)}
</AnimatePresence>
</div>
)}
</AnimatePresence>
</div>
+11 -50
View File
@@ -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 (
<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}
</span>
);
@@ -77,26 +72,12 @@ const MapLegend = () => {
<button onClick={() => setIsOpen(false)} className="md:hidden ml-4"><X size={14} /></button>
</h4>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-red-500 shadow-sm shadow-red-500/50"></div>
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">Hot Lead</span>
</div>
<div className="flex items-center space-x-2">
<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>
{CANVASSING_STATUS_ORDER.map(status => (
<div key={status} className="flex items-center space-x-2">
<div className={`w-3 h-3 rounded-full shadow-sm ring-1 ring-black/5 dark:ring-white/10 ${CANVASSING_STATUS[status].dot}`}></div>
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-300">{status}</span>
</div>
))}
</div>
</div>
</>
@@ -150,29 +131,9 @@ const MapView = ({ data, onSelect, onMapCreate }) => {
<MapInteraction onMapClick={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 (
<Polygon
+32
View File
@@ -47,6 +47,32 @@ function getPipelineHealth(lead) {
return 32;
}
// ---------- Metric legend ----------
// Explains the Variance color convention (under vs over budget) and the Health
// score thresholds so an owner can read both columns at a glance.
const LegendDot = ({ className }) => (
<span className={`inline-block w-2 h-2 rounded-full ${className}`} />
);
const MetricLegend = ({ showVariance = true }) => (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-3 border-b border-zinc-200 dark:border-white/10 bg-zinc-50/60 dark:bg-white/[0.02] text-[11px] text-zinc-600 dark:text-zinc-400">
{showVariance && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">Variance</span>
<span className="flex items-center gap-1.5"><LegendDot className="bg-emerald-500" /> Under budget ()</span>
<span className="flex items-center gap-1.5"><LegendDot className="bg-red-500" /> Over budget (+)</span>
<span className="flex items-center gap-1.5"><LegendDot className="bg-zinc-400" /> On budget (0%)</span>
</div>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">Health</span>
<span className="flex items-center gap-1.5"><LegendDot className="bg-emerald-500" /> Good 70100</span>
<span className="flex items-center gap-1.5"><LegendDot className="bg-amber-500" /> At Risk 4069</span>
<span className="flex items-center gap-1.5"><LegendDot className="bg-red-500" /> Critical 039</span>
</div>
</div>
);
const OwnerProjectList = () => {
const { user } = useAuth();
const { projects, vendors, kanbanLeads, kanbanColumns } = useMockStore();
@@ -269,6 +295,9 @@ const OwnerProjectList = () => {
{/* Project Table (Desktop) / Cards (Mobile) */}
<SpotlightCard className="overflow-hidden">
{/* Legend — explains Variance colors & Health thresholds */}
<MetricLegend showVariance />
{/* Mobile Card View */}
<div className="md:hidden divide-y divide-zinc-100 dark:divide-white/5">
{sortedProjects.length > 0 ? sortedProjects.map(project => {
@@ -484,6 +513,9 @@ const OwnerProjectList = () => {
{/* Pipeline table */}
<SpotlightCard className="overflow-hidden">
{/* Legend — Health thresholds (no Variance column in pipeline) */}
<MetricLegend showVariance={false} />
{/* Mobile card view */}
<div className="md:hidden divide-y divide-zinc-100 dark:divide-white/5">
{filteredPipelineLeads.length > 0 ? filteredPipelineLeads.map(lead => {