Board

4 blocks

Board 01

Preview
npx nateui@latest add KanbanBoardGrid
Dark
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 LeadPipelineBoard
Dark
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 DealPipelineBoard
Dark
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 CampaignStatusBoard
Dark
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>
  );
}