Kanban CRM Sales Deals
This example demonstrates a sophisticated Kanban board for CRM sales pipeline management with drag-and-drop functionality, keyboard navigation, and real-time updates.
Key Features
Drag-and-drop deal management between pipeline stages
Keyboard navigation with arrow keys and tab support
Real-time search and filtering across deals
Stage-based organization with visual indicators
Deal detail navigation with click-to-view functionality
Responsive design that works on mobile and desktop
Accessibility features with ARIA labels and screen reader support
Dynamic metadata resolution for flexible table structures
Use Cases
Perfect for applications requiring:
Sales pipeline management and CRM systems
Project management with workflow stages
Task management and Kanban boards
Lead tracking and conversion funnels
Support ticket management systems
Any workflow requiring stage-based organization
Implementation Overview
This widget uses the Other Tables Data pattern with dynamic metadata resolution to work with any "Deals" datatable structure. Key features include:
Advanced drag-and-drop with visual feedback
Comprehensive keyboard navigation for accessibility
Real-time search across deal titles and customers
Responsive design with mobile optimization
Professional styling with stage-specific colors and icons
Code Example
<!DOCTYPE html>
<html lang="en">
<head>
<title>Kanban Board Widget</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/[email protected]/umd/react.production.min.js" crossorigin></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone/babel.min.js" crossorigin></script>
<style>
html, body, #root {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
}
.kanban-scroll-x {
overflow-x: auto;
overflow-y: hidden;
width: 100%;
height: 100%;
min-height: 400px;
display: flex;
flex-direction: row;
}
.kanban-col {
min-width: 320px;
max-width: 380px;
flex: 1 1 320px;
display: flex;
flex-direction: column;
margin-right: 1.5rem;
background: #fff;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(10,10,10,0.06);
height: 100%;
}
.kanban-col:last-child {
margin-right: 0;
}
.kanban-col-header {
padding: 1rem 1rem 0.5rem 1rem;
border-bottom: 1px solid #f0f0f0;
font-weight: 600;
font-size: 1.1rem;
background: #fafbfc;
border-radius: 6px 6px 0 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.kanban-card-list {
flex: 1 1 auto;
padding: 1rem;
min-height: 60px;
background: transparent;
transition: background 0.2s;
}
.kanban-card-list.drag-over {
background: #f5faff;
}
.kanban-card {
background: #f8f9fa;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(10,10,10,0.04);
margin-bottom: 1rem;
padding: 1rem;
cursor: grab;
transition: box-shadow 0.2s, background 0.2s;
outline: none;
border: 2px solid transparent;
display: flex;
flex-direction: column;
}
.kanban-card:last-child {
margin-bottom: 0;
}
.kanban-card.dragging {
opacity: 0.5;
background: #e3e9f7;
box-shadow: 0 4px 12px rgba(10,10,10,0.10);
}
.kanban-card:focus {
border: 2px solid #3273dc;
background: #e8f0fe;
}
.kanban-card .card-title {
font-weight: 600;
font-size: 1.05rem;
margin-bottom: 0.25rem;
color: #363636;
}
.kanban-card .card-row {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
font-size: 0.95rem;
}
.kanban-card .card-row:last-child {
margin-bottom: 0;
}
.kanban-card .card-value {
font-weight: 500;
color: #209cee;
margin-right: 0.5rem;
}
.kanban-card .card-deadline {
color: #b86b00;
font-size: 0.93rem;
margin-left: 0.5rem;
}
.kanban-card .card-owner {
display: flex;
align-items: center;
font-size: 0.95rem;
color: #4a4a4a;
}
.kanban-card .card-customer {
color: #7a7a7a;
font-size: 0.93rem;
margin-left: 0.5rem;
}
.kanban-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
@media (max-width: 900px) {
.kanban-col {
min-width: 260px;
}
}
@media (max-width: 600px) {
.kanban-col {
min-width: 180px;
}
.kanban-scroll-x {
min-height: 300px;
}
}
</style>
</head>
<body>
<div id="root" style="height:100%;width:100%;"></div>
<script type="text/babel">
const buzzyFrameAPI = new BuzzyFrameAPI();
const DEAL_TABLE_NAME = 'Deals';
const DEAL_FIELD_NAMES = {
title: 'title',
value: 'value',
stage: 'stage',
owner: 'owner',
customer: 'customer',
deadline: 'deadline'
};
const STAGES = [
{ key: "Suspect", color: "#bdbdbd", icon: "help" },
{ key: "Lead", color: "#209cee", icon: "person_search" },
{ key: "Warm", color: "#ffdd57", icon: "local_fire_department" },
{ key: "Hot", color: "#ff3860", icon: "whatshot" },
{ key: "Closed", color: "#23d160", icon: "check_circle" },
{ key: "Lost", color: "#7a7a7a", icon: "cancel" }
];
function KanbanBoard() {
const { useState, useEffect, useRef } = React;
const [deals, setDeals] = useState([]);
const [search, setSearch] = useState('');
const [draggedDeal, setDraggedDeal] = useState(null);
const [dragOverStage, setDragOverStage] = useState(null);
const [keyboardFocus, setKeyboardFocus] = useState({ dealId: null });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const cardRefs = useRef({});
const [dealTableID, setDealTableID] = useState(null);
const [dealFieldIDs, setDealFieldIDs] = useState({});
const [dealDetailScreenID, setDealDetailScreenID] = useState(null);
useEffect(() => {
async function initialiseAll() {
try {
console.log('[init] Calling buzzyFrameAPI.initialise()...');
const init = await buzzyFrameAPI.initialise();
const metadata = await buzzyFrameAPI.fetchDatatableMetadata({
dataTableID: init.rowJSON?.parentResourceID,
});
const dealsTable = metadata.find(
table => table.dataTableName === DEAL_TABLE_NAME
);
if (!dealsTable) {
throw new Error(`Table "${DEAL_TABLE_NAME}" not found. Please ensure there is a table named "${DEAL_TABLE_NAME}".`);
}
setDealTableID(dealsTable.id);
const fieldIDs = {};
Object.entries(DEAL_FIELD_NAMES).forEach(([key, fieldName]) => {
const field = dealsTable.fields.find(f => f.fieldName === fieldName);
if (field) {
fieldIDs[key] = field.id;
} else {
console.warn(`Field "${fieldName}" not found in table "${DEAL_TABLE_NAME}"`);
}
});
setDealFieldIDs(fieldIDs);
console.log('[init] Metadata resolved:', {
dealTableID: dealsTable.id,
fieldIDs
});
buzzyFrameAPI.addMicroappListener({
microAppID: dealsTable.id,
listener: async () => {
console.log('[listener] Deals data changed, refreshing...');
await fetchDeals();
}
});
try {
const detailScreenID = await buzzyFrameAPI.getScreenID({ screenName: 'Deal Detail' });
setDealDetailScreenID(detailScreenID);
} catch (err) {
console.warn('Deal detail screen not found:', err);
}
} catch (err) {
setError('Error during initialisation: ' + (err.message || err));
console.error('[error]', err);
}
}
initialiseAll();
}, []);
useEffect(() => {
if (dealTableID) {
fetchDeals();
}
}, [dealTableID]);
async function fetchDeals() {
if (!dealTableID) return;
setLoading(true);
setError('');
console.log('[fetchDeals] Fetching deals...');
try {
const dealsData = await buzzyFrameAPI.fetchDataTableRows({
microAppID: dealTableID
});
console.log('[fetchDeals] Got', dealsData.length, 'deals');
setDeals(dealsData);
} catch (err) {
setError('Error fetching deals: ' + (err.message || err));
console.error('[error]', err);
} finally {
setLoading(false);
}
}
function formatCurrency(val) {
if (typeof val !== 'number') return '';
return '$' + val.toLocaleString();
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
if (isNaN(d)) return '';
return d.toLocaleDateString();
}
const filteredDeals = deals.filter(deal => {
if (search) {
const s = search.toLowerCase();
const title = (deal.title || '').toLowerCase();
const customer = deal.customer || '';
return title.includes(s) || customer.includes(s);
}
return true;
});
const dealsByStage = {};
STAGES.forEach(s => { dealsByStage[s.key] = []; });
filteredDeals.forEach(deal => {
if (STAGES.some(s => s.key === deal.stage)) {
dealsByStage[deal.stage].push(deal);
}
});
function onDragStart(e, deal) {
setDraggedDeal(deal);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', deal._id);
}
function onDragEnd() {
setDraggedDeal(null);
setDragOverStage(null);
}
function onDragOverCol(e, stage) {
e.preventDefault();
setDragOverStage(stage);
}
async function onDropCol(e, stage) {
e.preventDefault();
setDragOverStage(null);
if (draggedDeal && draggedDeal.stage !== stage) {
try {
await buzzyFrameAPI.updateMicroappRow({
body: {
rowID: draggedDeal._id,
rowData: { stage },
ignoreActionRules: true
}
});
console.log(`[onDropCol] Successfully updated deal ${draggedDeal._id} to stage ${stage}`);
} catch (error) {
console.error('Error updating deal stage:', error);
setError('Error updating deal stage: ' + (error.message || error));
}
}
setDraggedDeal(null);
}
function onCardKeyDown(e, deal, stageIdx, cardIdx) {
if (e.key === 'Enter' || e.key === ' ') {
setDraggedDeal(deal);
setDragOverStage(deal.stage);
e.preventDefault();
} else if (e.key === 'ArrowRight') {
if (stageIdx < STAGES.length - 1) {
const nextStage = STAGES[stageIdx + 1].key;
updateDealStage(deal, nextStage);
}
e.preventDefault();
} else if (e.key === 'ArrowLeft') {
if (stageIdx > 0) {
const prevStage = STAGES[stageIdx - 1].key;
updateDealStage(deal, prevStage);
}
e.preventDefault();
}
}
async function updateDealStage(deal, newStage) {
try {
await buzzyFrameAPI.updateMicroappRow({
body: {
rowID: deal._id,
rowData: { stage: newStage },
ignoreActionRules: true
}
});
console.log(`[updateDealStage] Successfully updated deal ${deal._id} to stage ${newStage}`);
} catch (error) {
console.error('Error updating deal stage:', error);
setError('Error updating deal stage: ' + (error.message || error));
}
}
async function onCardClick(deal) {
if (dealDetailScreenID) {
buzzyFrameAPI.navigate({ screenID: dealDetailScreenID, rowID: deal._id });
} else {
console.warn('Deal detail screen not available');
}
}
return (
<div className="is-flex is-flex-direction-column" style={{height:'100%',width:'100%'}}>
<div className="kanban-filters">
<div className="field has-addons">
<p className="control has-icons-left">
<input
className="input"
type="search"
placeholder="Search deals..."
aria-label="Search deals"
value={search}
onChange={e => setSearch(e.target.value)}
/>
<span className="icon is-left">
<span className="material-symbols-outlined">search</span>
</span>
</p>
{search && (
<p className="control">
<button className="button is-light" aria-label="Clear search" onClick={() => setSearch('')}>
<span className="material-symbols-outlined">close</span>
</button>
</p>
)}
</div>
</div>
{error && <div style={{color: 'red', marginBottom: '1rem'}}>{error}</div>}
{loading && <div style={{marginBottom: '1rem'}}>Loading deals...</div>}
<div className="kanban-scroll-x" role="list" aria-label="Kanban board" tabIndex={0}>
{STAGES.map((stage, stageIdx) => (
<section
key={stage.key}
className="kanban-col"
aria-label={stage.key + ' column'}
onDragOver={e => onDragOverCol(e, stage.key)}
onDrop={e => onDropCol(e, stage.key)}
tabIndex={-1}
>
<header className="kanban-col-header" style={{borderBottomColor: stage.color}}>
<span>
<span className="material-symbols-outlined" style={{color: stage.color, verticalAlign:'middle', marginRight:'0.3em'}}>{stage.icon}</span>
{stage.key}
</span>
<span className="tag is-light" aria-label={dealsByStage[stage.key].length + ' deals'}>{dealsByStage[stage.key].length}</span>
</header>
<div
className={
'kanban-card-list' +
(dragOverStage === stage.key ? ' drag-over' : '')
}
role="list"
aria-label={stage.key + ' deals'}
>
{dealsByStage[stage.key].map((deal, cardIdx) => {
return (
<article
key={deal._id}
className={
'kanban-card' +
(draggedDeal && draggedDeal._id === deal._id ? ' dragging' : '')
}
role="listitem"
tabIndex={0}
aria-label={deal.title}
ref={el => (cardRefs.current[deal._id] = el)}
draggable
onDragStart={e => onDragStart(e, deal)}
onDragEnd={onDragEnd}
onKeyDown={e => onCardKeyDown(e, deal, stageIdx, cardIdx)}
onFocus={() => setKeyboardFocus({ dealId: deal._id })}
onBlur={() => setKeyboardFocus({ dealId: null })}
aria-grabbed={draggedDeal && draggedDeal._id === deal._id ? 'true' : 'false'}
aria-describedby={'deal-desc-' + deal._id}
onClick={() => onCardClick(deal)}
>
<div className="card-title">{deal.title}</div>
<div className="card-row">
<span className="card-value">
<span className="material-symbols-outlined" style={{fontSize:'1.1em',verticalAlign:'middle'}}>attach_money</span>
{formatCurrency(deal.value)}
</span>
{deal.deadline && (
<span className="card-deadline">
<span className="material-symbols-outlined" style={{fontSize:'1.1em',verticalAlign:'middle'}}>event</span>
{formatDate(deal.deadline)}
</span>
)}
</div>
<div className="card-row card-owner">
<span className="material-symbols-outlined card-avatar" style={{background:'#e0e0e0',display:'inline-flex',alignItems:'center',justifyContent:'center',width:'28px',height:'28px',fontSize:'1.2em'}}>person</span>
<span>{deal.owner || 'Unassigned'}</span>
{deal.customer && (
<span className="card-customer">
<span className="material-symbols-outlined" style={{fontSize:'1em',verticalAlign:'middle'}}>business</span>
{deal.customer}
</span>
)}
</div>
<div id={'deal-desc-' + deal._id} className="is-sr-only">
Value: {formatCurrency(deal.value)}. Deadline: {formatDate(deal.deadline)}. Owner: {deal.owner || 'Unassigned'}. Customer: {deal.customer || ''}.
</div>
</article>
);
})}
</div>
</section>
))}
</div>
</div>
);
}
ReactDOM.render(<KanbanBoard />, document.getElementById('root'));
</script>
</body>
</html>
Key Concepts
Advanced Drag-and-Drop
Visual Feedback: Cards and columns provide clear visual feedback during drag operations
Stage Updates: Automatic database updates when deals are moved between stages
Keyboard Support: Full keyboard navigation with arrow keys for accessibility
Touch Support: Works seamlessly on mobile devices with touch interactions
Dynamic Metadata Resolution
Flexible Structure: Automatically discovers table and field IDs by name
Error Handling: Graceful handling when expected tables or fields are missing
Field Mapping: Maps business field names to actual database field IDs
Validation: Warns when expected fields are not found
Professional CRM Features
Search Functionality: Real-time search across deal titles and customers
Currency Formatting: Professional currency display with locale formatting
Date Formatting: User-friendly date display for deadlines
Stage Management: Visual stage indicators with colors and icons
Accessibility & UX
ARIA Labels: Comprehensive accessibility support for screen readers
Keyboard Navigation: Full keyboard support for all interactions
Responsive Design: Optimized for desktop, tablet, and mobile devices
Loading States: Clear feedback during data loading and updates
This Kanban board example demonstrates how code widgets can create sophisticated business applications with professional-grade user interfaces and full accessibility support.
Last updated