Plank help · updated 2026-07-01
Создание CRM для воронки продаж
Готовый рецепт воронки продаж с перетаскиванием — одна таблица данных плюс канбан-дашборд, который сохраняет каждое перемещение. Подходит для любого рабочего пространства.
Agents: fetch the raw markdown of this page at /ru/help/crm-sales-pipeline.md
Создание CRM для воронки продаж
Это полный готовый рецепт работающей воронки продаж: одна таблица данных плюс канбан-дашборд с перетаскиванием, где перемещение карточки между колонками сохраняет изменение. Он использует всегда доступные инструменты данных приложений — никакой настройки, никакой интеграции для установки. Любое рабочее пространство может попросить об этом («сделай мне CRM для воронки продаж»), и ассистент воспроизведёт ровно то, что ниже.
Если вам нужны только концепции хранения, сначала прочитайте Данные приложений и базы данных. Эта страница — конкретная сборка.
Когда это строить
Используйте, когда пользователь ведёт сделки по этапам — продажи, но и любая форма воронки: кандидаты по этапам найма, тикеты по процессу, лиды по последующим контактам. Если работа сводится к «перемещать элементы между колонками и помнить, где они», — это тот самый шаблон.
Для разового документа (один отчёт или выгрузка) используйте обычный файл — смотрите Как Plank выдаёт готовые документы.
Модель данных
Одна таблица, deals. Каждая строка — сделка; её колонка status — на каком этапе воронки она находится.
| Колонка | Тип | Примечания |
|---|---|---|
id | uuid | Добавляется автоматически — первичный ключ. |
title | text | Название сделки, напр. «Acme — годовой план». |
company | text | Компания. |
value | numeric | Размер сделки. |
contact | text | С кем общаетесь (необязательно). |
status | text | Этап воронки. Одно из названий этапов ниже. |
notes | text | Свободные заметки (необязательно). |
Этапы (колонки канбана, слева → направо): Lead, Contacted, Proposal, Won, Lost. Новые сделки начинаются с Lead.
Как собрать
Создайте таблицу инструментами данных приложений (это делает ассистент, а не пользователь):
plank_app_ensure_schema— идемпотентно; гарантирует, что хранилище данных этого пространства существует.plank_app_add_table— таблицаdealsс колонкамиtitle(text),company(text),value(numeric),contact(text),status(text),notes(text). Колонкаidдобавляется за вас.- По желанию добавьте пару строк через
plank_app_insert, чтобы доска не была пустой при первом открытии.
Затем напишите дашборд как один файл .html в рабочем пространстве.
Дашборд
Этот HTML и есть всё приложение. Он читает сделки через подписанный префикс файлового просмотрщика (fetch('apps/deals')), отображает по одной колонке на этап, а при drop отправляет PATCH со статусом перемещённой сделки — поэтому перемещение сохраняется. Форма «Add deal» отправляет POST с новой строкой. Сохраните как, например, sales-pipeline.html и откройте.
<!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); // оптимистичное перемещение
// Сохраняем. Если запись не прошла (например, у вас только доступ на
// просмотр или вы офлайн), возвращаем карточку назад, чтобы доска не
// показывала несохранённое изменение как сохранённое.
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("Не удалось сохранить (статус " + r.status + ")"); }
}).catch(function () { from.appendChild(card); alert("Не удалось сохранить — вы офлайн?"); });
});
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>
Почему это сохраняется
Дашборд работает в файловом просмотрщике и общается с таблицей через относительные вызовы apps/…:
- Чтение —
fetch('apps/deals')возвращает{ data: [ …строки… ] }. - Перемещение карточки — при drop
PATCH apps/deals/<id>с{ "status": "<новый этап>" }обновляет эту строку. При следующем открытии карточка уже в новой колонке. - Добавление —
POST apps/dealsс объектом строки возвращает новую строку (с её сгенерированнымid). - Удаление —
DELETE apps/deals/<id>удаляет сделку (привяжите к меню карточки, если нужно).
Никаких кнопок, которые только выглядят рабочими: каждое взаимодействие записывается в таблицу.
Повторное использование в другом пространстве
Этот рецепт не зависит от рабочего пространства. В любом пространстве пользователь может сказать «собери CRM для воронки продаж из документации» (или просто «настрой воронку продаж»), и ассистент воссоздаст таблицу deals и этот дашборд там. У каждого пространства свои приватные данные deals — они никогда не смешиваются.
Сделайте его своим
Частые расширения — все небольшие правки к вышеуказанному:
- Другие этапы — измените массив
STAGESи используйте свои названия статусов. - Больше полей — добавьте колонки (
plank_app_add_column, напр.close_dateкак date,ownerкак text) и покажите их на карточке. - Страница деталей сделки — свяжите каждую карточку с
deal-detail.html?id=...в той же папке; читайтеplank.params.idиfetch('apps/deals/' + plank.params.id). Форма с id возвращает массив из одного элемента, поэтому читайте строку какres.data[0]. Смотрите раздел о многостраничности в Данные приложений и базы данных. - Итог по воронке — просуммируйте
valueпо загруженным сделкам и покажите в шапке. - Журнал активности по сделке — добавьте колонку
jsonb(например,activities) и дописывайте касания в неё списком; удобно для таймлайна только для чтения на карточке. Если же вам нужно фильтровать или строить отчёты по отдельным активностям, дайте им собственную таблицу, связанную сdeals. Смотрите Хранение списка или объекта внутри строки (JSON).