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:
Satyam-Rastogi
2026-04-08 19:56:42 +05:30
parent c927f4c44d
commit b1ece35ac8
11 changed files with 2440 additions and 1 deletions
+347
View File
@@ -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>
);
}