Back to The Times of Claw

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

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>

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 →

Mark Rachapoom

Written by

Mark Rachapoom

Building the future of AI CRM software.

Continue reading

DENCH

© 2026 DenchHQ · San Francisco, CA