Plank help · updated 2026-07-01

Build a sales pipeline CRM

A ready-made recipe for a drag-and-drop sales pipeline — one data table plus a kanban dashboard that saves every move. Reusable in any workspace.

Agents: fetch the raw markdown of this page at /en/help/crm-sales-pipeline.md

Build a sales pipeline CRM

This is a complete, copy-ready recipe for a working sales pipeline: a single data table plus a drag-and-drop kanban dashboard where moving a card between columns saves the change. It uses the always-on app-data tools — no setup, no integration to install. Any workspace can ask for it ("build me the sales-pipeline CRM"), and the assistant reproduces exactly what's below.

If you only need the storage concepts, read App data & databases first. This page is the concrete build.

When to build this

Reach for it whenever the user is tracking deals through stages — sales, but also any pipeline shape: candidates through hiring stages, tickets through a workflow, leads through follow-up. If the work is "move things between columns and remember where they are," this is the pattern.

For a one-off document (a single report or export), use a normal file instead — see How Plank delivers finished documents.

The data model

One table, deals. Each row is a deal; its status column is which pipeline stage it's in.

ColumnTypeNotes
iduuidAdded automatically — the primary key.
titletextDeal name, e.g. "Acme — annual plan".
companytextThe account.
valuenumericDeal size.
contacttextWho you're talking to (optional).
statustextThe pipeline stage. One of the stage names below.
notestextFree notes (optional).

Stages (the kanban columns, left → right): Lead, Contacted, Proposal, Won, Lost. New deals start at Lead.

Build it

Create the table with the app-data tools (the assistant does this, not the user):

  1. plank_app_ensure_schema — idempotent; makes sure this workspace's data store exists.
  2. plank_app_add_table — table deals with columns title (text), company (text), value (numeric), contact (text), status (text), notes (text). The id column is added for you.
  3. Optionally seed a couple of rows with plank_app_insert so the board isn't empty on first open.

Then write the dashboard as a single .html file in the workspace.

The dashboard

