Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:29Get Lifetime →
loopkit
freeDashboard · Next.js + Tailwind

Keel

A dashboard for a CRM with an assistant: a pipeline board by stage, deal sheets with an AI summary and next step, contacts by engagement, a weighted forecast and today's suggested follow-ups.

loopkitui.com/t/ai-crmscroll inside ↓

Built from

  • Dash kit (shell, charts, tables)in the zip

For: Small sales teams that want their CRM to summarise and suggest, not just store.

prompt
You are building a complete dashboard app screen with Loopkit: the "Keel" template.

Goal: A dashboard for a CRM with an assistant: a pipeline board by stage, deal sheets with an AI summary and next step, contacts by engagement, a weighted forecast and today's suggested follow-ups.
Audience: Small sales teams that want their CRM to summarise and suggest, not just store.

Stack: Next.js App Router, TypeScript, Tailwind CSS v4, framer-motion. Colour tokens are shadcn's plus Loopkit's --signal, --ok, --warn, --info (see https://loopkitui.com/docs/theming). Light theme by default, dark via the .dark class.

1. Install the Loopkit components this page uses:
   npx shadcn@latest add https://loopkitui.com/r/dash-kit.json   # dash-kit
2. Create app/page.tsx with the source below. Keep the section order and the copy structure; replace the product name, copy and links with mine.
3. Check it at 360, 768, 1440 and 2560 px wide, in light and dark, with reduced motion on and off. The console must be clean.

Page source:

```tsx
"use client";

import * as React from "react";
import {
  DashShell,
  Panel,
  Kpi,
  BarChart,
  DataTable,
  Pill,
  Seg,
  Btn,
  Sheet,
  Facts,
  Confirm,
  Avatar,
  Icon,
  useToast,
  series,
  fmt,
  type Column,
  type Tone,
} from "@/components/loopkit/dash-kit";

/**
 * Keel — a CRM with an assistant built in: a pipeline board, deals with an AI summary and a
 * suggested next step, a contact list scored by engagement, and a weighted forecast.
 */

type Stage = "Qualified" | "Discovery" | "Proposal" | "Negotiation" | "Won";
const STAGES: Stage[] = ["Qualified", "Discovery", "Proposal", "Negotiation", "Won"];
const PROB: Record<Stage, number> = { Qualified: 0.1, Discovery: 0.25, Proposal: 0.5, Negotiation: 0.75, Won: 1 };

interface Deal {
  id: string;
  company: string;
  contact: string;
  value: number;
  stage: Stage;
  owner: string;
  lastTouch: string;
  risk: "low" | "medium" | "high";
  summary: string;
  next: string;
}
const DEALS: Deal[] = [
  { id: "d1", company: "Northwind Clinics", contact: "Amara Diallo", value: 48000, stage: "Proposal", owner: "Jonas", lastTouch: "2d", risk: "low", summary: "Rolling out to 6 clinics in Q1. Main concern is migrating patient notes; IT wants a pilot in one clinic first.", next: "Send the pilot plan with a 30-day migration timeline before Thursday's call." },
  { id: "d2", company: "Bluefin Logistics", contact: "Tomas Varga", value: 120000, stage: "Negotiation", owner: "Elif", lastTouch: "6h", risk: "medium", summary: "Agreed on scope. Procurement is pushing for a 15% discount on a 2-year term; champion is supportive.", next: "Offer 10% on a 2-year term with annual payment upfront." },
  { id: "d3", company: "Orchard Studio", contact: "Lina Park", value: 9600, stage: "Discovery", owner: "Jonas", lastTouch: "9d", risk: "high", summary: "Good first call, then quiet for 9 days. They mentioned evaluating two other tools.", next: "Share the comparison guide and ask for 20 minutes next week." },
  { id: "d4", company: "Granite Legal", contact: "Peter Lund", value: 36000, stage: "Qualified", owner: "Maya", lastTouch: "1d", risk: "low", summary: "Inbound from the pricing page. 40 seats, needs SSO and data residency in the EU.", next: "Book discovery with their IT lead and confirm EU hosting requirements." },
  { id: "d5", company: "Fieldnote Farms", contact: "Rosa Mendes", value: 14400, stage: "Proposal", owner: "Elif", lastTouch: "3d", risk: "medium", summary: "Proposal sent. Budget is approved but signer is out until the 20th.", next: "Schedule a reminder for the 21st and line up the signer's deputy." },
  { id: "d6", company: "Lumen Robotics", contact: "Kenji Mori", value: 72000, stage: "Won", owner: "Maya", lastTouch: "today", risk: "low", summary: "Signed a 1-year contract for 120 seats.", next: "Hand off to onboarding with the kickoff notes." },
  { id: "d7", company: "Copperline Energy", contact: "Ada Novak", value: 54000, stage: "Discovery", owner: "Jonas", lastTouch: "4d", risk: "medium", summary: "Two teams interested. They need an integration with their ticketing tool.", next: "Bring a solutions engineer to the next call to scope the integration." },
];
const RISK_TONE: Record<Deal["risk"], Tone> = { low: "ok", medium: "warn", high: "danger" };

export default function Page() {
  const [view, setView] = React.useState("pipeline");
  const titles: Record<string, [string, string]> = {
    pipeline: ["Pipeline", "Open deals by stage. Use the arrows on a card to move it"],
    contacts: ["Contacts", "People you're talking to, ranked by engagement"],
    forecast: ["Forecast", "Weighted by stage probability"],
    assistant: ["Assistant", "Follow-ups the assistant suggests today"],
  };
  return (
    <DashShell
      brand="Keel"
      mark="K"
      workspace="Sales"
      accent="info"
      variant="panel"
      nav={[
        { id: "pipeline", label: "Pipeline", icon: "layers" },
        { id: "contacts", label: "Contacts", icon: "users" },
        { id: "forecast", label: "Forecast", icon: "chart" },
        { id: "assistant", label: "Assistant", icon: "sparkle", badge: 3 },
      ]}
      active={view}
      onNavigate={setView}
      title={titles[view][0]}
      subtitle={titles[view][1]}
      searchPlaceholder="Search deals and people"
      user={{ name: "Elif Demir", role: "Account executive" }}
    >
      {view === "pipeline" && <Pipeline />}
      {view === "contacts" && <Contacts />}
      {view === "forecast" && <Forecast />}
      {view === "assistant" && <Assistant />}
    </DashShell>
  );
}

function Pipeline() {
  const toast = useToast();
  const [deals, setDeals] = React.useState(DEALS);
  const [open, setOpen] = React.useState<Deal | null>(null);
  const [lost, setLost] = React.useState(false);
  const move = (d: Deal, dir: 1 | -1) => {
    const i = STAGES.indexOf(d.stage) + dir;
    if (i < 0 || i >= STAGES.length) return;
    setDeals((ds) => ds.map((x) => (x.id === d.id ? { ...x, stage: STAGES[i] } : x)));
    toast(`${d.company} moved to ${STAGES[i]}`, STAGES[i] === "Won" ? "ok" : "info");
  };
  return (
    <>
      <div className="mb-6 grid grid-cols-2 gap-4 xl:grid-cols-4">
        <Kpi label="Open pipeline" value={fmt.usd(deals.filter((d) => d.stage !== "Won").reduce((a, d) => a + d.value, 0))} delta="+8%" spark={series(4, 12, 300, 20, 6)} />
        <Kpi label="Weighted" value={fmt.usd(Math.round(deals.filter((d) => d.stage !== "Won").reduce((a, d) => a + d.value * PROB[d.stage], 0)))} tone="info" />
        <Kpi label="Won this month" value={fmt.usd(deals.filter((d) => d.stage === "Won").reduce((a, d) => a + d.value, 0))} tone="ok" />
        <Kpi label="Deals at risk" value={String(deals.filter((d) => d.risk === "high").length)} good={false} delta="no reply in 7+ days" tone="danger" />
      </div>
      <div>
        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
          {STAGES.map((s) => {
            const col = deals.filter((d) => d.stage === s);
            return (
              <section key={s} className="min-w-0 rounded-2xl bg-muted/50 p-3">
                <header className="mb-3 flex items-center justify-between px-1">
                  <h2 className="text-sm font-semibold">{s}</h2>
                  <span className="font-mono text-[11px] text-muted-foreground">{fmt.usd(col.reduce((a, d) => a + d.value, 0))}</span>
                </header>
                <ul className="space-y-2">
                  {col.map((d) => (
                    <li key={d.id} className="rounded-xl border border-border bg-card p-3">
                      <button type="button" onClick={() => setOpen(d)} className="block w-full text-left">
                        <p className="truncate text-sm font-semibold">{d.company}</p>
                        <p className="truncate text-xs text-muted-foreground">{d.contact}</p>
                        <div className="mt-2 flex items-center justify-between gap-2">
                          <span className="font-mono text-xs">{fmt.usd(d.value)}</span>
                          <Pill tone={RISK_TONE[d.risk]}>{d.risk}</Pill>
                        </div>
                      </button>
                      <div className="mt-2 flex justify-between border-t border-border pt-2">
                        <button type="button" aria-label={`Move ${d.company} back`} disabled={s === "Qualified"} onClick={() => move(d, -1)} className="grid size-6 place-items-center rounded-md text-muted-foreground hover:bg-muted disabled:opacity-30"><Icon name="chevron" className="size-3.5 rotate-180" /></button>
                        <Avatar name={d.owner} className="size-6 text-[9px]" />
                        <button type="button" aria-label={`Move ${d.company} forward`} disabled={s === "Won"} onClick={() => move(d, 1)} className="grid size-6 place-items-center rounded-md text-muted-foreground hover:bg-muted disabled:opacity-30"><Icon name="chevron" className="size-3.5" /></button>
                      </div>
                    </li>
                  ))}
                  {col.length === 0 && <li className="rounded-xl border border-dashed border-border p-4 text-center text-xs text-muted-foreground">No deals</li>}
                </ul>
              </section>
            );
          })}
        </div>
      </div>
      <Sheet
        open={!!open}
        onClose={() => setOpen(null)}
        title={open?.company ?? ""}
        footer={open && <><Btn variant="primary" icon="msg" onClick={() => { toast(`Follow-up written for ${open.contact}`, "ok"); setOpen(null); }}>Write follow-up</Btn><Btn variant="danger" onClick={() => setLost(true)}>Mark lost</Btn></>}
      >
        {open && (
          <div className="space-y-5">
            <Facts items={[["Contact", open.contact], ["Value", fmt.usd(open.value)], ["Stage", open.stage], ["Owner", open.owner], ["Last touch", open.lastTouch], ["Risk", <Pill key="r" tone={RISK_TONE[open.risk]} dot>{open.risk}</Pill>]]} />
            <div className="rounded-xl bg-[color-mix(in_oklab,var(--acc)_10%,transparent)] p-4 text-sm">
              <p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-[var(--acc)]"><Icon name="sparkle" className="size-3.5" /> Summary from emails and calls</p>
              <p>{open.summary}</p>
              <p className="mt-3 text-xs font-semibold text-[var(--acc)]">Suggested next step</p>
              <p>{open.next}</p>
            </div>
          </div>
        )}
      </Sheet>
      <Confirm open={lost} danger title="Mark this deal as lost?" body="It leaves the pipeline and the forecast. You can reopen it from the deal history." confirmLabel="Mark lost" onCancel={() => setLost(false)} onConfirm={() => { if (open) setDeals((ds) => ds.filter((d) => d.id !== open.id)); setLost(false); setOpen(null); toast("Deal marked as lost", "warn"); }} />
    </>
  );
}

interface Contact {
  id: string;
  name: string;
  company: string;
  title: string;
  score: number;
  last: string;
}
const CONTACTS: Contact[] = DEALS.map((d, i) => ({ id: d.id, name: d.contact, company: d.company, title: ["Head of IT", "COO", "Founder", "Partner", "Finance lead", "VP Engineering", "Ops manager"][i], score: [82, 91, 34, 67, 58, 95, 61][i], last: d.lastTouch }));

function Contacts() {
  const [band, setBand] = React.useState<"all" | "hot" | "cold">("all");
  const rows = band === "all" ? CONTACTS : band === "hot" ? CONTACTS.filter((c) => c.score >= 70) : CONTACTS.filter((c) => c.score < 50);
  const cols: Column<Contact>[] = [
    { key: "name", label: "Name", sort: (r) => r.name, render: (r) => <span className="flex items-center gap-2.5"><Avatar name={r.name} className="size-7 text-[10px]" /><span className="font-medium">{r.name}</span></span> },
    { key: "title", label: "Title" },
    { key: "company", label: "Company", sort: (r) => r.company },
    { key: "score", label: "Engagement", align: "right", sort: (r) => r.score, render: (r) => <Pill tone={r.score >= 70 ? "ok" : r.score < 50 ? "danger" : "warn"}>{r.score}</Pill> },
    { key: "last", label: "Last touch", align: "right" },
  ];
  return (
    <Panel title="Contacts" pad={false} action={<Seg size="sm" label="Engagement" options={[{ value: "all", label: "All" }, { value: "hot", label: "Engaged" }, { value: "cold", label: "Going cold" }]} value={band} onChange={setBand} />}>
      <DataTable columns={cols} rows={rows} empty="No contacts in this band." />
    </Panel>
  );
}

function Forecast() {
  const months = ["Nov", "Dec", "Jan", "Feb", "Mar", "Apr"];
  return (
    <div className="space-y-6">
      <Panel title="Weighted forecast by month" subtitle="Commit, best case and pipeline">
        <BarChart labels={months} format={(n) => fmt.usd(n)} data={[{ name: "Commit", data: series(1, 6, 90000, 15000, 4000).map(Math.round), tone: "accent" }, { name: "Best case", data: series(2, 6, 40000, 9000).map(Math.round), tone: "info" }, { name: "Pipeline", data: series(3, 6, 30000, 9000).map(Math.round), tone: "muted" }]} height={240} />
      </Panel>
      <Panel title="By owner">
        <ul className="space-y-4">
          {["Elif", "Jonas", "Maya"].map((o) => {
            const ds = DEALS.filter((d) => d.owner === o && d.stage !== "Won");
            const w = ds.reduce((a, d) => a + d.value * PROB[d.stage], 0);
            return (
              <li key={o} className="flex items-center gap-3">
                <Avatar name={o} />
                <span className="w-16 text-sm font-medium">{o}</span>
                <div className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-[var(--acc)]" style={{ width: `${Math.min(100, (w / 100000) * 100)}%` }} /></div>
                <span className="w-20 text-right font-mono text-xs">{fmt.usd(Math.round(w))}</span>
              </li>
            );
          })}
        </ul>
      </Panel>
    </div>
  );
}

function Assistant() {
  const toast = useToast();
  const [done, setDone] = React.useState<string[]>([]);
  const items = DEALS.filter((d) => d.stage !== "Won").slice(0, 4);
  return (
    <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
      {items.map((d) => (
        <Panel key={d.id} title={d.company} subtitle={`${d.contact} · last touch ${d.lastTouch}`} action={<Pill tone={RISK_TONE[d.risk]} dot>{d.risk} risk</Pill>}>
          <p className="text-sm">{d.next}</p>
          <div className="mt-4 flex flex-wrap gap-2">
            {done.includes(d.id) ? (
              <Pill tone="ok">Done</Pill>
            ) : (
              <>
                <Btn variant="primary" icon="check" onClick={() => { setDone((x) => [...x, d.id]); toast("Task created and email prepared", "ok"); }}>Do it</Btn>
                <Btn onClick={() => { setDone((x) => [...x, d.id]); toast("Snoozed until tomorrow", "muted"); }}>Snooze</Btn>
              </>
            )}
          </div>
        </Panel>
      ))}
    </div>
  );
}
```