Build a Visual Sales Pipeline App

Build a drag-and-drop visual sales pipeline app in DenchClaw using the App Builder, with real-time deal movement and revenue totals per stage.

Mark Rachapoom
Mark Rachapoom
·6 min read
Build a Visual Sales Pipeline App

DenchClaw has a built-in Kanban view for deals, but there are good reasons to build a custom pipeline app: you want to add revenue totals per column, show probability-weighted values, highlight deals that have been stuck too long, or build a view tailored specifically to how your sales process works.

This guide builds a visual pipeline app as a Dench App — a drag-and-drop Kanban board with stage revenue totals, deal aging indicators, and quick-edit functionality.

What You're Building#

  • Drag-and-drop Kanban board with your deal stages as columns
  • Revenue total displayed at the top of each column
  • Deal cards showing: company name, deal value, assigned owner, days in stage
  • Visual indicator for deals stuck > 14 days in a stage
  • One-click stage advancement

Step 1: Set Up the App#

mkdir -p ~/.openclaw-dench/workspace/apps/sales-pipeline.dench.app

.dench.yaml:

name: Sales Pipeline
description: Visual drag-and-drop pipeline with revenue by stage
icon: git-branch
version: 1.0.0
permissions:
  - read:crm
  - write:crm
display: tab

Step 2: HTML Structure#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Sales Pipeline</title>
  <style>
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 20px; overflow-x: auto; }
    .pipeline { display: flex; gap: 16px; min-height: calc(100vh - 60px); }
    .stage-col { min-width: 260px; max-width: 300px; background: #1e293b; border-radius: 12px; padding: 16px; flex-shrink: 0; }
    .stage-header { margin-bottom: 16px; }
    .stage-title { font-weight: 700; font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; }
    .stage-revenue { font-size: 20px; font-weight: 800; color: #e2e8f0; margin-top: 4px; }
    .stage-count { font-size: 12px; color: #64748b; margin-top: 2px; }
    .deal-card { background: #0f172a; border-radius: 8px; padding: 12px; margin-bottom: 10px; cursor: grab; border: 1px solid #334155; transition: border-color 0.15s; }
    .deal-card:hover { border-color: #6366f1; }
    .deal-card.stuck { border-left: 3px solid #ef4444; }
    .deal-card.dragging { opacity: 0.5; }
    .deal-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
    .deal-value { color: #10b981; font-weight: 700; font-size: 16px; }
    .deal-meta { display: flex; justify-content: space-between; margin-top: 8px; font-size: 12px; color: #64748b; }
    .days-badge { padding: 2px 8px; border-radius: 999px; font-size: 11px; }
    .days-ok { background: #10b98120; color: #10b981; }
    .days-warn { background: #f59e0b20; color: #f59e0b; }
    .days-stuck { background: #ef444420; color: #ef4444; }
    .drop-zone { min-height: 60px; border: 2px dashed #334155; border-radius: 8px; }
    .drop-zone.over { border-color: #6366f1; background: #6366f110; }
  </style>
</head>
<body>
  <div id="pipeline" class="pipeline"></div>
  <script src="pipeline.js"></script>
</body>
</html>

Talk to Sales

Tell us about your team and we'll show you how Dench fits your workflow.

By submitting this form you agree to be contacted by our team. See our Privacy Policy and Terms of Service.

Step 3: Pipeline Logic with Drag-and-Drop#

pipeline.js:

const STAGES = ['Prospecting', 'Qualified', 'Proposal', 'Negotiation', 'Closed Won', 'Closed Lost'];
 
let deals = [];
let draggedDealId = null;
 
async function loadDeals() {
  deals = await dench.db.query(`
    SELECT
      id,
      "Company",
      "Deal Name",
      "Value",
      "Stage",
      "Owner",
      "Stage Changed Date",
      DATE_DIFF('day', CAST("Stage Changed Date" AS DATE), CURRENT_DATE) AS days_in_stage
    FROM v_deals
    WHERE "Stage" NOT IN ('Closed Lost')
    ORDER BY CAST("Value" AS DOUBLE) DESC NULLS LAST
  `);
  renderPipeline();
}
 
function formatCurrency(val) {
  if (!val) return '$0';
  return '$' + Number(val).toLocaleString('en-US', { maximumFractionDigits: 0 });
}
 
function daysClass(days) {
  if (!days || days < 7) return 'days-ok';
  if (days < 14) return 'days-warn';
  return 'days-stuck';
}
 
function renderPipeline() {
  const container = document.getElementById('pipeline');
  container.innerHTML = '';
 
  STAGES.forEach(stage => {
    const stageDeals = deals.filter(d => d.Stage === stage);
    const totalValue = stageDeals.reduce((sum, d) => sum + (Number(d.Value) || 0), 0);
 
    const col = document.createElement('div');
    col.className = 'stage-col';
    col.dataset.stage = stage;
 
    col.innerHTML = `
      <div class="stage-header">
        <div class="stage-title">${stage}</div>
        <div class="stage-revenue">${formatCurrency(totalValue)}</div>
        <div class="stage-count">${stageDeals.length} deal${stageDeals.length !== 1 ? 's' : ''}</div>
      </div>
      <div class="cards-container">
        ${stageDeals.map(deal => `
          <div class="deal-card ${deal.days_in_stage > 14 ? 'stuck' : ''}"
               data-id="${deal.id}" draggable="true">
            <div class="deal-name">${deal.Company || deal['Deal Name'] || 'Unnamed'}</div>
            <div class="deal-value">${formatCurrency(deal.Value)}</div>
            <div class="deal-meta">
              <span>${deal.Owner || 'Unassigned'}</span>
              <span class="days-badge ${daysClass(deal.days_in_stage)}">
                ${deal.days_in_stage != null ? `${deal.days_in_stage}d` : 'New'}
              </span>
            </div>
          </div>
        `).join('')}
        <div class="drop-zone" data-stage="${stage}"></div>
      </div>
    `;
    container.appendChild(col);
  });
 
  attachDragHandlers();
}
 
function attachDragHandlers() {
  document.querySelectorAll('.deal-card').forEach(card => {
    card.addEventListener('dragstart', e => {
      draggedDealId = card.dataset.id;
      card.classList.add('dragging');
      e.dataTransfer.effectAllowed = 'move';
    });
    card.addEventListener('dragend', () => {
      card.classList.remove('dragging');
    });
  });
 
  document.querySelectorAll('.drop-zone').forEach(zone => {
    zone.addEventListener('dragover', e => {
      e.preventDefault();
      zone.classList.add('over');
    });
    zone.addEventListener('dragleave', () => zone.classList.remove('over'));
    zone.addEventListener('drop', async e => {
      e.preventDefault();
      zone.classList.remove('over');
      const newStage = zone.dataset.stage;
      if (!draggedDealId || !newStage) return;
      await moveDeal(draggedDealId, newStage);
    });
  });
}
 
async function moveDeal(dealId, newStage) {
  // Optimistically update local state
  const deal = deals.find(d => d.id === dealId);
  if (deal) {
    deal.Stage = newStage;
    deal.days_in_stage = 0;
  }
  renderPipeline();
 
  // Persist to DenchClaw CRM
  try {
    await dench.agent.run(`Update deal ${dealId}: set Stage to "${newStage}" and Stage Changed Date to today`);
    await dench.ui.toast({ message: `Moved to ${newStage}`, type: 'success' });
  } catch (err) {
    await dench.ui.toast({ message: 'Failed to save stage change', type: 'error' });
    loadDeals(); // Reload to revert optimistic update
  }
}
 
// Real-time refresh
dench.events.on('entry:updated', loadDeals);
dench.events.on('entry:created', loadDeals);
 
loadDeals();

Step 4: Customize Your Stages#

The STAGES array at the top of pipeline.js should match your actual deal stages. To get them dynamically from DenchClaw:

const stagesResult = await dench.db.query(`
  SELECT DISTINCT "Stage" FROM v_deals WHERE "Stage" IS NOT NULL ORDER BY "Stage"
`);
const STAGES = stagesResult.map(r => r.Stage);

Or ask DenchClaw: "What are the status values for my deals object?"

Adding Probability-Weighted Revenue#

A common pipeline metric is expected revenue — deal value × close probability. Add a Probability field to your deals object, then update the stage total calculation:

const weightedValue = stageDeals.reduce((sum, d) => {
  const val = Number(d.Value) || 0;
  const prob = Number(d.Probability) || 0;
  return sum + (val * prob / 100);
}, 0);

Frequently Asked Questions#

How do I add a "New Deal" button to the pipeline?#

Add a button to each column header that calls dench.agent.run("Create a new deal in stage X"). Or build a modal form and use the CRM write API to insert a new entry.

Can I filter the pipeline by owner?#

Yes. Add an owner filter select, load distinct owners from v_deals, and pass the filter into your loadDeals query with a WHERE "Owner" = ? clause.

How do I handle the "Closed Lost" stage?#

The example excludes Closed Lost from the main view. You can add a toggle to show/hide lost deals, or build a separate "Lost Deals" section below the pipeline.

Is the drag-and-drop mobile-friendly?#

HTML5 drag-and-drop doesn't work on touch devices. For mobile support, add a touch drag library like Sortable.js (CDN: https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js).

How do I add deal notes or activity history?#

Use dench.apps.navigate('/entry/${dealId}') to open the deal's full entry page in DenchClaw. Or add an inline panel by fetching the entry document: await dench.db.query("SELECT content FROM documents WHERE entry_id = ?", [dealId]).

Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →

Related articles

Keep reading

View all