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:
@@ -0,0 +1,347 @@
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import {
|
||||
DndContext, DragOverlay, PointerSensor, TouchSensor,
|
||||
useSensor, useSensors, closestCorners
|
||||
} from '@dnd-kit/core';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useAuth, ROLES } from '../context/AuthContext';
|
||||
import { useMockStore } from '../data/mockStore';
|
||||
import { toast } from 'sonner';
|
||||
import { LayoutGrid, Search, Filter, X, Send, Pencil } from 'lucide-react';
|
||||
|
||||
import KanbanCard from '../components/kanban/KanbanCard';
|
||||
import KanbanColumn from '../components/kanban/KanbanColumn';
|
||||
import LeadInfoDrawer from '../components/kanban/LeadInfoDrawer';
|
||||
import ColumnManagerModal from '../components/kanban/ColumnManagerModal';
|
||||
|
||||
// Campaign toast with countdown + Edit option
|
||||
function fireCampaignToast(lead, column, onEdit) {
|
||||
if (!column?.campaignMessage) return;
|
||||
const msg = column.campaignMessage.replace('{name}', lead.name.split(' ')[0]);
|
||||
|
||||
toast(
|
||||
({ id }) => {
|
||||
const [secs, setSecs] = React.useState(20);
|
||||
React.useEffect(() => {
|
||||
const t = setInterval(() => setSecs(s => {
|
||||
if (s <= 1) { clearInterval(t); toast.dismiss(id); return 0; }
|
||||
return s - 1;
|
||||
}), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Send size={13} className="text-blue-500 shrink-0" />
|
||||
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-100">Campaign Message</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-zinc-400">Sending in {secs}s</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 line-clamp-2 leading-relaxed">{msg}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { toast.dismiss(id); onEdit(msg, lead, column); }}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-blue-600 text-white text-[11px] font-semibold hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Pencil size={10} /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { toast.dismiss(id); toast.success('Campaign message sent'); }}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 text-[11px] font-semibold hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Send Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ duration: 20000, position: 'bottom-right' }
|
||||
);
|
||||
}
|
||||
|
||||
// Campaign Edit Modal
|
||||
function CampaignEditModal({ message, lead, column, onClose }) {
|
||||
const [text, setText] = useState(message);
|
||||
return (
|
||||
<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 }}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-2xl w-full max-w-md p-5"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white text-sm">Edit Campaign Message</h3>
|
||||
<p className="text-[11px] text-zinc-400 mt-0.5">To: {lead?.name} · via {column?.name}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"><X size={15} /></button>
|
||||
</div>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
rows={5}
|
||||
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 resize-none mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => { toast.success('Campaign message sent'); onClose(); }}
|
||||
className="flex-1 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KanbanPage() {
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
kanbanColumns, kanbanLeads,
|
||||
moveKanbanLead, addKanbanColumn, updateKanbanColumn, deleteKanbanColumn,
|
||||
} = useMockStore();
|
||||
|
||||
const canManage = user?.role === ROLES.OWNER || user?.role === ROLES.ADMIN;
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [draggingLead, setDraggingLead] = useState(null);
|
||||
const [selectedLead, setSelectedLead] = useState(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Column manager modal
|
||||
const [colModal, setColModal] = useState(null); // { mode: 'add'|'rename'|'delete', column?, leadsCount? }
|
||||
|
||||
// Campaign edit modal
|
||||
const [campaignEdit, setCampaignEdit] = useState(null); // { message, lead, column }
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||
);
|
||||
|
||||
const stageColumns = useMemo(() =>
|
||||
[...kanbanColumns].filter(c => c.type === 'stage').sort((a, b) => a.order - b.order),
|
||||
[kanbanColumns]);
|
||||
|
||||
const bucketColumns = useMemo(() =>
|
||||
[...kanbanColumns].filter(c => c.type === 'bucket').sort((a, b) => a.order - b.order),
|
||||
[kanbanColumns]);
|
||||
|
||||
const allColumns = useMemo(() => [...stageColumns, ...bucketColumns], [stageColumns, bucketColumns]);
|
||||
|
||||
const filteredLeads = useMemo(() => {
|
||||
if (!search.trim()) return kanbanLeads;
|
||||
const q = search.toLowerCase();
|
||||
return kanbanLeads.filter(l =>
|
||||
l.name.toLowerCase().includes(q) ||
|
||||
l.address.toLowerCase().includes(q) ||
|
||||
(l.assignedAgentName || '').toLowerCase().includes(q) ||
|
||||
l.jobType.toLowerCase().includes(q)
|
||||
);
|
||||
}, [kanbanLeads, search]);
|
||||
|
||||
const leadsForColumn = useCallback((colId) => {
|
||||
return filteredLeads.filter(l => {
|
||||
const col = kanbanColumns.find(c => c.id === colId);
|
||||
if (col?.type === 'bucket') return l.bucketId === colId;
|
||||
return l.columnId === colId && !l.bucketId;
|
||||
});
|
||||
}, [filteredLeads, kanbanColumns]);
|
||||
|
||||
const handleDragStart = ({ active }) => {
|
||||
const lead = kanbanLeads.find(l => l.id === active.id);
|
||||
setDraggingLead(lead ?? null);
|
||||
};
|
||||
|
||||
const handleDragEnd = ({ active, over }) => {
|
||||
setDraggingLead(null);
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const targetCol = kanbanColumns.find(c => c.id === over.id);
|
||||
if (!targetCol) return;
|
||||
|
||||
const lead = kanbanLeads.find(l => l.id === active.id);
|
||||
if (!lead) return;
|
||||
|
||||
const isBucket = targetCol.type === 'bucket';
|
||||
const newColumnId = isBucket ? lead.columnId : targetCol.id;
|
||||
const newBucketId = isBucket ? targetCol.id : null;
|
||||
|
||||
if (newColumnId === lead.columnId && newBucketId === lead.bucketId) return;
|
||||
|
||||
moveKanbanLead(active.id, newColumnId, newBucketId);
|
||||
|
||||
// Fire campaign toast for stage drops only
|
||||
if (!isBucket) {
|
||||
setTimeout(() => {
|
||||
fireCampaignToast(lead, targetCol, (msg, l, col) => {
|
||||
setCampaignEdit({ message: msg, lead: l, column: col });
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const openCard = (lead) => {
|
||||
setSelectedLead(lead);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
// Column manager handlers
|
||||
const handleAddColumn = () => setColModal({ mode: 'add' });
|
||||
const handleRenameColumn = (col) => setColModal({ mode: 'rename', column: col });
|
||||
const handleDeleteColumn = (col) => {
|
||||
const count = leadsForColumn(col.id).length;
|
||||
setColModal({ mode: 'delete', column: col, leadsCount: count });
|
||||
};
|
||||
|
||||
const handleColModalSave = (data) => {
|
||||
const { mode, column } = colModal;
|
||||
if (mode === 'add') {
|
||||
const maxOrder = Math.max(...stageColumns.map(c => c.order), 0);
|
||||
addKanbanColumn({ ...data, type: 'stage', order: maxOrder + 1 });
|
||||
toast.success(`"${data.name}" stage added`);
|
||||
} else if (mode === 'rename') {
|
||||
updateKanbanColumn(column.id, data);
|
||||
toast.success('Stage updated');
|
||||
} else if (mode === 'delete') {
|
||||
deleteKanbanColumn(column.id);
|
||||
toast.success(`"${column.name}" deleted`);
|
||||
}
|
||||
setColModal(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-zinc-50 dark:bg-[#09090b] ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
{/* Page header */}
|
||||
<div className="px-4 sm:px-6 py-5 border-b border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-950 sticky top-0 z-20">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center">
|
||||
<LayoutGrid size={16} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-black uppercase tracking-tight text-zinc-900 dark:text-white leading-none"
|
||||
style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
||||
Pipeline
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">{kanbanLeads.length} leads across {stageColumns.length} stages</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search leads..."
|
||||
className="w-full pl-8 pr-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900 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"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board */}
|
||||
<div className="overflow-x-auto pb-8">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-4 px-4 sm:px-6 pt-5 min-w-max">
|
||||
{/* Stage columns */}
|
||||
{stageColumns.map((col, i) => (
|
||||
<KanbanColumn
|
||||
key={col.id}
|
||||
column={col}
|
||||
leads={leadsForColumn(col.id)}
|
||||
stageColumns={stageColumns}
|
||||
canManage={canManage}
|
||||
onCardClick={openCard}
|
||||
onAddColumn={handleAddColumn}
|
||||
onRenameColumn={handleRenameColumn}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
isLast={i === stageColumns.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex flex-col gap-2 shrink-0 self-start pt-1">
|
||||
<div className="w-px h-full min-h-[200px] bg-zinc-200 dark:bg-zinc-800 mx-2" />
|
||||
</div>
|
||||
|
||||
{/* Bucket columns */}
|
||||
{bucketColumns.map((col) => (
|
||||
<KanbanColumn
|
||||
key={col.id}
|
||||
column={col}
|
||||
leads={leadsForColumn(col.id)}
|
||||
stageColumns={allColumns}
|
||||
canManage={false}
|
||||
onCardClick={openCard}
|
||||
onAddColumn={() => {}}
|
||||
onRenameColumn={() => {}}
|
||||
onDeleteColumn={() => {}}
|
||||
isLast={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drag overlay */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggingLead ? (
|
||||
<KanbanCard
|
||||
lead={draggingLead}
|
||||
columnColor={kanbanColumns.find(c => c.id === draggingLead.columnId)?.color ?? '#3B82F6'}
|
||||
isDragOverlay
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Lead info drawer */}
|
||||
<LeadInfoDrawer
|
||||
lead={selectedLead}
|
||||
columns={kanbanColumns}
|
||||
isOpen={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Column manager modal */}
|
||||
<AnimatePresence>
|
||||
{colModal && (
|
||||
<ColumnManagerModal
|
||||
mode={colModal.mode}
|
||||
column={colModal.column}
|
||||
leadsInColumn={colModal.leadsCount ?? 0}
|
||||
onSave={handleColModalSave}
|
||||
onClose={() => setColModal(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Campaign edit modal */}
|
||||
<AnimatePresence>
|
||||
{campaignEdit && (
|
||||
<CampaignEditModal
|
||||
{...campaignEdit}
|
||||
onClose={() => setCampaignEdit(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useAuth, ROLES } from '../context/AuthContext';
|
||||
import { useMockStore } from '../data/mockStore';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ArrowLeft, Phone, Mail, MapPin, User, Briefcase,
|
||||
Shield, Clock, FileText, ChevronRight, ArrowUpRight,
|
||||
ArrowDownLeft, ArrowRight, MessageSquare, CheckCircle,
|
||||
LayoutGrid, ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '';
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2.5 border-b border-zinc-100 dark:border-white/[0.05] last:border-0">
|
||||
<Icon size={14} className="text-zinc-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] text-zinc-400 mb-0.5">{label}</p>
|
||||
<p className="text-sm text-zinc-800 dark:text-zinc-100 font-medium">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressHeader({ lead, columns, onStageChange }) {
|
||||
const [stageOpen, setStageOpen] = useState(false);
|
||||
const stageColumns = columns.filter(c => c.type === 'stage').sort((a, b) => a.order - b.order);
|
||||
const bucketColumns = columns.filter(c => c.type === 'bucket');
|
||||
const stageIdx = stageColumns.findIndex(c => c.id === lead.columnId);
|
||||
const totalStages = stageColumns.length;
|
||||
const pct = totalStages > 0 ? Math.round(((stageIdx + 1) / totalStages) * 100) : 0;
|
||||
const currentStageCol = stageColumns[stageIdx];
|
||||
const bucketCol = lead.bucketId ? columns.find(c => c.id === lead.bucketId) : null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5 mb-5">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{currentStageCol && (
|
||||
<span className="text-sm font-bold px-3 py-1 rounded-full"
|
||||
style={{ backgroundColor: currentStageCol.color + '18', color: currentStageCol.color }}>
|
||||
{currentStageCol.name}
|
||||
</span>
|
||||
)}
|
||||
{bucketCol && (
|
||||
<span className="text-sm font-bold px-3 py-1 rounded-full"
|
||||
style={{ backgroundColor: bucketCol.color + '18', color: bucketCol.color }}>
|
||||
{bucketCol.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-bold text-zinc-500 dark:text-zinc-400">{pct}% complete</span>
|
||||
</div>
|
||||
|
||||
{/* Stage mover */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setStageOpen(v => !v)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200 text-sm font-semibold hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
Move Stage <ChevronDown size={13} />
|
||||
</button>
|
||||
{stageOpen && (
|
||||
<div className="absolute right-0 top-full mt-1.5 z-20 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl shadow-lg overflow-hidden min-w-[180px]">
|
||||
<p className="px-3 py-2 text-[10px] font-bold text-zinc-400 uppercase tracking-widest border-b border-zinc-100 dark:border-white/[0.07]">Stages</p>
|
||||
{stageColumns.map(col => (
|
||||
<button
|
||||
key={col.id}
|
||||
onClick={() => { onStageChange(col.id, null); setStageOpen(false); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-sm text-left hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors ${col.id === lead.columnId && !lead.bucketId ? 'font-semibold' : ''}`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
|
||||
{col.name}
|
||||
{col.id === lead.columnId && !lead.bucketId && <CheckCircle size={12} className="ml-auto text-emerald-500" />}
|
||||
</button>
|
||||
))}
|
||||
<p className="px-3 py-2 text-[10px] font-bold text-zinc-400 uppercase tracking-widest border-t border-zinc-100 dark:border-white/[0.07]">Buckets</p>
|
||||
{bucketColumns.map(col => (
|
||||
<button
|
||||
key={col.id}
|
||||
onClick={() => { onStageChange(lead.columnId, col.id); setStageOpen(false); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-sm text-left hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors ${col.id === lead.bucketId ? 'font-semibold' : ''}`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
|
||||
{col.name}
|
||||
{col.id === lead.bucketId && <CheckCircle size={12} className="ml-auto text-emerald-500" />}
|
||||
</button>
|
||||
))}
|
||||
{lead.bucketId && (
|
||||
<>
|
||||
<div className="border-t border-zinc-100 dark:border-white/[0.07]" />
|
||||
<button
|
||||
onClick={() => { onStageChange(lead.columnId, null); setStageOpen(false); }}
|
||||
className="w-full px-3 py-2.5 text-sm text-left text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Clear bucket
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-2.5 bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden mb-3">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.7, ease: 'easeOut' }}
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: currentStageCol?.color ?? '#3B82F6' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stage steps breadcrumb */}
|
||||
<div className="flex items-center gap-0.5 flex-wrap">
|
||||
{stageColumns.map((col, i) => (
|
||||
<React.Fragment key={col.id}>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded transition-colors ${
|
||||
i < stageIdx ? 'text-zinc-400 dark:text-zinc-500' :
|
||||
i === stageIdx ? 'text-zinc-800 dark:text-zinc-100' :
|
||||
'text-zinc-300 dark:text-zinc-700'
|
||||
}`}>
|
||||
{col.name}
|
||||
</span>
|
||||
{i < stageColumns.length - 1 && (
|
||||
<ChevronRight size={10} className="text-zinc-300 dark:text-zinc-700 shrink-0" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityItem({ entry, isLast }) {
|
||||
const isFirst = !entry.from;
|
||||
const isRegression = entry.from && (
|
||||
entry.to === 'Contacted' || entry.to === 'New Lead' ||
|
||||
(entry.from !== null && entry.activity_type === 'regression')
|
||||
);
|
||||
const isBucket = entry.to === 'Stuck' || entry.to === 'Follow-up';
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
|
||||
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={13} className="text-blue-600 dark:text-blue-400" /> :
|
||||
isBucket ? <MessageSquare size={13} className="text-amber-600 dark:text-amber-400" /> :
|
||||
isRegression ? <ArrowDownLeft size={13} className="text-orange-600 dark:text-orange-400" /> :
|
||||
<ArrowUpRight size={13} className="text-emerald-600 dark:text-emerald-400" />}
|
||||
</div>
|
||||
{!isLast && <div className="w-px flex-1 bg-zinc-100 dark:bg-zinc-800 my-2 min-h-[20px]" />}
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 min-w-0 ${!isLast ? 'pb-6' : ''}`}>
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-100 dark:border-white/[0.07] rounded-xl px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div>
|
||||
{entry.from ? (
|
||||
<p className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<span className="text-zinc-400">{entry.from}</span>
|
||||
<ArrowRight size={12} className="inline mx-1.5 text-zinc-400" />
|
||||
<span style={{ color: isBucket ? '#F59E0B' : isRegression ? '#F97316' : undefined }}>
|
||||
{entry.to}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-blue-600 dark:text-blue-400">Lead Created</p>
|
||||
)}
|
||||
<p className="text-xs text-zinc-400 mt-0.5">by <span className="font-medium text-zinc-600 dark:text-zinc-300">{entry.by}</span> · {formatDate(entry.date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{entry.note && (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed mt-2 pt-2 border-t border-zinc-100 dark:border-white/[0.05]">
|
||||
{entry.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeadProjectPage() {
|
||||
const { leadId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const { user } = useAuth();
|
||||
const { kanbanLeads, kanbanColumns, moveKanbanLead } = useMockStore();
|
||||
|
||||
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
|
||||
|
||||
const lead = kanbanLeads.find(l => l.id === leadId);
|
||||
|
||||
const handleStageChange = (newColumnId, newBucketId) => {
|
||||
if (!lead) return;
|
||||
if (newColumnId === lead.columnId && newBucketId === lead.bucketId) return;
|
||||
moveKanbanLead(lead.id, newColumnId, newBucketId);
|
||||
toast.success('Stage updated');
|
||||
};
|
||||
|
||||
if (!lead) {
|
||||
return (
|
||||
<div className={`min-h-screen bg-zinc-50 dark:bg-[#09090b] flex items-center justify-center ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
<div className="text-center">
|
||||
<FileText size={32} className="mx-auto mb-3 text-zinc-300 dark:text-zinc-700" />
|
||||
<p className="text-sm font-semibold text-zinc-500">Lead not found</p>
|
||||
<button onClick={() => navigate(-1)} className="mt-3 text-xs text-blue-600 dark:text-blue-400 hover:underline">Go back</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stageColumns = kanbanColumns.filter(c => c.type === 'stage').sort((a, b) => a.order - b.order);
|
||||
const currentStageCol = stageColumns.find(c => c.id === lead.columnId);
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-zinc-50 dark:bg-[#09090b] ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-20 bg-white dark:bg-zinc-950 border-b border-zinc-200 dark:border-white/[0.07] px-4 sm:px-6 py-4">
|
||||
<div className="flex items-center gap-3 max-w-6xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate(`${basePath}/kanban`)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 text-xs font-semibold hover:bg-zinc-50 dark:hover:bg-white/5 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors shrink-0"
|
||||
>
|
||||
<ArrowLeft size={13} />
|
||||
<span className="hidden sm:inline">Pipeline</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2.5 flex-1 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">
|
||||
<h1 className="font-black text-zinc-900 dark:text-white text-base truncate"
|
||||
style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
||||
{lead.name}
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 truncate">{lead.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStageCol && (
|
||||
<span className="text-xs font-bold px-2.5 py-1 rounded-full shrink-0"
|
||||
style={{ backgroundColor: currentStageCol.color + '18', color: currentStageCol.color }}>
|
||||
{currentStageCol.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
|
||||
{/* Progress */}
|
||||
<ProgressHeader lead={lead} columns={kanbanColumns} onStageChange={handleStageChange} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
|
||||
{/* Left — info panels */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Contact */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-3">Contact</p>
|
||||
<InfoRow icon={Phone} label="Phone" value={lead.phone} />
|
||||
<InfoRow icon={Mail} label="Email" value={lead.email} />
|
||||
<InfoRow icon={MapPin} label="Address" value={lead.address} />
|
||||
</div>
|
||||
|
||||
{/* Job details */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-3">Job Details</p>
|
||||
<InfoRow icon={Briefcase} label="Job Type" value={lead.jobType} />
|
||||
<InfoRow icon={Shield} label="Category" value={lead.insuranceType} />
|
||||
<InfoRow icon={User} label="Assigned To" value={lead.assignedAgentName ?? 'Unassigned'} />
|
||||
<InfoRow icon={Clock} label="Created" value={formatDate(lead.createdDate)} />
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{lead.notes && (
|
||||
<div className="bg-amber-50 dark:bg-amber-500/5 border border-amber-100 dark:border-amber-500/10 rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold text-amber-600 dark:text-amber-400 uppercase tracking-widest mb-2">Notes</p>
|
||||
<p className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed">{lead.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest mb-3">Stats</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl px-3 py-2.5 text-center">
|
||||
<p className="text-xl font-black text-zinc-900 dark:text-white" style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
||||
{lead.activity?.length ?? 0}
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-400">Stage Moves</p>
|
||||
</div>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl px-3 py-2.5 text-center">
|
||||
<p className="text-xl font-black text-zinc-900 dark:text-white" style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
|
||||
{lead.activity?.[0]
|
||||
? Math.max(0, Math.floor((new Date('2026-04-07') - new Date(lead.activity[0].date + 'T00:00:00')) / 86400000))
|
||||
: 0}
|
||||
</p>
|
||||
<p className="text-[10px] text-zinc-400">Days in Stage</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — activity log */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Activity Log</p>
|
||||
<span className="text-[10px] font-bold text-zinc-400 bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded-full">
|
||||
{lead.activity?.length ?? 0} entries
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{(lead.activity || []).map((entry, i) => (
|
||||
<ActivityItem
|
||||
key={i}
|
||||
entry={entry}
|
||||
isLast={i === (lead.activity?.length ?? 1) - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user