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