fix(kanban): responsive layout, drag-drop offset, and project view enhancements
Kanban pipeline board: - Fix h-full flex-col layout so board fills viewport height on all screen sizes - KanbanColumn drop zone: flex-1 min-h-0 so it expands to fill full column height - KanbanCard: extract KanbanCardDisplay as named export for DragOverlay rendering above overflow containers - KanbanPage: lift DndContext to page root; add "Add Stage" shortcut to page header - PageTransition: remove slide-in-from-bottom-4 transform — the CSS transform ancestor was creating a containing block for position:fixed DragOverlay, causing drag ghost to render at a viewport offset - Migrate fireCampaignToast to toast.custom() Sonner v2 API with explicit card styling Project & lead views: - LeadProjectPage: enrich project detail with richer info sections aligned with project stage - OwnerProjectDetail: expand construction project detail with additional data panels - OwnerProjectList: project list view improvements - mockStore: extend lead/project data to support richer project detail rendering
This commit is contained in:
+85
-72
@@ -1,16 +1,16 @@
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import {
|
||||
DndContext, DragOverlay, PointerSensor, TouchSensor,
|
||||
useSensor, useSensors, closestCorners
|
||||
useSensor, useSensors, pointerWithin, rectIntersection
|
||||
} 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 { LayoutGrid, Search, Filter, X, Send, Pencil, Plus } from 'lucide-react';
|
||||
|
||||
import KanbanCard from '../components/kanban/KanbanCard';
|
||||
import KanbanCard, { KanbanCardDisplay } from '../components/kanban/KanbanCard';
|
||||
import KanbanColumn from '../components/kanban/KanbanColumn';
|
||||
import LeadInfoDrawer from '../components/kanban/LeadInfoDrawer';
|
||||
import ColumnManagerModal from '../components/kanban/ColumnManagerModal';
|
||||
@@ -20,45 +20,43 @@ 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>
|
||||
// Sonner v2: toast.custom(fn) where fn receives the toast ID directly (string)
|
||||
toast.custom((toastId) => {
|
||||
const [secs, setSecs] = React.useState(20);
|
||||
React.useEffect(() => {
|
||||
const t = setInterval(() => setSecs(s => {
|
||||
if (s <= 1) { clearInterval(t); toast.dismiss(toastId); return 0; }
|
||||
return s - 1;
|
||||
}), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-xl p-4">
|
||||
<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>
|
||||
);
|
||||
},
|
||||
{ duration: 20000, position: 'bottom-right' }
|
||||
);
|
||||
<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(toastId); 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(toastId); 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
|
||||
@@ -110,7 +108,6 @@ export default function KanbanPage() {
|
||||
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);
|
||||
|
||||
@@ -120,6 +117,9 @@ export default function KanbanPage() {
|
||||
// Campaign edit modal
|
||||
const [campaignEdit, setCampaignEdit] = useState(null); // { message, lead, column }
|
||||
|
||||
// Active drag — tracked so DragOverlay can render the floating clone
|
||||
const [draggingLead, setDraggingLead] = useState(null); // { lead, columnColor }
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||
@@ -156,7 +156,9 @@ export default function KanbanPage() {
|
||||
|
||||
const handleDragStart = ({ active }) => {
|
||||
const lead = kanbanLeads.find(l => l.id === active.id);
|
||||
setDraggingLead(lead ?? null);
|
||||
if (!lead) return;
|
||||
const col = kanbanColumns.find(c => c.id === lead.columnId);
|
||||
setDraggingLead({ lead, columnColor: col?.color ?? '#6366f1' });
|
||||
};
|
||||
|
||||
const handleDragEnd = ({ active, over }) => {
|
||||
@@ -217,11 +219,17 @@ export default function KanbanPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-zinc-50 dark:bg-[#09090b] ${theme === 'dark' ? 'dark' : ''}`}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={(args) => pointerWithin(args).length > 0 ? pointerWithin(args) : rectIntersection(args)}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className={`h-full flex flex-col 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="shrink-0 px-4 sm:px-6 py-4 border-b border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-950 z-20">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<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>
|
||||
@@ -230,12 +238,22 @@ export default function KanbanPage() {
|
||||
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>
|
||||
<p className="text-xs text-zinc-400 mt-0.5">{kanbanLeads.length} leads · {stageColumns.length} stages</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Stage — header shortcut for owners/admins */}
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-2 rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:border-zinc-400 dark:hover:border-zinc-500 text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Plus size={12} /> Add Stage
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<div className="relative flex-1 min-w-[180px] max-w-xs ml-auto">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||
<input
|
||||
value={search}
|
||||
@@ -252,15 +270,9 @@ export default function KanbanPage() {
|
||||
</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">
|
||||
{/* Board — fills remaining height, horizontal scroll only */}
|
||||
<div className="flex-1 overflow-x-auto overflow-y-hidden min-h-0">
|
||||
<div className="flex gap-4 px-4 sm:px-6 py-5 min-w-max h-full">
|
||||
{/* Stage columns */}
|
||||
{stageColumns.map((col, i) => (
|
||||
<KanbanColumn
|
||||
@@ -278,8 +290,8 @@ export default function KanbanPage() {
|
||||
))}
|
||||
|
||||
{/* 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 className="shrink-0 self-stretch flex items-stretch px-2">
|
||||
<div className="w-px bg-zinc-200 dark:bg-zinc-800" />
|
||||
</div>
|
||||
|
||||
{/* Bucket columns */}
|
||||
@@ -297,19 +309,7 @@ export default function KanbanPage() {
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Lead info drawer */}
|
||||
@@ -343,5 +343,18 @@ export default function KanbanPage() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* DragOverlay — dnd-kit already portals this to document.body internally,
|
||||
so position:fixed is always viewport-relative regardless of ancestors. */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggingLead ? (
|
||||
<KanbanCardDisplay
|
||||
lead={draggingLead.lead}
|
||||
columnColor={draggingLead.columnColor}
|
||||
isOverlay
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user