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.
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: tabStep 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 →
