niceui

Sales Dashboard New

KPI tiles with sparklines, revenue against quota, a pipeline funnel and a rep leaderboard, switchable by period.

ReactTailwind CSSTypeScriptCRMMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/crm-sales-dashboard.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/crm/SalesDashboard.tsx
import { useEffect, useState, type ReactNode } from 'react';
import { ArrowDownRight, ArrowUpRight, Trophy } from 'lucide-react';
import { cn } from '@/lib/cn';

export type Period = 'month' | 'quarter' | 'year';
export type Kpi = { label: string; value: number; format: 'money' | 'percent' | 'days'; delta: number; spark: number[]; goodWhenDown?: boolean };
export type FunnelStage = { label: string; count: number };
export type RevenueBar = { label: string; revenue: number; quota: number };
export type Rep = { name: string; closed: number; quota: number };
export type DashboardData = { kpis: Kpi[]; funnel: FunnelStage[]; revenue: RevenueBar[]; reps: Rep[] };

const money = (n: number) => (n >= 1e6 ? `$${(n / 1e6).toFixed(n >= 1e7 ? 1 : 2)}M` : n >= 1e3 ? `$${(n / 1e3).toFixed(n >= 1e5 ? 0 : 1)}k` : `$${Math.round(n)}`);
const fmt = (k: Kpi, v: number) => (k.format === 'money' ? money(v) : k.format === 'percent' ? `${v.toFixed(0)}%` : `${v.toFixed(0)} d`);

