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 — на каком этапе воронки она находится.

КолонкаТипПримечания
iduuidДобавляется автоматически — первичный ключ.
titletextНазвание сделки, напр. «Acme — годовой план».
companytextКомпания.
valuenumericРазмер сделки.
contacttextС кем общаетесь (необязательно).
statustextЭтап воронки. Одно из названий этапов ниже.
notestextСвободные заметки (необязательно).

Этапы (колонки канбана, слева → направо): Lead, Contacted, Proposal, Won, Lost. Новые сделки начинаются с Lead.

Как собрать

Создайте таблицу инструментами данных приложений (это делает ассистент, а не пользователь):

  1. plank_app_ensure_schema — идемпотентно; гарантирует, что хранилище данных этого пространства существует.
  2. plank_app_add_table — таблица deals с колонками title (text), company (text), value (numeric), contact (text), status (text), notes (text). Колонка id добавляется за вас.
  3. По желанию добавьте пару строк через 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).