feat(kanban): Project Pipeline board with drag-and-drop, lead drawer, and full project view
- 44 mock leads across 7 stage columns + 2 bucket columns (Stuck, Follow-up) with seeded activity logs - dnd-kit drag-and-drop: cross-column card movement, bucket logic, DragOverlay preview - Campaign toast on stage drop: 20s countdown, Edit button opens message editor, auto-sends if ignored - KanbanColumn: droppable zones, stage progress bar (position/total), column color accents, Add/Rename/Delete (Admin+Owner) - KanbanCard: initials avatar, job type pill, insurance badge, assigned agent, days-in-stage counter, left color accent bar - ColumnManagerModal: add/rename/delete stages with name, color picker, campaign message; delete blocked if leads exist - LeadInfoDrawer: right slide-in with progress bar + stage steps, Details and Activity tabs, More Details CTA - ActivityTimeline: color-coded entries (created/forward/regression/bucket), stage arrows, notes, agent + date - LeadProjectPage: full project view at /*/leads/:leadId — progress bar, stage mover dropdown, info panels, full activity log, stats - Routes: /*/kanban + /*/leads/:leadId for Owner, Admin, Field Agent - Pipeline nav item added for all three roles
This commit is contained in:
@@ -5,7 +5,7 @@ import { useTheme } from '../context/ThemeContext';
|
||||
import {
|
||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap
|
||||
FileText, Menu, X, Calculator, PlusCircle, ClipboardList, Zap, LayoutGrid
|
||||
} from 'lucide-react';
|
||||
|
||||
import PageTransition from './PageTransition';
|
||||
@@ -138,6 +138,7 @@ const Layout = () => {
|
||||
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||
{ to: "/owner/projects", icon: Briefcase, label: "Projects" },
|
||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||
{ to: "/owner/kanban", icon: LayoutGrid, label: "Pipeline" },
|
||||
{ to: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
|
||||
{ to: "/owner/maps", icon: Map, label: "Territory Map" },
|
||||
{
|
||||
@@ -156,6 +157,7 @@ const Layout = () => {
|
||||
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
|
||||
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||
{ to: "/admin/kanban", icon: LayoutGrid, label: "Pipeline" },
|
||||
{ to: "/admin/dispatch", icon: Zap, label: "LynkDispatch" },
|
||||
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
|
||||
{
|
||||
@@ -184,6 +186,7 @@ const Layout = () => {
|
||||
{ to: "/emp/fa/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||
{ to: "/emp/fa/maps", icon: Map, label: "My Map" },
|
||||
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||
{ to: "/emp/fa/kanban", icon: LayoutGrid, label: "Pipeline" },
|
||||
{
|
||||
to: "/emp/fa/pro-canvas",
|
||||
icon: LayoutDashboard,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Layers, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#3B82F6','#6366F1','#8B5CF6','#EC4899',
|
||||
'#F59E0B','#10B981','#06B6D4','#22C55E',
|
||||
'#EF4444','#F97316','#14B8A6','#A855F7',
|
||||
];
|
||||
|
||||
export default function ColumnManagerModal({ mode, column, leadsInColumn, onSave, onClose }) {
|
||||
const isDelete = mode === 'delete';
|
||||
const isRename = mode === 'rename';
|
||||
const isAdd = mode === 'add';
|
||||
|
||||
const [name, setName] = useState(column?.name ?? '');
|
||||
const [color, setColor] = useState(column?.color ?? '#3B82F6');
|
||||
const [campaign, setCampaign] = useState(column?.campaignMessage ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
setName(column?.name ?? '');
|
||||
setColor(column?.color ?? '#3B82F6');
|
||||
setCampaign(column?.campaignMessage ?? '');
|
||||
}, [column, mode]);
|
||||
|
||||
const canDelete = leadsInColumn === 0;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isDelete) { onSave(); return; }
|
||||
if (!name.trim()) return;
|
||||
onSave({ name: name.trim(), color, campaignMessage: campaign.trim() || null });
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-md"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isDelete
|
||||
? <AlertTriangle size={16} className="text-red-500" />
|
||||
: <Layers size={16} className="text-blue-500" />
|
||||
}
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white text-sm">
|
||||
{isAdd ? 'Add Stage' : isRename ? 'Rename Stage' : 'Delete Stage'}
|
||||
</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-4">
|
||||
{isDelete ? (
|
||||
<>
|
||||
{canDelete ? (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||
Are you sure you want to delete <strong className="text-zinc-800 dark:text-zinc-100">"{column?.name}"</strong>? This action cannot be undone.
|
||||
</p>
|
||||
) : (
|
||||
<div className="bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 rounded-xl px-4 py-3">
|
||||
<p className="text-sm font-semibold text-amber-700 dark:text-amber-400 mb-1">Cannot Delete</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
<strong>{leadsInColumn}</strong> {leadsInColumn === 1 ? 'lead is' : 'leads are'} currently in <strong>"{column?.name}"</strong>. Move them to another column first.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 mb-1.5 block">Stage Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Site Survey"
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 mb-1.5 block">Color</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className="w-7 h-7 rounded-full transition-transform hover:scale-110 focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
ring: color === c ? `3px solid ${c}` : undefined,
|
||||
outline: color === c ? `2px solid ${c}` : '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign message */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 mb-1.5 block">
|
||||
Campaign Message <span className="font-normal text-zinc-400">(optional — sent when card drops here)</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={campaign}
|
||||
onChange={e => setCampaign(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Hi {name}, just a quick update..."
|
||||
className="w-full px-3 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow resize-none"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 px-5 py-4 border-t border-zinc-100 dark:border-white/[0.07]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 text-sm font-semibold text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{(!isDelete || canDelete) && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isDelete && !name.trim()}
|
||||
className={`flex-1 px-4 py-2 rounded-xl text-sm font-semibold transition-colors ${
|
||||
isDelete
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isAdd ? 'Add Stage' : isRename ? 'Save' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { MapPin, User, Clock } from 'lucide-react';
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function getDaysInStage(activity) {
|
||||
if (!activity?.length) return 0;
|
||||
const last = activity[0];
|
||||
const lastDate = new Date(last.date);
|
||||
const now = new Date('2026-04-07');
|
||||
return Math.max(0, Math.floor((now - lastDate) / 86400000));
|
||||
}
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300',
|
||||
'bg-violet-100 dark:bg-violet-500/20 text-violet-700 dark:text-violet-300',
|
||||
'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300',
|
||||
'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300',
|
||||
'bg-rose-100 dark:bg-rose-500/20 text-rose-700 dark:text-rose-300',
|
||||
'bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-300',
|
||||
];
|
||||
|
||||
function avatarColor(name) {
|
||||
const idx = (name || '').charCodeAt(0) % AVATAR_COLORS.length;
|
||||
return AVATAR_COLORS[idx];
|
||||
}
|
||||
|
||||
export default function KanbanCard({ lead, columnColor, onClick, isDragOverlay }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: lead.id,
|
||||
data: { lead },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
opacity: isDragging ? 0.35 : 1,
|
||||
zIndex: isDragging ? 999 : 'auto',
|
||||
};
|
||||
|
||||
const days = getDaysInStage(lead.activity);
|
||||
const initials = getInitials(lead.name);
|
||||
const avatarCls = avatarColor(lead.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isDragOverlay ? undefined : setNodeRef}
|
||||
style={isDragOverlay ? undefined : style}
|
||||
{...(isDragOverlay ? {} : { ...attributes, ...listeners })}
|
||||
onClick={isDragOverlay ? undefined : onClick}
|
||||
className={`group relative bg-white dark:bg-zinc-900 border rounded-xl cursor-grab active:cursor-grabbing transition-all select-none
|
||||
${isDragOverlay
|
||||
? 'shadow-2xl border-zinc-200 dark:border-white/20 rotate-1 scale-[1.02]'
|
||||
: 'border-zinc-200 dark:border-white/[0.07] hover:shadow-md dark:hover:shadow-black/30 hover:border-zinc-300 dark:hover:border-white/[0.14]'
|
||||
}`}
|
||||
>
|
||||
{/* Left color accent bar */}
|
||||
<div
|
||||
className="absolute left-0 top-3 bottom-3 w-0.5 rounded-full"
|
||||
style={{ backgroundColor: columnColor }}
|
||||
/>
|
||||
|
||||
<div className="pl-4 pr-3 py-3">
|
||||
{/* Name row */}
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-zinc-900 dark:text-white leading-tight truncate">{lead.name}</p>
|
||||
{lead.address && (
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 truncate mt-0.5 flex items-center gap-1">
|
||||
<MapPin size={9} className="shrink-0" />
|
||||
{lead.address.split(',')[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job type pill */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: columnColor + '18', color: columnColor }}>
|
||||
{lead.jobType}
|
||||
</span>
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
|
||||
lead.insuranceType === 'Insurance'
|
||||
? 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400'
|
||||
}`}>
|
||||
{lead.insuranceType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Footer row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<User size={10} className="shrink-0 text-zinc-400" />
|
||||
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 truncate">
|
||||
{lead.assignedAgentName ?? 'Unassigned'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Clock size={10} className="text-zinc-400" />
|
||||
<span className="text-[11px] text-zinc-400 dark:text-zinc-500">{days}d</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Plus, Settings, Trash2, Pencil } from 'lucide-react';
|
||||
import KanbanCard from './KanbanCard';
|
||||
|
||||
export default function KanbanColumn({
|
||||
column, leads, stageColumns, canManage,
|
||||
onCardClick, onAddColumn, onRenameColumn, onDeleteColumn,
|
||||
isLast,
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: column.id });
|
||||
|
||||
const stageCount = stageColumns.filter(c => c.type === 'stage').length;
|
||||
const stageIdx = stageColumns.filter(c => c.type === 'stage').findIndex(c => c.id === column.id);
|
||||
const isBucket = column.type === 'bucket';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col shrink-0 w-[272px]">
|
||||
{/* Column header */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between gap-2 px-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: column.color }} />
|
||||
<h3
|
||||
className="text-xs font-black uppercase tracking-widest truncate text-zinc-700 dark:text-zinc-200"
|
||||
style={{ fontFamily: 'Barlow Condensed, sans-serif', letterSpacing: '0.08em' }}
|
||||
>
|
||||
{column.name}
|
||||
</h3>
|
||||
<span
|
||||
className="text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: column.color + '18', color: column.color }}
|
||||
>
|
||||
{leads.length}
|
||||
</span>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
|
||||
<button
|
||||
onClick={() => onRenameColumn(column)}
|
||||
className="p-1 rounded-md text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteColumn(column)}
|
||||
className="p-1 rounded-md text-zinc-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage progress bar — only for stage columns */}
|
||||
{!isBucket && stageIdx >= 0 && (
|
||||
<div className="mt-2 px-1">
|
||||
<div className="h-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${((stageIdx + 1) / stageCount) * 100}%`,
|
||||
backgroundColor: column.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[9px] text-zinc-400 mt-0.5 text-right">
|
||||
{stageIdx + 1}/{stageCount}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`group flex-1 min-h-[120px] rounded-2xl p-2 transition-all duration-150 ${
|
||||
isOver
|
||||
? 'bg-blue-50/80 dark:bg-blue-500/10 ring-2 ring-inset ring-blue-400/40'
|
||||
: 'bg-zinc-100/60 dark:bg-zinc-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{leads.map((lead, i) => (
|
||||
<motion.div
|
||||
key={lead.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.15, delay: i * 0.03 }}
|
||||
>
|
||||
<KanbanCard
|
||||
lead={lead}
|
||||
columnColor={column.color}
|
||||
onClick={() => onCardClick(lead)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{leads.length === 0 && (
|
||||
<div className="flex items-center justify-center h-16 rounded-xl border-2 border-dashed border-zinc-200 dark:border-zinc-700/60">
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-600 font-medium">Drop here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add column button after last stage column */}
|
||||
{isLast && canManage && (
|
||||
<button
|
||||
onClick={onAddColumn}
|
||||
className="mt-3 flex items-center gap-1.5 px-3 py-2 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:border-zinc-400 dark:hover:border-zinc-500 text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Plus size={12} /> Add Stage
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import {
|
||||
X, Phone, Mail, MapPin, Briefcase, Shield, User,
|
||||
Clock, ArrowRight, ExternalLink, ChevronRight, FileText,
|
||||
CheckCircle, ArrowUpRight, ArrowDownLeft, MessageSquare
|
||||
} from 'lucide-react';
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '';
|
||||
const dt = new Date(d + 'T00:00:00');
|
||||
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function ProgressBar({ lead, columns }) {
|
||||
const stageColumns = columns.filter(c => c.type === 'stage');
|
||||
const currentStageId = lead.columnId;
|
||||
const stageIdx = stageColumns.findIndex(c => c.id === currentStageId);
|
||||
const totalStages = stageColumns.length;
|
||||
const pct = totalStages > 0 ? Math.round(((stageIdx + 1) / totalStages) * 100) : 0;
|
||||
const currentCol = stageColumns[stageIdx];
|
||||
const bucketCol = lead.bucketId ? columns.find(c => c.id === lead.bucketId) : null;
|
||||
|
||||
return (
|
||||
<div className="px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{currentCol && (
|
||||
<span className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: currentCol.color + '18', color: currentCol.color }}>
|
||||
{currentCol.name}
|
||||
</span>
|
||||
)}
|
||||
{bucketCol && (
|
||||
<span className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: bucketCol.color + '18', color: bucketCol.color }}>
|
||||
{bucketCol.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-bold text-zinc-500 dark:text-zinc-400">{pct}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: currentCol?.color ?? '#3B82F6' }}
|
||||
/>
|
||||
</div>
|
||||
{/* Stage steps */}
|
||||
<div className="flex items-center gap-0 mt-2 overflow-x-auto scrollbar-hide">
|
||||
{stageColumns.map((col, i) => (
|
||||
<React.Fragment key={col.id}>
|
||||
<div className={`shrink-0 flex items-center gap-1 text-[9px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
||||
i <= stageIdx ? 'text-zinc-700 dark:text-zinc-200' : 'text-zinc-300 dark:text-zinc-600'
|
||||
}`}>
|
||||
{i <= stageIdx && (
|
||||
<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
|
||||
)}
|
||||
{col.name}
|
||||
</div>
|
||||
{i < stageColumns.length - 1 && (
|
||||
<ChevronRight size={9} className="shrink-0 text-zinc-300 dark:text-zinc-700 mx-0.5" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityTimeline({ activity }) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{(activity || []).map((entry, i) => {
|
||||
const isRegression = entry.from && entry.to &&
|
||||
entry.to === 'Contacted' && entry.from !== null;
|
||||
const isFirst = !entry.from;
|
||||
const isBucket = entry.to === 'Stuck' || entry.to === 'Follow-up';
|
||||
|
||||
return (
|
||||
<div key={i} className="flex gap-3">
|
||||
{/* Timeline line */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center shrink-0 mt-1 ${
|
||||
isFirst ? 'bg-blue-100 dark:bg-blue-500/20' :
|
||||
isBucket ? 'bg-amber-100 dark:bg-amber-500/20' :
|
||||
isRegression ? 'bg-orange-100 dark:bg-orange-500/20' :
|
||||
'bg-emerald-100 dark:bg-emerald-500/20'
|
||||
}`}>
|
||||
{isFirst ? <ArrowRight size={9} className="text-blue-600 dark:text-blue-400" /> :
|
||||
isBucket ? <MessageSquare size={9} className="text-amber-600 dark:text-amber-400" /> :
|
||||
isRegression ? <ArrowDownLeft size={9} className="text-orange-600 dark:text-orange-400" /> :
|
||||
<ArrowUpRight size={9} className="text-emerald-600 dark:text-emerald-400" />}
|
||||
</div>
|
||||
{i < activity.length - 1 && (
|
||||
<div className="w-px flex-1 bg-zinc-100 dark:bg-zinc-800 my-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="pb-4 flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
{entry.from ? (
|
||||
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<span className={isRegression ? 'text-orange-600 dark:text-orange-400' : 'text-zinc-500 dark:text-zinc-400'}>
|
||||
{entry.from}
|
||||
</span>
|
||||
<ArrowRight size={10} className="inline mx-1 text-zinc-400" />
|
||||
<span style={{ color: isBucket ? '#F59E0B' : undefined }}>
|
||||
{entry.to}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs font-semibold text-blue-600 dark:text-blue-400">Lead Created</p>
|
||||
)}
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">by {entry.by} · {formatDate(entry.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{entry.note && (
|
||||
<p className="mt-1.5 text-[11px] text-zinc-500 dark:text-zinc-400 leading-relaxed bg-zinc-50 dark:bg-zinc-800/60 px-2.5 py-2 rounded-lg border border-zinc-100 dark:border-white/[0.05]">
|
||||
{entry.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeadInfoDrawer({ lead, columns, isOpen, onClose }) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [tab, setTab] = useState('details');
|
||||
|
||||
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
|
||||
|
||||
if (!lead) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[70] bg-black/30 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 38 }}
|
||||
className="fixed right-0 top-0 bottom-0 z-[71] w-full sm:w-[460px] bg-white dark:bg-zinc-950 shadow-2xl border-l border-zinc-200 dark:border-white/[0.07] flex flex-col"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07] shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-full bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center text-sm font-bold text-blue-700 dark:text-blue-300 shrink-0">
|
||||
{getInitials(lead.name)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-bold text-zinc-900 dark:text-white text-sm truncate">{lead.name}</h2>
|
||||
<p className="text-[11px] text-zinc-400 truncate">{lead.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors shrink-0"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<ProgressBar lead={lead} columns={columns} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-5 pt-3 pb-0 border-b border-zinc-100 dark:border-white/[0.07] shrink-0">
|
||||
{['details', 'activity'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`relative pb-2.5 px-3 text-xs font-semibold capitalize transition-colors ${
|
||||
tab === t
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
{tab === t && (
|
||||
<motion.div layoutId="drawer-tab-line" className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{tab === 'details' && (
|
||||
<div className="space-y-5">
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-2">Contact</p>
|
||||
<div className="space-y-2">
|
||||
{lead.phone && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Phone size={13} className="text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200">{lead.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{lead.email && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Mail size={13} className="text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200 truncate">{lead.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{lead.address && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin size={13} className="text-zinc-400 shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200">{lead.address}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Job */}
|
||||
<section>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-2">Job Details</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl px-3 py-2.5">
|
||||
<p className="text-[10px] text-zinc-400 mb-0.5">Type</p>
|
||||
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{lead.jobType}</p>
|
||||
</div>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl px-3 py-2.5">
|
||||
<p className="text-[10px] text-zinc-400 mb-0.5">Category</p>
|
||||
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{lead.insuranceType}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Assignment */}
|
||||
<section>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-2">Assignment</p>
|
||||
<div className="flex items-center gap-2.5 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl px-3 py-2.5">
|
||||
<User size={13} className="text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-200 font-medium">
|
||||
{lead.assignedAgentName ?? <span className="text-zinc-400 italic">Unassigned</span>}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notes */}
|
||||
{lead.notes && (
|
||||
<section>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-2">Notes</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed bg-amber-50 dark:bg-amber-500/5 border border-amber-100 dark:border-amber-500/10 rounded-xl px-3 py-2.5">
|
||||
{lead.notes}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<section>
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-2">Timeline</p>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Clock size={13} className="text-zinc-400 shrink-0" />
|
||||
<span className="text-sm text-zinc-500 dark:text-zinc-400">Created {formatDate(lead.createdDate)}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'activity' && (
|
||||
<ActivityTimeline activity={lead.activity} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-zinc-100 dark:border-white/[0.07] shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(`${basePath}/leads/${lead.id}`);
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors shadow-sm"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
More Details
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user