Copy <!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>