Board
4 blocksBoard 01
Preview
npx nateui@latest add KanbanBoardGridDark
import { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DragOverEvent, DragStartEvent } from '@dnd-kit/core';
import Avatar from '@/components/ui/Avatar';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Dropdown from '@/components/ui/Dropdown';
import Scroll from '@/components/ui/Scroll';
import Tag from '@/components/ui/Tag';
import Divider from '@/components/composites/Divider';
import {
PiArrowsDownUp,
PiChatCircle,
PiClock,
PiDotsThreeVerticalBold,
PiFunnel,
PiLink,
PiListChecks,
PiPlus,
} from 'react-icons/pi';
const assetBase = 'https://statics.nateui.com/img';
type Priority = 'Low' | 'Medium' | 'High';
type ColumnId = 'todo' | 'in-progress' | 'done';
type ColumnDef = {
id: ColumnId;
name: string;
dotClassName: string;
};
const columns: ColumnDef[] = [
{ id: 'todo', name: 'To Do', dotClassName: 'bg-muted-foreground' },
{ id: 'in-progress', name: 'In Progress', dotClassName: 'bg-primary' },
{ id: 'done', name: 'Done', dotClassName: 'bg-success' },
];
type TaskCard = {
id: string;
columnId: ColumnId;
title: string;
description: string;
assigneeName: string;
assigneeAvatar: string;
dueDate: string;
priority: Priority;
commentCount: number;
taskCount: number;
linkCount: number;
};
const priorityIntentMap: Record<Priority, { dot: string; text: string }> = {
Low: { dot: 'bg-success', text: 'text-success' },
Medium: { dot: 'bg-warning', text: 'text-warning' },
High: { dot: 'bg-destructive', text: 'text-destructive' },
};
const initialCards: TaskCard[] = [
{
id: 'card-1',
columnId: 'todo',
title: 'Checkout Redesign',
description: 'Simplify the payment steps and reduce cart abandonment across mobile and desktop.',
assigneeName: 'Maya Chen',
assigneeAvatar: `${assetBase}/avatars/thumb-1.jpg`,
dueDate: 'Sep 2',
priority: 'High',
commentCount: 4,
taskCount: 3,
linkCount: 1,
},
{
id: 'card-2',
columnId: 'todo',
title: 'Internal Analytics Dashboard',
description: 'Consolidate usage metrics from every product surface into one reporting view.',
assigneeName: 'Owen Bright',
assigneeAvatar: `${assetBase}/avatars/thumb-2.jpg`,
dueDate: 'Sep 9',
priority: 'Low',
commentCount: 0,
taskCount: 1,
linkCount: 2,
},
{
id: 'card-3',
columnId: 'in-progress',
title: 'Notification Service Revamp',
description: 'Move batch alerts onto the new delivery queue and retire the legacy worker.',
assigneeName: 'Priya Shah',
assigneeAvatar: `${assetBase}/avatars/thumb-3.jpg`,
dueDate: 'Sep 15',
priority: 'High',
commentCount: 2,
taskCount: 1,
linkCount: 0,
},
{
id: 'card-4',
columnId: 'in-progress',
title: 'Billing Reconciliation Tool',
description: 'Match invoice records against processor payouts and flag mismatches automatically.',
assigneeName: 'Leo Marchetti',
assigneeAvatar: `${assetBase}/avatars/thumb-4.jpg`,
dueDate: 'Sep 22',
priority: 'Medium',
commentCount: 3,
taskCount: 2,
linkCount: 1,
},
{
id: 'card-5',
columnId: 'done',
title: 'Mobile Onboarding Flow',
description: 'Ship the guided first-run setup and welcome checklist for new accounts.',
assigneeName: 'Naomi Osei',
assigneeAvatar: `${assetBase}/avatars/thumb-5.jpg`,
dueDate: 'Aug 18',
priority: 'Medium',
commentCount: 2,
taskCount: 4,
linkCount: 3,
},
{
id: 'card-6',
columnId: 'done',
title: 'Support Macros Audit',
description: 'Review canned replies for accuracy and tone before the next release.',
assigneeName: 'Theo Lindqvist',
assigneeAvatar: `${assetBase}/avatars/thumb-6.jpg`,
dueDate: 'Aug 25',
priority: 'Low',
commentCount: 1,
taskCount: 2,
linkCount: 0,
},
];
const sortOptions = ['Priority', 'Due date', 'Title A-Z'];
const filterOptions = ['Assignee', 'Priority', 'Due date', 'Tag'];
type DragData =
| { type: 'Card'; card: TaskCard }
| { type: 'Column'; columnId: ColumnId };
function TaskCardBody({ card }: { card: TaskCard }) {
const intent = priorityIntentMap[card.priority];
return (
<>
<div className="space-y-3 p-4">
<div className="flex items-center justify-between">
<Tag
className={`gap-1 bg-transparent`}
prefix={<span className={`h-2.5 w-2.5 rounded-xs ${intent.dot}`} />}
>
{card.priority}
</Tag>
<span className="flex items-center gap-1">
<PiClock className="text-base" />
{card.dueDate}
</span>
</div>
<div>
<div className="text-base font-semibold text-card-foreground">{card.title}</div>
<p className="mt-1 line-clamp-2 text-muted-foreground">{card.description}</p>
</div>
</div>
<div className="flex items-center justify-between border-t px-4 py-3">
<Avatar size={24} src={card.assigneeAvatar} alt={card.assigneeName} />
<div className="flex items-center">
<span className="flex items-center gap-1">
<PiChatCircle className="text-base" />
{card.commentCount}
</span>
<Divider orientation="vertical" className="min-h-3" />
<span className="flex items-center gap-1">
<PiListChecks className="text-base" />
{card.taskCount}
</span>
<Divider orientation="vertical" className="min-h-3" />
<span className="flex items-center gap-1">
<PiLink className="text-base" />
{card.linkCount}
</span>
</div>
</div>
</>
);
}
function SortableTaskCard({ card }: { card: TaskCard }) {
const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
id: card.id,
data: { type: 'Card', card } satisfies DragData,
});
const style = {
transform: CSS.Translate.toString(transform),
transition,
};
return (
<Card
ref={setNodeRef}
style={style}
bodyClass="p-0"
className={isDragging ? 'opacity-30 ring-2 ring-primary' : ''}
{...attributes}
{...listeners}
>
<TaskCardBody card={card} />
</Card>
);
}
function TaskCardOverlay({ card }: { card: TaskCard }) {
return (
<Card bordered={false} bodyClass="p-0" className="w-[288px] ring-2 ring-primary">
<TaskCardBody card={card} />
</Card>
);
}
function KanbanColumn({ column, cards }: { column: ColumnDef; cards: TaskCard[] }) {
const { setNodeRef } = useDroppable({
id: column.id,
data: { type: 'Column', columnId: column.id } satisfies DragData,
});
const cardIds = useMemo(() => cards.map((card) => card.id), [cards]);
return (
<Card
ref={setNodeRef}
className="flex h-full w-[320px] flex-shrink-0 flex-col bg-muted shadow-none"
bodyClass="flex h-full flex-col gap-3 p-2"
bordered={false}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${column.dotClassName}`} />
<h6 className="font-semibold text-foreground">{column.name}</h6>
</div>
<div className="flex items-center gap-1">
<Button
shape="circle"
size="sm"
variant="ghost"
icon={<PiPlus />}
aria-label={`Add card to ${column.name}`}
/>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
shape="circle"
size="sm"
variant="ghost"
icon={<PiDotsThreeVerticalBold />}
aria-label={`More actions for ${column.name}`}
/>
}
>
<Dropdown.Item eventKey="add">Add card</Dropdown.Item>
<Dropdown.Item eventKey="rename">Rename column</Dropdown.Item>
<Dropdown.Item variant="divider" />
<Dropdown.Item eventKey="delete" className="text-destructive">
Delete column
</Dropdown.Item>
</Dropdown>
</div>
</div>
<Scroll className="min-h-0 flex-1" contentClassName="flex flex-col gap-4 p-1" scrollbars="vertical">
<SortableContext items={cardIds}>
{cards.map((card) => (
<SortableTaskCard key={card.id} card={card} />
))}
</SortableContext>
</Scroll>
</Card>
);
}
export default function KanbanBoardGrid() {
const [cards, setCards] = useState<TaskCard[]>(initialCards);
const [activeCard, setActiveCard] = useState<TaskCard | null>(null);
const cardsByColumn = useMemo(() => {
const map: Record<ColumnId, TaskCard[]> = { todo: [], 'in-progress': [], done: [] };
for (const card of cards) {
map[card.columnId].push(card);
}
return map;
}, [cards]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function handleDragStart(event: DragStartEvent) {
const data = event.active.data.current as DragData | undefined;
if (data?.type === 'Card') {
setActiveCard(data.card);
}
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeData = active.data.current as DragData | undefined;
const overData = over.data.current as DragData | undefined;
if (activeData?.type !== 'Card' || !overData) return;
setCards((prev) => {
const activeIndex = prev.findIndex((card) => card.id === active.id);
if (activeIndex === -1) return prev;
const withoutActive = prev.filter((_, index) => index !== activeIndex);
if (overData.type === 'Column') {
const targetColumnId = overData.columnId;
const lastIndexInColumn = withoutActive.reduce(
(lastIndex, card, index) => (card.columnId === targetColumnId ? index : lastIndex),
-1,
);
const moved = { ...prev[activeIndex], columnId: targetColumnId };
withoutActive.splice(lastIndexInColumn + 1, 0, moved);
return withoutActive;
}
const overIndexInSource = withoutActive.findIndex((card) => card.id === over.id);
if (overIndexInSource === -1) return prev;
const isBelowOverItem =
active.rect.current.translated &&
active.rect.current.translated.top > over.rect.top + over.rect.height / 2;
const insertAt = overIndexInSource + (isBelowOverItem ? 1 : 0);
const moved = { ...prev[activeIndex], columnId: overData.card.columnId };
withoutActive.splice(insertAt, 0, moved);
return withoutActive;
});
}
function handleDragEnd() {
setActiveCard(null);
}
return (
<section aria-label="Board" className="w-full space-y-4">
<div className="border-b pb-4">
<h4 className="text-xl font-semibold text-foreground">Board</h4>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Dropdown
placement="bottom-start"
renderTitle={
<Button variant="default" icon={<PiFunnel />}>
Filter
</Button>
}
>
{filterOptions.map((option) => (
<Dropdown.Item key={option} eventKey={option}>
{option}
</Dropdown.Item>
))}
</Dropdown>
<Dropdown
placement="bottom-start"
renderTitle={
<Button variant="default" icon={<PiArrowsDownUp />}>
Sort
</Button>
}
>
{sortOptions.map((option) => (
<Dropdown.Item key={option} eventKey={option}>
{option}
</Dropdown.Item>
))}
</Dropdown>
</div>
<Button variant="solid" icon={<PiPlus />}>
Create Task
</Button>
</div>
<DndContext
id="kanban-board-grid"
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<Scroll
scrollbars="horizontal"
offsetScrollbars="horizontal"
scrollbarSize={8}
contentClassName="p-1"
>
<div className="flex h-[520px] gap-4">
{columns.map((column) => (
<KanbanColumn key={column.id} column={column} cards={cardsByColumn[column.id]} />
))}
</div>
</Scroll>
{typeof document !== 'undefined' &&
createPortal(
<DragOverlay>{activeCard && <TaskCardOverlay card={activeCard} />}</DragOverlay>,
document.body,
)}
</DndContext>
</section>
);
}
Board 02
Preview
npx nateui@latest add LeadPipelineBoardDark
import { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core';
import Avatar from '@/components/ui/Avatar';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Dropdown from '@/components/ui/Dropdown';
import Scroll from '@/components/ui/Scroll';
import IconFrame from '@/components/composites/IconFrame';
import {
PiArrowsDownUp,
PiAt,
PiCalendarBlank,
PiCaretDown,
PiCheckCircle,
PiChatCircle,
PiCurrencyCircleDollar,
PiEnvelopeSimple,
PiFileText,
PiFunnel,
PiPhone,
PiPlus,
PiSquaresFour,
PiUser,
} from 'react-icons/pi';
const assetBase = 'https://statics.nateui.com/img';
type StageId = 'prospecting' | 'qualification' | 'meeting' | 'proposal';
type StageDef = {
id: StageId;
name: string;
dotClassName: string;
};
const stages: StageDef[] = [
{ id: 'prospecting', name: 'Prospecting', dotClassName: 'bg-muted-foreground' },
{ id: 'qualification', name: 'Qualification', dotClassName: 'bg-primary' },
{ id: 'meeting', name: 'Meeting', dotClassName: 'bg-palette-orange' },
{ id: 'proposal', name: 'Proposal made', dotClassName: 'bg-palette-purple' },
];
type Deal = {
id: string;
stageId: StageId;
company: string;
logo: string;
amount: number;
closeDate: string;
email: string;
contactName: string;
contactAvatar: string;
phone: string;
mentionCount: number;
attachmentCount: number;
taskCount: number;
commentCount: number;
};
const initialDeals: Deal[] = [
{
id: 'deal-1',
stageId: 'prospecting',
company: 'Zendesk',
logo: `${assetBase}/thumbs/brands/zendesk.png`,
amount: 18400,
closeDate: 'Sep 4, 2026',
email: 'elena.marsh@zendesk.com',
contactName: 'Elena Marsh',
contactAvatar: `${assetBase}/avatars/thumb-7.jpg`,
phone: '+1 415 555 0142',
mentionCount: 1,
attachmentCount: 2,
taskCount: 1,
commentCount: 3,
},
{
id: 'deal-2',
stageId: 'prospecting',
company: 'Dropbox',
logo: `${assetBase}/thumbs/brands/dropbox.png`,
amount: 24900,
closeDate: 'Sep 10, 2026',
email: 'marcus.webb@dropbox.com',
contactName: 'Marcus Webb',
contactAvatar: `${assetBase}/avatars/thumb-8.jpg`,
phone: '+1 628 555 0199',
mentionCount: 0,
attachmentCount: 1,
taskCount: 2,
commentCount: 0,
},
{
id: 'deal-3',
stageId: 'prospecting',
company: 'Asana',
logo: `${assetBase}/thumbs/brands/asana.png`,
amount: 9800,
closeDate: 'Sep 14, 2026',
email: 'ivy.chen@asana.com',
contactName: 'Ivy Chen',
contactAvatar: `${assetBase}/avatars/thumb-9.jpg`,
phone: '+1 212 555 0173',
mentionCount: 2,
attachmentCount: 1,
taskCount: 0,
commentCount: 1,
},
{
id: 'deal-4',
stageId: 'qualification',
company: 'Shopify',
logo: `${assetBase}/thumbs/brands/shopify.png`,
amount: 31200,
closeDate: 'Sep 6, 2026',
email: 'dana.ruiz@shopify.com',
contactName: 'Dana Ruiz',
contactAvatar: `${assetBase}/avatars/thumb-10.jpg`,
phone: '+1 512 555 0110',
mentionCount: 1,
attachmentCount: 3,
taskCount: 2,
commentCount: 4,
},
{
id: 'deal-5',
stageId: 'qualification',
company: 'Notion',
logo: `${assetBase}/thumbs/brands/notion.png`,
amount: 42750,
closeDate: 'Sep 12, 2026',
email: 'tomas.reyes@notion.so',
contactName: 'Tomas Reyes',
contactAvatar: `${assetBase}/avatars/thumb-11.jpg`,
phone: '+1 206 555 0187',
mentionCount: 3,
attachmentCount: 2,
taskCount: 1,
commentCount: 9,
},
{
id: 'deal-6',
stageId: 'qualification',
company: 'Figma',
logo: `${assetBase}/thumbs/brands/figma.png`,
amount: 15300,
closeDate: 'Sep 18, 2026',
email: 'grace.liu@figma.com',
contactName: 'Grace Liu',
contactAvatar: `${assetBase}/avatars/thumb-12.jpg`,
phone: '+1 303 555 0164',
mentionCount: 0,
attachmentCount: 1,
taskCount: 1,
commentCount: 0,
},
{
id: 'deal-7',
stageId: 'meeting',
company: 'HubSpot',
logo: `${assetBase}/thumbs/brands/hubspot.png`,
amount: 27600,
closeDate: 'Sep 8, 2026',
email: 'felix.bauer@hubspot.com',
contactName: 'Felix Bauer',
contactAvatar: `${assetBase}/avatars/thumb-13.jpg`,
phone: '+1 646 555 0121',
mentionCount: 1,
attachmentCount: 2,
taskCount: 3,
commentCount: 2,
},
{
id: 'deal-8',
stageId: 'meeting',
company: 'Atlassian',
logo: `${assetBase}/thumbs/brands/atlassian.png`,
amount: 53400,
closeDate: 'Sep 16, 2026',
email: 'priya.nair@atlassian.com',
contactName: 'Priya Nair',
contactAvatar: `${assetBase}/avatars/thumb-14.jpg`,
phone: '+1 773 555 0155',
mentionCount: 2,
attachmentCount: 2,
taskCount: 0,
commentCount: 1,
},
{
id: 'deal-9',
stageId: 'proposal',
company: 'Slack',
logo: `${assetBase}/thumbs/brands/slack.png`,
amount: 19900,
closeDate: 'Sep 5, 2026',
email: 'owen.blake@slack.com',
contactName: 'Owen Blake',
contactAvatar: `${assetBase}/avatars/thumb-15.jpg`,
phone: '+1 917 555 0139',
mentionCount: 1,
attachmentCount: 2,
taskCount: 4,
commentCount: 6,
},
{
id: 'deal-10',
stageId: 'proposal',
company: 'Stripe',
logo: `${assetBase}/thumbs/brands/stripe.png`,
amount: 61000,
closeDate: 'Sep 20, 2026',
email: 'nadia.petrov@stripe.com',
contactName: 'Nadia Petrov',
contactAvatar: `${assetBase}/avatars/thumb-16.jpg`,
phone: '+1 415 555 0188',
mentionCount: 3,
attachmentCount: 1,
taskCount: 2,
commentCount: 5,
},
];
const sortOptions = ['Deal value', 'Close date', 'Company A-Z'];
const filterOptions = ['Owner', 'Deal value', 'Close date', 'Tag'];
type DragData =
| { type: 'Deal'; deal: Deal }
| { type: 'Column'; stageId: StageId };
function DealCardBody({ deal }: { deal: Deal }) {
return (
<>
<div className="space-y-4 p-4">
<div className="flex items-center gap-2.5">
<IconFrame size={32}>
<Avatar src={deal.logo} alt={deal.company} size={20} shape="square" className="border-0 bg-transparent" />
</IconFrame>
<div className="min-w-0 truncate font-semibold text-card-foreground">{deal.company}</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<PiCurrencyCircleDollar className="text-base" />${deal.amount.toLocaleString('en-US')}
</div>
<div className="flex items-center gap-2">
<PiCalendarBlank className="text-base" />
{deal.closeDate}
</div>
<div className="flex items-center gap-2">
<PiEnvelopeSimple className="shrink-0 text-base" />
<span className="truncate">{deal.email}</span>
</div>
<div className="flex items-center gap-2">
<PiUser className="shrink-0 text-base" />
<Avatar size={18} src={deal.contactAvatar} alt={deal.contactName} />
<span className="truncate">{deal.contactName}</span>
</div>
<div className="flex items-center gap-2">
<PiPhone className="text-base" />
{deal.phone}
</div>
</div>
</div>
<div className="flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-1.5">
<span className="flex items-center gap-1">
<PiAt className="text-base" />
{deal.mentionCount}
</span>
<span>·</span>
<span className="flex items-center gap-1">
<PiFileText className="text-base" />
{deal.attachmentCount}
</span>
<span>·</span>
<span className="flex items-center gap-1">
<PiCheckCircle className="text-base" />
{deal.taskCount}
</span>
<span>·</span>
<span className="flex items-center gap-1">
<PiChatCircle className="text-base" />
{deal.commentCount}
</span>
</div>
<Button
size="sm"
variant="ghost"
icon={<PiPlus />}
aria-label={`Add activity to ${deal.company}`}
/>
</div>
</>
);
}
function SortableDealCard({ deal }: { deal: Deal }) {
const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
id: deal.id,
data: { type: 'Deal', deal } satisfies DragData,
});
const style = {
transform: CSS.Translate.toString(transform),
transition,
};
return (
<Card
ref={setNodeRef}
style={style}
bodyClass="p-0"
className={isDragging ? 'opacity-30 ring-2 ring-primary' : ''}
{...attributes}
{...listeners}
>
<DealCardBody deal={deal} />
</Card>
);
}
function DealCardOverlay({ deal }: { deal: Deal }) {
return (
<Card bodyClass="p-0" className="w-[288px] ring-2 ring-primary">
<DealCardBody deal={deal} />
</Card>
);
}
function PipelineColumn({ stage, deals }: { stage: StageDef; deals: Deal[] }) {
const { setNodeRef } = useDroppable({
id: stage.id,
data: { type: 'Column', stageId: stage.id } satisfies DragData,
});
const dealIds = useMemo(() => deals.map((deal) => deal.id), [deals]);
const total = useMemo(() => deals.reduce((sum, deal) => sum + deal.amount, 0), [deals]);
return (
<div ref={setNodeRef} className="flex h-full w-[300px] flex-shrink-0 flex-col gap-2">
<div className="px-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${stage.dotClassName}`} />
<h6 className="font-semibold text-foreground">{stage.name}</h6>
</div>
<Button
size="sm"
variant="ghost"
icon={<PiPlus />}
aria-label={`Add deal to ${stage.name}`}
/>
</div>
<p className="pl-4 text-xs text-muted-foreground">
${total.toLocaleString('en-US')} · {deals.length} deals
</p>
</div>
<Scroll className="min-h-0 flex-1" contentClassName="flex flex-col gap-4 p-1" scrollbars="vertical">
<SortableContext items={dealIds}>
{deals.map((deal) => (
<SortableDealCard key={deal.id} deal={deal} />
))}
</SortableContext>
</Scroll>
</div>
);
}
export default function LeadPipelineBoard() {
const [deals, setDeals] = useState<Deal[]>(initialDeals);
const [activeDeal, setActiveDeal] = useState<Deal | null>(null);
const dealsByStage = useMemo(() => {
const map: Record<StageId, Deal[]> = {
prospecting: [],
qualification: [],
meeting: [],
proposal: [],
};
for (const deal of deals) {
map[deal.stageId].push(deal);
}
return map;
}, [deals]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function handleDragStart(event: DragStartEvent) {
const data = event.active.data.current as DragData | undefined;
if (data?.type === 'Deal') {
setActiveDeal(data.deal);
}
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over) return;
const activeData = active.data.current as DragData | undefined;
const overData = over.data.current as DragData | undefined;
if (activeData?.type !== 'Deal') return;
const targetStageId =
overData?.type === 'Deal'
? overData.deal.stageId
: overData?.type === 'Column'
? overData.stageId
: undefined;
if (!targetStageId) return;
setDeals((prev) => {
const activeIndex = prev.findIndex((deal) => deal.id === active.id);
if (activeIndex === -1 || prev[activeIndex].stageId === targetStageId) return prev;
const next = [...prev];
next[activeIndex] = { ...next[activeIndex], stageId: targetStageId };
return next;
});
}
function handleDragEnd(event: DragEndEvent) {
setActiveDeal(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeData = active.data.current as DragData | undefined;
const overData = over.data.current as DragData | undefined;
if (activeData?.type !== 'Deal' || overData?.type !== 'Deal') return;
setDeals((prev) => {
const activeIndex = prev.findIndex((deal) => deal.id === active.id);
const overIndex = prev.findIndex((deal) => deal.id === over.id);
if (activeIndex === -1 || overIndex === -1) return prev;
return arrayMove(prev, activeIndex, overIndex);
});
}
return (
<section aria-label="Lead pipeline" className="w-full space-y-4">
<div className="border-b pb-4">
<h4 className="text-xl font-semibold text-foreground">Leads</h4>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Dropdown
placement="bottom-start"
renderTitle={
<Button variant="default" icon={<PiSquaresFour />}>
<span className="flex items-center gap-1">
Board: Kanban view
<PiCaretDown className="text-sm" />
</span>
</Button>
}
>
<Dropdown.Item eventKey="kanban">Kanban view</Dropdown.Item>
<Dropdown.Item eventKey="list">List view</Dropdown.Item>
<Dropdown.Item eventKey="calendar">Calendar view</Dropdown.Item>
</Dropdown>
<div className="flex flex-wrap items-center gap-2">
<Button variant="default" icon={<PiPlus />}>
Save view
</Button>
<Dropdown
placement="bottom-end"
renderTitle={
<Button variant="default" icon={<PiArrowsDownUp />}>
Sort
</Button>
}
>
{sortOptions.map((option) => (
<Dropdown.Item key={option} eventKey={option}>
{option}
</Dropdown.Item>
))}
</Dropdown>
<Dropdown
placement="bottom-end"
renderTitle={
<Button variant="default" icon={<PiFunnel />}>
Filter
</Button>
}
>
{filterOptions.map((option) => (
<Dropdown.Item key={option} eventKey={option}>
{option}
</Dropdown.Item>
))}
</Dropdown>
</div>
</div>
<DndContext
id="lead-pipeline-board"
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<Scroll
scrollbars="horizontal"
offsetScrollbars="horizontal"
scrollbarSize={8}
contentClassName="p-1"
>
<div className="flex h-[1000px] gap-4">
{stages.map((stage) => (
<PipelineColumn key={stage.id} stage={stage} deals={dealsByStage[stage.id]} />
))}
</div>
</Scroll>
{typeof document !== 'undefined' &&
createPortal(
<DragOverlay>{activeDeal && <DealCardOverlay deal={activeDeal} />}</DragOverlay>,
document.body,
)}
</DndContext>
</section>
);
}
Board 03
Preview
npx nateui@latest add DealPipelineBoardDark
import { useState } from 'react';
import { createPortal } from 'react-dom';
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DragOverEvent, DragStartEvent } from '@dnd-kit/core';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Dropdown from '@/components/ui/Dropdown';
import Input from '@/components/ui/Input';
import Progress from '@/components/ui/Progress';
import Scroll from '@/components/ui/Scroll';
import Tooltip from '@/components/ui/Tooltip';
import Tag from '@/components/ui/Tag';
import {
PiArrowLineLeft,
PiArrowLineRight,
PiBuildingOffice,
PiCalendarBlank,
PiCurrencyDollar,
PiDotsThreeVerticalBold,
PiExport,
PiList,
PiMagnifyingGlass,
PiPlus,
PiSquaresFour,
PiTable,
PiUserCircle,
} from 'react-icons/pi';
type StageId =
| 'discovery'
| 'demo'
| 'evaluation'
| 'negotiation'
| 'contract'
| 'won';
type StageDef = {
id: StageId;
name: string;
barClassName: string;
};
const stages: StageDef[] = [
{ id: 'discovery', name: 'Discovery', barClassName: 'bg-palette-gray' },
{ id: 'demo', name: 'Demo', barClassName: 'bg-palette-cyan' },
{ id: 'evaluation', name: 'Evaluation', barClassName: 'bg-palette-orange' },
{ id: 'negotiation', name: 'Negotiation', barClassName: 'bg-palette-blue' },
{ id: 'contract', name: 'Contract', barClassName: 'bg-palette-purple' },
{ id: 'won', name: 'Closed Won', barClassName: 'bg-palette-emerald' },
];
const initiallyCollapsed: StageId[] = [
'discovery',
'demo',
'evaluation',
'negotiation',
];
type Deal = {
id: string;
stageId: StageId;
name: string;
amount: number;
company: string;
ownerName: string;
closeDate: string;
probability: number;
};
const initialDeals: Deal[] = [
{
id: 'deal-1',
stageId: 'discovery',
name: 'Fleet telemetry rollout',
amount: 42000,
company: 'Northbank Logistics',
ownerName: 'Priya Raghavan',
closeDate: '2026-11-14',
probability: 15,
},
{
id: 'deal-2',
stageId: 'demo',
name: 'Compliance reporting module',
amount: 68500,
company: 'Verity Health Group',
ownerName: 'Tomas Feld',
closeDate: '2026-10-30',
probability: 25,
},
{
id: 'deal-3',
stageId: 'evaluation',
name: 'Multi-site inventory sync',
amount: 115000,
company: 'Copperline Foods',
ownerName: 'Ingrid Sollie',
closeDate: '2026-10-22',
probability: 40,
},
{
id: 'deal-4',
stageId: 'evaluation',
name: 'Field service platform',
amount: 87000,
company: 'Marlow Manufacturing',
ownerName: 'Adaeze Nwosu',
closeDate: '2026-11-06',
probability: 45,
},
{
id: 'deal-5',
stageId: 'negotiation',
name: 'Warehouse automation suite',
amount: 156000,
company: 'Ridgeway Group',
ownerName: 'Priya Raghavan',
closeDate: '2026-09-26',
probability: 60,
},
{
id: 'deal-6',
stageId: 'negotiation',
name: 'Customer portal licence',
amount: 64000,
company: 'Bellhaven Retail',
ownerName: 'Ingrid Sollie',
closeDate: '2026-10-03',
probability: 65,
},
{
id: 'deal-7',
stageId: 'contract',
name: 'Data migration programme',
amount: 98500,
company: 'Ostrand Energy',
ownerName: 'Tomas Feld',
closeDate: '2026-09-18',
probability: 80,
},
{
id: 'deal-8',
stageId: 'contract',
name: 'Payments integration',
amount: 46000,
company: 'Quillon Analytics',
ownerName: 'Adaeze Nwosu',
closeDate: '2026-09-30',
probability: 85,
},
{
id: 'deal-9',
stageId: 'won',
name: 'Regional rollout phase one',
amount: 132000,
company: 'Northbank Logistics',
ownerName: 'Priya Raghavan',
closeDate: '2026-09-04',
probability: 100,
},
];
function formatCurrency(value: number) {
return `$${Math.round(value).toLocaleString('en-US')}`;
}
function DealCardBody({ deal, stage }: { deal: Deal; stage: StageDef }) {
return (
<>
<div className="flex items-start justify-between gap-2">
<h4 className="min-w-0 text-base font-semibold text-card-foreground">
{deal.name}
</h4>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
size="sm"
variant="ghost"
aria-label={`Actions for ${deal.name}`}
icon={<PiDotsThreeVerticalBold />}
/>
}
>
<Dropdown.Item eventKey="edit">Edit deal</Dropdown.Item>
<Dropdown.Item eventKey="owner">Change owner</Dropdown.Item>
<Dropdown.Item eventKey="stage">Move stage</Dropdown.Item>
</Dropdown>
</div>
<div className="mt-2 flex flex-col gap-2">
<div className="flex items-center gap-2">
<PiCurrencyDollar className="shrink-0 text-base" />
<span className="text-card-foreground tabular-nums">
{formatCurrency(deal.amount)}
</span>
</div>
<div className="flex items-center gap-2">
<PiBuildingOffice className="shrink-0 text-base" />
<span className="min-w-0 truncate">{deal.company}</span>
</div>
<div className="flex items-center gap-2">
<PiUserCircle className="shrink-0 text-base" />
<span className="min-w-0 truncate">{deal.ownerName}</span>
</div>
<div className="flex items-center gap-2">
<PiCalendarBlank className="shrink-0 text-base" />
<span>{deal.closeDate}</span>
</div>
</div>
<div className="mt-4 border-t pt-2">
<div className="flex items-center justify-between">
<span>Probability</span>
<span className="text-card-foreground tabular-nums">
{deal.probability}%
</span>
</div>
<Progress
className="mt-2"
size="sm"
percent={deal.probability}
showInfo={false}
strokeClass={stage.barClassName}
/>
</div>
</>
);
}
function SortableDealCard({ deal, stage }: { deal: Deal; stage: StageDef }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: deal.id, data: { stageId: deal.stageId } });
return (
<Card
ref={setNodeRef}
className={`cursor-grab touch-none select-none ${isDragging ? 'opacity-40' : ''}`}
style={{ transform: CSS.Translate.toString(transform), transition }}
{...attributes}
{...listeners}
>
<DealCardBody deal={deal} stage={stage} />
</Card>
);
}
function DealCardOverlay({ deal, stage }: { deal: Deal; stage: StageDef }) {
return (
<Card className="w-80 cursor-grabbing ring-1 ring-primary">
<DealCardBody deal={deal} stage={stage} />
</Card>
);
}
function CollapsedStageRail({
stage,
count,
onExpand,
}: {
stage: StageDef;
count: number;
onExpand: () => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: stage.id });
return (
<div
ref={setNodeRef}
className={`flex w-12 shrink-0 flex-col items-center gap-2 rounded-card border bg-card p-2 ${
isOver ? 'ring-1 ring-primary' : ''
}`}
>
<Tooltip title={`Expand ${stage.name}`}>
<Button
size="sm"
variant="ghost"
aria-label={`Expand ${stage.name}`}
icon={<PiArrowLineRight />}
onClick={onExpand}
/>
</Tooltip>
<h3 className="[writing-mode:vertical-rl] text-sm font-medium text-card-foreground">
{stage.name}
</h3>
<Tag>{count}</Tag>
</div>
);
}
function PipelineStageColumn({
stage,
deals,
onCollapse,
}: {
stage: StageDef;
deals: Deal[];
onCollapse: () => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: stage.id });
return (
<div className="flex h-full w-80 min-h-0 shrink-0 flex-col gap-2">
<div className="flex items-center justify-between gap-2 rounded-card border bg-card p-1">
<div className="min-w-0 flex-1 truncate text-sm font-medium text-card-foreground mx-2">
{stage.name}
</div>
<div className="flex items-center">
<Tag className="mx-2">{deals.length}</Tag>
<Tooltip title={`Collapse ${stage.name}`}>
<Button
size="sm"
variant="ghost"
aria-label={`Collapse ${stage.name}`}
icon={<PiArrowLineLeft />}
onClick={onCollapse}
/>
</Tooltip>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
size="sm"
variant="ghost"
aria-label={`Actions for ${stage.name}`}
icon={<PiDotsThreeVerticalBold />}
/>
}
>
<Dropdown.Item eventKey="rename">Rename stage</Dropdown.Item>
<Dropdown.Item eventKey="probability">Set probability</Dropdown.Item>
<Dropdown.Item eventKey="clear">Clear stage</Dropdown.Item>
</Dropdown>
</div>
</div>
<div
ref={setNodeRef}
className={`flex min-h-0 flex-1 flex-col rounded-card bg-muted p-2 ${
isOver ? 'ring-1 ring-primary' : ''
}`}
>
<Scroll
className="min-h-0 flex-1"
contentClassName="flex flex-col gap-4 p-1"
scrollbars="vertical"
>
<SortableContext items={deals.map((deal) => deal.id)}>
{deals.map((deal) => (
<SortableDealCard key={deal.id} deal={deal} stage={stage} />
))}
</SortableContext>
</Scroll>
</div>
</div>
);
}
export default function DealPipelineBoard() {
const [deals, setDeals] = useState<Deal[]>(initialDeals);
const [collapsedStages, setCollapsedStages] =
useState<StageId[]>(initiallyCollapsed);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const activeDeal = deals.find((deal) => deal.id === activeId) ?? null;
const activeStage =
stages.find((stage) => stage.id === activeDeal?.stageId) ?? stages[0];
const toggleStage = (stageId: StageId) => {
setCollapsedStages((current) =>
current.includes(stageId)
? current.filter((id) => id !== stageId)
: [...current, stageId],
);
};
const resolveStageId = (id: string): StageId | null => {
if (stages.some((stage) => stage.id === id)) {
return id as StageId;
}
return deals.find((deal) => deal.id === id)?.stageId ?? null;
};
const handleDragStart = (event: DragStartEvent) => {
setActiveId(String(event.active.id));
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over) {
return;
}
const activeDealId = String(active.id);
const targetStageId = resolveStageId(String(over.id));
if (!targetStageId) {
return;
}
setDeals((current) => {
const moving = current.find((deal) => deal.id === activeDealId);
if (!moving || moving.stageId === targetStageId) {
return current;
}
return current.map((deal) =>
deal.id === activeDealId ? { ...deal, stageId: targetStageId } : deal,
);
});
};
const handleDragEnd = () => {
setActiveId(null);
};
return (
<section aria-label="Deal pipeline board" className="flex flex-col gap-4">
<div className="border-b pb-4">
<h4 className="text-xl font-semibold text-foreground">Deals</h4>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Input
className="sm:max-w-sm"
placeholder="Search name, company, owner"
prefix={<PiMagnifyingGlass />}
/>
<div className="flex flex-wrap items-center gap-2">
<Button icon={<PiExport />}>Export</Button>
<Button icon={<PiTable />}>Table</Button>
<Button
variant="ghost"
aria-label="List view"
icon={<PiList />}
/>
<Button
active
variant="ghost"
aria-label="Board view"
icon={<PiSquaresFour />}
/>
</div>
</div>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragEnd}
>
<Scroll
scrollbars="horizontal"
offsetScrollbars="horizontal"
scrollbarSize={8}
contentClassName="p-1"
>
<div className="flex h-[720px] items-start gap-4">
{stages.map((stage) => {
const stageDeals = deals.filter(
(deal) => deal.stageId === stage.id,
);
if (collapsedStages.includes(stage.id)) {
return (
<CollapsedStageRail
key={stage.id}
stage={stage}
count={stageDeals.length}
onExpand={() => toggleStage(stage.id)}
/>
);
}
return (
<div key={stage.id} className="h-full">
<PipelineStageColumn
stage={stage}
deals={stageDeals}
onCollapse={() => toggleStage(stage.id)}
/>
</div>
);
})}
<Button className="shrink-0" icon={<PiPlus />}>
Stage
</Button>
</div>
</Scroll>
{typeof document !== 'undefined' &&
createPortal(
<DragOverlay>
{activeDeal ? (
<DealCardOverlay deal={activeDeal} stage={activeStage} />
) : null}
</DragOverlay>,
document.body,
)}
</DndContext>
</section>
);
}
Board 04
Preview
npx nateui@latest add CampaignStatusBoardDark
import { Fragment, useState } from 'react';
import { createPortal } from 'react-dom';
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
horizontalListSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core';
import Avatar from '@/components/ui/Avatar';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Dropdown from '@/components/ui/Dropdown';
import Input from '@/components/ui/Input';
import Scroll from '@/components/ui/Scroll';
import Tag from '@/components/ui/Tag';
import {
PiBroadcast,
PiCurrencyDollar,
PiDotsSixVerticalBold,
PiDotsThreeVerticalBold,
PiFunnel,
PiMagnifyingGlass,
PiPlus,
PiCalendar
} from 'react-icons/pi';
const assetBase = 'https://statics.nateui.com/img';
const channelLogos: Record<string, string> = {
Google: `${assetBase}/thumbs/brands/google.png`,
Facebook: `${assetBase}/thumbs/brands/facebook.png`,
TikTok: `${assetBase}/thumbs/brands/tiktok.png`,
Instagram: `${assetBase}/thumbs/brands/instagram.png`,
Email: `${assetBase}/thumbs/misc/email.png`,
};
type StageId = 'draft' | 'scheduled' | 'active' | 'ended';
type StageDef = {
id: StageId;
name: string;
tagClassName: string;
};
const stages: StageDef[] = [
{ id: 'draft', name: 'Draft', tagClassName: '' },
{
id: 'scheduled',
name: 'Scheduled',
tagClassName: 'border-0 bg-info-soft text-info',
},
{
id: 'active',
name: 'Active',
tagClassName: 'border-0 bg-success-soft text-success',
},
{
id: 'ended',
name: 'Ended',
tagClassName: 'border-0 bg-palette-gray-soft text-palette-gray-soft-foreground',
},
];
const initialStageOrder: StageId[] = stages.map((stage) => stage.id);
type Campaign = {
id: string;
stageId: StageId;
name: string;
dateRange: string;
channel: string;
budget: number;
rate: number | null;
};
const initialCampaigns: Campaign[] = [
{
id: 'campaign-1',
stageId: 'draft',
name: 'Autumn Clearance',
dateRange: 'Oct 3 – Oct 10',
channel: 'Email',
budget: 800,
rate: null,
},
{
id: 'campaign-2',
stageId: 'draft',
name: 'Loyalty Tier Launch',
dateRange: 'Oct 12 – Oct 20',
channel: 'Instagram',
budget: 1200,
rate: null,
},
{
id: 'campaign-3',
stageId: 'scheduled',
name: 'Back to School Countdown',
dateRange: 'Sep 1 – Sep 7',
channel: 'Google',
budget: 1500,
rate: null,
},
{
id: 'campaign-4',
stageId: 'scheduled',
name: 'Founders Day Teaser',
dateRange: 'Sep 14 – Sep 16',
channel: 'Facebook',
budget: 600,
rate: null,
},
{
id: 'campaign-5',
stageId: 'scheduled',
name: 'Referral Bonus Week',
dateRange: 'Sep 21 – Sep 28',
channel: 'Email',
budget: 900,
rate: null,
},
{
id: 'campaign-6',
stageId: 'active',
name: 'Flash Weekend Discount',
dateRange: 'Aug 8 – Aug 10',
channel: 'TikTok',
budget: 1000,
rate: 4.5,
},
{
id: 'campaign-7',
stageId: 'active',
name: 'New Arrival Spotlight',
dateRange: 'Aug 5 – Aug 19',
channel: 'Instagram',
budget: 2200,
rate: 3.1,
},
{
id: 'campaign-8',
stageId: 'active',
name: 'Cart Recovery Nudge',
dateRange: 'Aug 1 – Aug 31',
channel: 'Email',
budget: 500,
rate: 6.4,
},
{
id: 'campaign-9',
stageId: 'ended',
name: 'Spring Refresh Sale',
dateRange: 'May 5 – May 12',
channel: 'Google',
budget: 1300,
rate: 5.2,
},
{
id: 'campaign-10',
stageId: 'ended',
name: 'Anniversary Rewards',
dateRange: 'Apr 20 – Apr 30',
channel: 'Facebook',
budget: 700,
rate: 4.8,
},
];
function formatCurrency(value: number) {
return `$${Math.round(value).toLocaleString('en-US')}`;
}
function CampaignCardBody({ campaign }: { campaign: Campaign }) {
return (
<>
<div className="flex items-center gap-2">
<Avatar
src={channelLogos[campaign.channel]}
alt={campaign.channel}
size={16}
shape="square"
className="border-0 bg-transparent"
/>
<div className="min-w-0 font-semibold text-card-foreground">
{campaign.name}
</div>
</div>
<div className="mt-2 flex items-center gap-1">
<PiCalendar className="text-base" />
<span className="shrink-0">{campaign.dateRange}</span>
</div>
<div className="mt-4 flex items-center gap-2">
<Tag>
<span className="flex items-center gap-1">
<PiCurrencyDollar className="text-sm" />
{formatCurrency(campaign.budget)}
</span>
</Tag>
<Tag>
<span className="flex items-center gap-1">
<PiBroadcast className="text-sm" />
{campaign.rate !== null ? `${campaign.rate}%` : '–'}
</span>
</Tag>
</div>
</>
);
}
function SortableCampaignCard({ campaign }: { campaign: Campaign }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: campaign.id, data: { type: 'card', stageId: campaign.stageId } });
return (
<Card
ref={setNodeRef}
className={`cursor-grab touch-none select-none ${isDragging ? 'opacity-40' : ''}`}
style={{ transform: CSS.Translate.toString(transform), transition }}
{...attributes}
{...listeners}
>
<CampaignCardBody campaign={campaign} />
</Card>
);
}
function CampaignCardOverlay({ campaign }: { campaign: Campaign }) {
return (
<Card className="cursor-grabbing ring-1 ring-primary">
<CampaignCardBody campaign={campaign} />
</Card>
);
}
function CampaignStageColumn({
stage,
campaigns,
}: {
stage: StageDef;
campaigns: Campaign[];
}) {
const {
attributes,
listeners,
setNodeRef: setSortableRef,
transform,
transition,
isDragging,
} = useSortable({ id: stage.id, data: { type: 'column' } });
const { setNodeRef: setDropZoneRef, isOver } = useDroppable({
id: `zone-${stage.id}`,
});
return (
<div
ref={setSortableRef}
className={`flex h-full w-62 min-h-0 shrink-0 flex-col gap-2 ${isDragging ? 'opacity-40' : ''}`}
style={{ transform: CSS.Translate.toString(transform), transition }}
>
<div className="flex items-center justify-between gap-2 rounded-card border bg-card p-1">
<div className="flex min-w-0 items-center gap-2">
<Button
size="sm"
variant="ghost"
aria-label={`Reorder ${stage.name} column`}
icon={<PiDotsSixVerticalBold />}
className="cursor-grab touch-none"
{...attributes}
{...listeners}
/>
<div className="font-semibold text-foreground">{stage.name}</div>
<Tag>{campaigns.length}</Tag>
</div>
<Dropdown
placement="bottom-end"
renderTitle={
<Button
size="sm"
variant="ghost"
aria-label={`Actions for ${stage.name}`}
icon={<PiDotsThreeVerticalBold />}
/>
}
>
<Dropdown.Item eventKey="rename">Rename status</Dropdown.Item>
<Dropdown.Item eventKey="duplicate">Duplicate column</Dropdown.Item>
<Dropdown.Item eventKey="clear">Clear column</Dropdown.Item>
</Dropdown>
</div>
<div
ref={setDropZoneRef}
className={`flex min-h-0 flex-1 flex-col ${
isOver ? 'ring-1 ring-primary' : ''
}`}
>
<Scroll
className="min-h-0 flex-1"
contentClassName="flex flex-col gap-4 p-1"
scrollbars="vertical"
>
<SortableContext items={campaigns.map((campaign) => campaign.id)}>
{campaigns.map((campaign) => (
<SortableCampaignCard key={campaign.id} campaign={campaign} />
))}
</SortableContext>
</Scroll>
</div>
</div>
);
}
function CampaignColumnOverlay({
stage,
campaigns,
}: {
stage: StageDef;
campaigns: Campaign[];
}) {
return (
<div className="flex w-64 cursor-grabbing flex-col gap-2 rounded-card ring-1 ring-primary">
<div className="flex items-center justify-between gap-2 rounded-card border bg-card p-1">
<div className="flex min-w-0 items-center gap-2">
<PiDotsSixVerticalBold className="text-lg text-muted-foreground" />
<div className="font-semibold text-foreground">{stage.name}</div>
<Tag>{campaigns.length}</Tag>
</div>
</div>
<div className="flex flex-col gap-4 rounded-card p-1">
{campaigns.map((campaign) => (
<Card key={campaign.id}>
<CampaignCardBody campaign={campaign} />
</Card>
))}
</div>
</div>
);
}
export default function CampaignStatusBoard() {
const [campaigns, setCampaigns] = useState<Campaign[]>(initialCampaigns);
const [stageOrder, setStageOrder] = useState<StageId[]>(initialStageOrder);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const stagesById = new Map(stages.map((stage) => [stage.id, stage]));
const activeCampaign = campaigns.find((campaign) => campaign.id === activeId) ?? null;
const activeStage =
!activeCampaign && activeId
? (stagesById.get(activeId as StageId) ?? null)
: null;
const resolveStageId = (id: string): StageId | null => {
if (id.startsWith('zone-')) {
const rawId = id.slice(5);
return stagesById.has(rawId as StageId) ? (rawId as StageId) : null;
}
return campaigns.find((campaign) => campaign.id === id)?.stageId ?? null;
};
const handleDragStart = (event: DragStartEvent) => {
setActiveId(String(event.active.id));
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over || active.data.current?.type !== 'card') {
return;
}
const activeCampaignId = String(active.id);
const targetStageId = resolveStageId(String(over.id));
if (!targetStageId) {
return;
}
setCampaigns((current) => {
const moving = current.find((campaign) => campaign.id === activeCampaignId);
if (!moving || moving.stageId === targetStageId) {
return current;
}
return current.map((campaign) =>
campaign.id === activeCampaignId
? { ...campaign, stageId: targetStageId }
: campaign,
);
});
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.data.current?.type === 'column' && active.id !== over.id) {
setStageOrder((current) => {
const oldIndex = current.indexOf(active.id as StageId);
const newIndex = current.indexOf(over.id as StageId);
if (oldIndex === -1 || newIndex === -1) {
return current;
}
return arrayMove(current, oldIndex, newIndex);
});
}
setActiveId(null);
};
return (
<section aria-label="Campaign status board" className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<h4 className="text-xl font-semibold text-foreground">Campaigns</h4>
<div className="flex flex-wrap items-center gap-2">
<Dropdown
placement="bottom-end"
renderTitle={
<Button icon={<PiFunnel />}>Filter</Button>
}
>
<Dropdown.Item eventKey="status">Status</Dropdown.Item>
<Dropdown.Item eventKey="channel">Channel</Dropdown.Item>
<Dropdown.Item eventKey="date-range">Date range</Dropdown.Item>
</Dropdown>
<Input className="w-44" placeholder="Search" prefix={<PiMagnifyingGlass />} />
<Button variant="solid" icon={<PiPlus />}>
Create Campaign
</Button>
</div>
</div>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveId(null)}
>
<Scroll
scrollbars="horizontal"
offsetScrollbars="horizontal"
scrollbarSize={8}
contentClassName="p-1"
>
<div className="flex h-[560px] items-start gap-2">
<SortableContext
items={stageOrder}
strategy={horizontalListSortingStrategy}
>
{stageOrder.map((stageId, index) => {
const stage = stagesById.get(stageId)!;
const stageCampaigns = campaigns.filter(
(campaign) => campaign.stageId === stageId,
);
return (
<Fragment key={stage.id}>
{index > 0 && (
<div className="w-px shrink-0 self-stretch bg-border" />
)}
<CampaignStageColumn
stage={stage}
campaigns={stageCampaigns}
/>
</Fragment>
);
})}
</SortableContext>
</div>
</Scroll>
{typeof document !== 'undefined' &&
createPortal(
<DragOverlay>
{activeCampaign ? (
<CampaignCardOverlay campaign={activeCampaign} />
) : activeStage ? (
<CampaignColumnOverlay
stage={activeStage}
campaigns={campaigns.filter(
(campaign) => campaign.stageId === activeStage.id,
)}
/>
) : null}
</DragOverlay>,
document.body,
)}
</DndContext>
</section>
);
}