/** Eases a number toward its target whenever the target changes. */
function useTween(target: number, ms = 700) {
  const [v, setV] = useState(target);
  useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return setV(target);
    const from = v;
    const t0 = performance.now();
    let raf = 0;
    const tick = (now: number) => {
      const p = Math.min(1, (now - t0) / ms);
      setV(from + (target - from) * (1 - Math.pow(1 - p, 3)));
      if (p < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [target, ms]);
  return v;
}

function Spark({ data, up }: { data: number[]; up: boolean }) {
  const max = Math.max(...data);
  const min = Math.min(...data);
  const pts = data.map((d, i) => `${(i / (data.length - 1)) * 100},${28 - ((d - min) / (max - min || 1)) * 24}`);
  const id = up ? 'spk-up' : 'spk-dn';
  return (
    <svg viewBox="0 0 100 30" preserveAspectRatio="none" className="h-8 w-full" aria-hidden>
      <defs>
        <linearGradient id={id} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor={up ? '#38bdf8' : '#fb7185'} stopOpacity="0.28" />
          <stop offset="1" stopColor={up ? '#38bdf8' : '#fb7185'} stopOpacity="0" />
        </linearGradient>
      </defs>
      <polygon points={`0,30 ${pts.join(' ')} 100,30`} fill={`url(#${id})`} className="transition-all duration-700" />
      <polyline points={pts.join(' ')} fill="none" stroke={up ? '#7dd3fc' : '#fda4af'} strokeWidth="1.6" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
    </svg>
  );
}

function KpiTile({ k }: { k: Kpi }) {
  const v = useTween(k.value);
  const good = k.goodWhenDown ? k.delta <= 0 : k.delta >= 0;
  return (
    <div className="min-w-0 rounded-xl bg-white/[0.03] p-3 ring-1 ring-white/[0.05]">
      <p className="truncate text-[11px] text-ink-500">{k.label}</p>
      <div className="mt-1 flex items-baseline gap-2">
        <span className="font-mono text-[19px] tabular-nums tracking-tight text-ink-50">{fmt(k, v)}</span>
        <span className={cn('flex items-center text-[11px] font-medium', good ? 'text-emerald-300' : 'text-rose-300')}>
          {k.delta >= 0 ? <ArrowUpRight className="h-3 w-3" /> : <ArrowDownRight className="h-3 w-3" />}
          {Math.abs(k.delta)}%
        </span>
      </div>
      <Spark data={k.spark} up={good} />
    </div>
  );
}

function Funnel({ stages }: { stages: FunnelStage[] }) {
  const top = stages[0]?.count || 1;
  return (
    <div className="space-y-1.5">
      {stages.map((s, i) => {
        const w = Math.max(14, (s.count / top) * 100);
        const conv = i ? Math.round((s.count / stages[i - 1].count) * 100) : 100;
        return (
          <div key={s.label} className="flex items-center gap-3">
            <span className="w-20 shrink-0 truncate text-[11.5px] text-ink-400">{s.label}</span>
            <div className="relative h-7 min-w-0 flex-1">
              <div className="absolute inset-y-0 left-1/2 -translate-x-1/2 rounded-md bg-gradient-to-r from-brand-500/50 via-brand-400/70 to-brand-500/50 transition-all duration-700 ease-smooth" style={{ width: `${w}%`, opacity: 1 - i * 0.12 }} />
              <span className="absolute inset-0 grid place-items-center font-mono text-[11px] tabular-nums text-white">{s.count.toLocaleString()}</span>
            </div>
            <span className="w-10 shrink-0 text-right font-mono text-[10.5px] tabular-nums text-ink-500">{i ? `${conv}%` : ''}</span>
          </div>
        );
      })}
    </div>
  );
}

function RevenueChart({ bars }: { bars: RevenueBar[] }) {
  const max = Math.max(...bars.map((b) => Math.max(b.revenue, b.quota))) * 1.1;
  return (
    <div>
      <div className="flex h-36 items-end gap-2">
        {bars.map((b) => {
          const hit = b.revenue >= b.quota;
          return (
            <div key={b.label} className="group relative flex h-full min-w-0 flex-1 flex-col justify-end">
              <span className="pointer-events-none absolute -top-1 left-1/2 z-10 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md bg-ink-800 px-1.5 py-0.5 font-mono text-[10px] text-ink-100 opacity-0 shadow-pop transition-opacity group-hover:opacity-100">
                {money(b.revenue)} / {money(b.quota)}
              </span>
              <div className="absolute inset-x-0 border-t border-dashed border-white/25 transition-all duration-700 ease-smooth" style={{ bottom: `${(b.quota / max) * 100}%` }} />
              <div className={cn('mx-auto w-full max-w-[28px] rounded-t-md transition-all duration-700 ease-smooth', hit ? 'bg-brand-400' : 'bg-brand-500/45')} style={{ height: `${(b.revenue / max) * 100}%` }} />
            </div>
          );
        })}
      </div>
      <div className="mt-1.5 flex gap-2">
        {bars.map((b) => (
          <span key={b.label} className="min-w-0 flex-1 truncate text-center font-mono text-[10px] text-ink-500">
            {b.label}
          </span>
        ))}
      </div>
    </div>
  );
}

function Leaderboard({ reps }: { reps: Rep[] }) {
  const sorted = [...reps].sort((a, b) => b.closed - a.closed);
  return (
    <ol className="grid grid-cols-[minmax(0,1fr)] gap-x-6 gap-y-2.5 sm:grid-cols-2">
      {sorted.map((r, i) => {
        const att = Math.round((r.closed / r.quota) * 100);
        return (
          <li key={r.name} className="flex items-center gap-2.5">
            <span className={cn('grid h-7 w-7 shrink-0 place-items-center rounded-full text-[10px] font-semibold', i === 0 ? 'bg-amber-400/20 text-amber-200' : 'bg-white/[0.06] text-ink-300')}>
              {i === 0 ? <Trophy className="h-3.5 w-3.5" /> : r.name.split(' ').map((p) => p[0]).join('')}
            </span>
            <div className="min-w-0 flex-1">
              <div className="flex items-baseline justify-between gap-2">
                <span className="truncate text-[12.5px] text-ink-100">{r.name}</span>
                <span className="shrink-0 font-mono text-[11px] tabular-nums text-ink-300">{money(r.closed)}</span>
              </div>
              <div className="mt-1 flex items-center gap-2">
                <div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-white/[0.06]">
                  <div className={cn('h-full rounded-full transition-[width] duration-700 ease-smooth', att >= 100 ? 'bg-emerald-400' : 'bg-brand-400')} style={{ width: `${Math.min(100, att)}%` }} />
                </div>
                <span className={cn('w-9 shrink-0 text-right font-mono text-[10px] tabular-nums', att >= 100 ? 'text-emerald-300' : 'text-ink-500')}>{att}%</span>
              </div>
            </div>
          </li>
        );
      })}
    </ol>
  );
}

function Panel({ title, meta, children, className }: { title: string; meta?: string; children: ReactNode; className?: string }) {
  return (
    <section className={cn('min-w-0 rounded-xl bg-white/[0.02] p-3.5 ring-1 ring-white/[0.05]', className)}>
      <div className="mb-3 flex items-baseline justify-between gap-2">
        <h4 className="text-[12.5px] font-medium text-ink-100">{title}</h4>
        {meta && <span className="truncate text-[10.5px] text-ink-500">{meta}</span>}
      </div>
      {children}
    </section>
  );
}

export type SalesDashboardProps = { data: Record<Period, DashboardData>; defaultPeriod?: Period; className?: string };

export function SalesDashboard({ data, defaultPeriod = 'quarter', className }: SalesDashboardProps) {
  const [period, setPeriod] = useState<Period>(defaultPeriod);
  const d = data[period];
  return (
    <div className={cn('w-full rounded-2xl bg-ink-900 p-4 shadow-card', className)}>
      <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
        <div>
          <h3 className="text-sm font-medium text-ink-50">Sales overview</h3>
          <p className="text-[11.5px] text-ink-500">All pipelines · all reps</p>
        </div>
        <div className="relative flex rounded-lg bg-ink-950/60 p-0.5" role="tablist" aria-label="Period">
          {(['month', 'quarter', 'year'] as Period[]).map((p) => (
            <button key={p} role="tab" aria-selected={period === p} onClick={() => setPeriod(p)} className={cn('focus-ring relative rounded-md px-3 py-1 text-[11.5px] capitalize transition-colors', period === p ? 'bg-ink-800 text-ink-50 shadow-hairline' : 'text-ink-500 hover:text-ink-200')}>
              {p}
            </button>
          ))}
        </div>
      </div>
      <div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
        {d.kpis.map((k) => (
          <KpiTile key={k.label} k={k} />
        ))}
      </div>
      <div className="mt-3 grid grid-cols-[minmax(0,1fr)] gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
        <Panel title="Revenue vs quota" meta="dashed line is quota">
          <RevenueChart bars={d.revenue} />
        </Panel>
        <Panel title="Pipeline funnel" meta="conversion from previous stage">
          <Funnel stages={d.funnel} />
        </Panel>
      </div>
      <Panel title="Leaderboard" meta="closed won vs quota" className="mt-3">
        <Leaderboard reps={d.reps} />
      </Panel>
    </div>
  );
}

/* ——— demo ——— */

const spark = (seed: number, n = 12, trend = 1) => Array.from({ length: n }, (_, i) => 40 + trend * i * 3 + Math.sin(i * 1.7 + seed) * 8 + Math.cos(i * 0.9 + seed * 2) * 5);

const DEMO: Record<Period, DashboardData> = {
  month: {
    kpis: [
      { label: 'Open pipeline', value: 842000, format: 'money', delta: 6, spark: spark(1) },
      { label: 'Win rate', value: 29, format: 'percent', delta: -3, spark: spark(2, 12, -0.5) },
      { label: 'Average deal', value: 17400, format: 'money', delta: 4, spark: spark(3) },
      { label: 'Sales cycle', value: 38, format: 'days', delta: -5, spark: spark(4, 12, -0.6), goodWhenDown: true },
    ],
    funnel: [
      { label: 'Leads', count: 1240 },
      { label: 'Qualified', count: 486 },
      { label: 'Proposal', count: 171 },
      { label: 'Negotiation', count: 74 },
      { label: 'Won', count: 31 },
    ],
    revenue: ['W1', 'W2', 'W3', 'W4'].map((label, i) => ({ label, revenue: [92000, 128000, 104000, 151000][i], quota: 120000 })),
    reps: [
      { name: 'Ana Ruiz', closed: 138000, quota: 120000 },
      { name: 'Dev Patel', closed: 112000, quota: 120000 },
      { name: 'Sara Lind', closed: 97000, quota: 110000 },
      { name: 'Kofi Mensah', closed: 128000, quota: 110000 },
    ],
  },
  quarter: {
    kpis: [
      { label: 'Open pipeline', value: 2410000, format: 'money', delta: 12, spark: spark(5) },
      { label: 'Win rate', value: 32, format: 'percent', delta: 4, spark: spark(6) },
      { label: 'Average deal', value: 18600, format: 'money', delta: 7, spark: spark(7) },
      { label: 'Sales cycle', value: 41, format: 'days', delta: -8, spark: spark(8, 12, -0.7), goodWhenDown: true },
    ],
    funnel: [
      { label: 'Leads', count: 3820 },
      { label: 'Qualified', count: 1460 },
      { label: 'Proposal', count: 512 },
      { label: 'Negotiation', count: 219 },
      { label: 'Won', count: 96 },
    ],
    revenue: ['Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].map((label, i) => ({ label, revenue: [410000, 452000, 398000, 516000, 548000, 602000][i], quota: [450000, 450000, 450000, 500000, 500000, 500000][i] })),
    reps: [
      { name: 'Ana Ruiz', closed: 412000, quota: 360000 },
      { name: 'Dev Patel', closed: 351000, quota: 360000 },
      { name: 'Sara Lind', closed: 289000, quota: 330000 },
      { name: 'Kofi Mensah', closed: 376000, quota: 330000 },
    ],
  },
  year: {
    kpis: [
      { label: 'Open pipeline', value: 9800000, format: 'money', delta: 21, spark: spark(9) },
      { label: 'Win rate', value: 31, format: 'percent', delta: 2, spark: spark(10) },
      { label: 'Average deal', value: 17900, format: 'money', delta: 9, spark: spark(11) },
      { label: 'Sales cycle', value: 44, format: 'days', delta: -3, spark: spark(12, 12, -0.3), goodWhenDown: true },
    ],
    funnel: [
      { label: 'Leads', count: 14600 },
      { label: 'Qualified', count: 5480 },
      { label: 'Proposal', count: 1930 },
      { label: 'Negotiation', count: 820 },
      { label: 'Won', count: 352 },
    ],
    revenue: ['Q1', 'Q2', 'Q3', 'Q4'].map((label, i) => ({ label, revenue: [1180000, 1310000, 1260000, 1666000][i], quota: [1250000, 1300000, 1350000, 1500000][i] })),
    reps: [
      { name: 'Ana Ruiz', closed: 1520000, quota: 1440000 },
      { name: 'Dev Patel', closed: 1310000, quota: 1440000 },
      { name: 'Sara Lind', closed: 1090000, quota: 1320000 },
      { name: 'Kofi Mensah', closed: 1490000, quota: 1320000 },
    ],
  },
};

export function SalesDashboardDemo() {
  return (
    <div className="mx-auto w-full max-w-3xl">
      <SalesDashboard data={DEMO} />
    </div>
  );
}