This HTML is the whole app. It reads deals through the file viewer's signed prefix (fetch('apps/deals')), renders one column per stage, and on drop PATCHes the moved deal's status — so the move persists. The "Add deal" form POSTs a new row. Save it as e.g. sales-pipeline.html and open it.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sales pipeline</title>
<style>
  :root { --blue:#4F6DF5; --ivory:#FAFAF7; --stone:#F0EDE8; --ink:#3A3A42; }
  * { box-sizing:border-box; }
  body { margin:0; font-family:Inter,system-ui,sans-serif; background:var(--ivory); color:var(--ink); }
  header { padding:20px 24px 8px; }
  h1 { margin:0; font-size:20px; letter-spacing:-0.03em; }
  form { display:flex; gap:8px; padding:8px 24px 16px; flex-wrap:wrap; }
  input { padding:8px 10px; border:1px solid #d9d4cc; border-radius:8px; font:inherit; }
  button { background:var(--blue); color:#fff; border:0; border-radius:8px; padding:8px 14px; font:inherit; cursor:pointer; }
  .board { display:flex; gap:16px; padding:0 24px 24px; overflow-x:auto; }
  .col { background:var(--stone); border-radius:14px; padding:12px; min-width:230px; flex:1; }
  .col h2 { font-size:12px; text-transform:uppercase; letter-spacing:0.05em; margin:4px 6px 12px; color:#6b6b73; }
  .col.over { outline:2px dashed var(--blue); outline-offset:-2px; }
  .cards { min-height:48px; display:flex; flex-direction:column; gap:8px; }
  .card { background:#fff; border:1px solid #e7e3dc; border-radius:10px; padding:12px; cursor:grab; box-shadow:0 1px 2px rgba(0,0,0,.04); }
  .card.drag { opacity:.4; }
  .card .name { font-weight:600; }
  .card .co { font-size:13px; color:#6b6b73; }
  .card .val { font-size:13px; color:var(--blue); font-weight:600; margin-top:4px; }
</style>
</head>
<body>
<header><h1>Sales pipeline</h1></header>
<form id="add">
  <input name="title" placeholder="Deal title" required />
  <input name="company" placeholder="Company" />
  <input name="value" type="number" placeholder="Value" />
  <button>Add deal</button>
</form>
<div class="board" id="board"></div>
<script>
  var STAGES = ["Lead", "Contacted", "Proposal", "Won", "Lost"];
  var board = document.getElementById("board");

  function api(path, opts) {
    return fetch(path, opts).then(function (r) { return r.json(); });
  }

  function cardEl(deal) {
    var el = document.createElement("div");
    el.className = "card";
    el.draggable = true;
    el.dataset.id = deal.id;
    el.innerHTML = '<div class="name"></div><div class="co"></div><div class="val"></div>';
    el.querySelector(".name").textContent = deal.title || "(untitled)";
    el.querySelector(".co").textContent = deal.company || "";
    el.querySelector(".val").textContent = deal.value ? "$" + deal.value : "";
    el.addEventListener("dragstart", function (e) {
      e.dataTransfer.setData("id", deal.id);
      el.classList.add("drag");
    });
    el.addEventListener("dragend", function () { el.classList.remove("drag"); });
    return el;
  }

  function render(deals) {
    board.innerHTML = "";
    STAGES.forEach(function (stage) {
      var col = document.createElement("div");
      col.className = "col";
      col.dataset.stage = stage;
      col.innerHTML = '<h2></h2><div class="cards"></div>';
      col.querySelector("h2").textContent = stage;
      var cards = col.querySelector(".cards");
      deals
        .filter(function (d) { return (d.status || "Lead") === stage; })
        .forEach(function (d) { cards.appendChild(cardEl(d)); });
      col.addEventListener("dragover", function (e) { e.preventDefault(); col.classList.add("over"); });
      col.addEventListener("dragleave", function () { col.classList.remove("over"); });
      col.addEventListener("drop", function (e) {
        e.preventDefault();
        col.classList.remove("over");
        var id = e.dataTransfer.getData("id");
        var card = document.querySelector('[data-id="' + id + '"]');
        if (!card) return;
        var from = card.parentNode;
        cards.appendChild(card); // optimistic move
        // Persist it. If the write fails (e.g. you only have view access, or
        // you're offline), revert the move so the board never shows an
        // unsaved change as if it stuck.
        fetch("apps/deals/" + encodeURIComponent(id), {
          method: "PATCH",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ status: stage }),
        }).then(function (r) {
          if (!r.ok) { from.appendChild(card); alert("Could not save (status " + r.status + ")"); }
        }).catch(function () { from.appendChild(card); alert("Could not save — are you offline?"); });
      });
      board.appendChild(col);
    });
  }

  function load() {
    api("apps/deals").then(function (res) { render(res.data || []); });
  }

  document.getElementById("add").addEventListener("submit", function (e) {
    e.preventDefault();
    var f = e.target;
    var row = {
      title: f.title.value,
      company: f.company.value,
      value: f.value.value ? Number(f.value.value) : null,
      status: "Lead",
    };
    api("apps/deals", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(row),
    }).then(function () { f.reset(); load(); });
  });

  load();
</script>
</body>
</html>

Why it persists

The dashboard runs in the file viewer and talks to the table through relative apps/… calls:

  • Readfetch('apps/deals') returns { data: [ …rows… ] }.
  • Move a card — on drop, PATCH apps/deals/<id> with { "status": "<new stage>" } updates that row. Next open, the card is in its new column.
  • AddPOST apps/deals with the row object returns the new row (with its generated id).
  • DeleteDELETE apps/deals/<id> removes a deal (wire it to a card menu if you want one).

No buttons-that-only-look-like-they-work: every interaction writes to the table.

Reuse in another workspace

This recipe is workspace-agnostic. In any space, the user can say "build the sales-pipeline CRM from the docs" (or just "set up a sales pipeline"), and the assistant recreates the deals table and this dashboard there. Each workspace gets its own private deals data — they never mix.

Make it yours

Common extensions, all small changes to the above:

  • Different stages — edit the STAGES array and use your own status names.
  • More fields — add columns (plank_app_add_column, e.g. close_date as date, owner as text) and show them on the card.
  • A deal detail page — link each card to deal-detail.html?id=... in the same folder; read plank.params.id and fetch('apps/deals/' + plank.params.id). The id form returns a one-element array, so read the row as res.data[0]. See the multi-page section of App data & databases.
  • A pipeline total — sum value across the loaded deals and show it in the header.
  • An activity log per deal — add a jsonb column (e.g. activities) and append touches to it as a list; good for a read-only timeline shown on the card. If you instead need to filter or report on individual activities, give them their own table linked to deals. See Storing a list or object inside a row (JSON).