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.
| Column | Type | Notes |
|---|---|---|
id | uuid | Added automatically — the primary key. |
title | text | Deal name, e.g. "Acme — annual plan". |
company | text | The account. |
value | numeric | Deal size. |
contact | text | Who you're talking to (optional). |
status | text | The pipeline stage. One of the stage names below. |
notes | text | Free 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):
plank_app_ensure_schema— idempotent; makes sure this workspace's data store exists.plank_app_add_table— tabledealswith columnstitle(text),company(text),value(numeric),contact(text),status(text),notes(text). Theidcolumn is added for you.- Optionally seed a couple of rows with
plank_app_insertso 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:
- Read —
fetch('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. - Add —
POST apps/dealswith the row object returns the new row (with its generatedid). - Delete —
DELETE 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
STAGESarray and use your own status names. - More fields — add columns (
plank_app_add_column, e.g.close_dateas date,owneras text) and show them on the card. - A deal detail page — link each card to
deal-detail.html?id=...in the same folder; readplank.params.idandfetch('apps/deals/' + plank.params.id). The id form returns a one-element array, so read the row asres.data[0]. See the multi-page section of App data & databases. - A pipeline total — sum
valueacross the loaded deals and show it in the header. - An activity log per deal — add a
jsonbcolumn (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 todeals. See Storing a list or object inside a row (JSON).