/* kanban-board.js - grid rendering and drag-drop between cells */
(function () {
'use strict';
var boardId = KANBAN.boardId;
var dragState = {
active: false,
x: 0,
y: 0,
rafId: 0
};
function post(url, data, cb) {
var params = new URLSearchParams();
Object.keys(data).forEach(function (k) { params.append(k, data[k]); });
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
})
.then(function (r) { return r.json(); })
.then(cb)
.catch(function (e) { console.error(url, e); });
}
function esc(s) {
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function applyGridTemplate() {
var grid = document.getElementById('kanban-grid');
var colHs = grid.querySelectorAll('.kanban-col-header');
var cols = '240px';
colHs.forEach(function () { cols += ' 220px'; });
grid.style.gridTemplateColumns = cols;
}
function buildCardEl(card) {
var div = document.createElement('div');
div.className = 'kanban-card';
div.dataset.id = card.id;
div.dataset.columnId = card.column_id;
div.dataset.laneId = card.swim_lane_id;
div.innerHTML =
'
' + esc(card.job_number || '') + '
' +
'' + esc(card.job_name || '') + '
';
div.addEventListener('click', function () {
window.KanbanModal.openEdit(
card.id,
card.column_id,
card.swim_lane_id,
card.job_number,
card.job_name
);
});
return div;
}
function renderCards() {
KANBAN.cards.forEach(function (card) {
var cell = document.querySelector(
'.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]'
);
if (cell) {
cell.appendChild(buildCardEl(card));
}
});
}
function handleDragEnd(evt) {
var cardId = evt.item.dataset.id;
var newColId = evt.to.dataset.colId;
var newLaneId = evt.to.dataset.laneId;
var newPos = evt.newIndex;
var siblings = [];
evt.to.querySelectorAll('.kanban-card').forEach(function (el) {
siblings.push(el.dataset.id);
});
var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); });
if (card) {
card.column_id = parseInt(newColId, 10);
card.swim_lane_id = parseInt(newLaneId, 10);
card.position = newPos;
}
evt.item.dataset.columnId = newColId;
evt.item.dataset.laneId = newLaneId;
post('/cards/' + cardId + '/move', {
column_id: newColId,
swim_lane_id: newLaneId,
position: newPos,
sibling_ids: siblings.join(',')
}, function (res) {
if (!res.ok) console.error('Move failed', res);
});
}
function createCellSortable(cell) {
Sortable.create(cell, {
group: 'cards',
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
handle: '.kanban-card',
delayOnTouchOnly: true,
delay: 120,
touchStartThreshold: 3,
fallbackTolerance: 4,
scroll: true,
bubbleScroll: true,
scrollSensitivity: 140,
scrollSpeed: 32,
onStart: function () { startEdgeAutoScroll(); },
onEnd: function (evt) {
stopEdgeAutoScroll();
handleDragEnd(evt);
}
});
}
function updatePointerFromEvent(evt) {
if (!evt) return;
if (evt.touches && evt.touches.length > 0) {
dragState.x = evt.touches[0].clientX;
dragState.y = evt.touches[0].clientY;
return;
}
if (evt.clientX !== undefined && evt.clientY !== undefined) {
dragState.x = evt.clientX;
dragState.y = evt.clientY;
}
}
function edgeScrollStep() {
if (!dragState.active) return;
var wrapper = document.querySelector('.kanban-wrapper');
if (wrapper) {
var rect = wrapper.getBoundingClientRect();
var edge = 110;
var maxStep = 40;
var dx = 0;
var dy = 0;
if (dragState.x > 0 && dragState.x < rect.left + edge) {
dx = -Math.min(maxStep, Math.ceil((rect.left + edge - dragState.x) / 3));
} else if (dragState.x > rect.right - edge && dragState.x < rect.right + edge) {
dx = Math.min(maxStep, Math.ceil((dragState.x - (rect.right - edge)) / 3));
}
if (dragState.y > 0 && dragState.y < rect.top + edge) {
dy = -Math.min(maxStep, Math.ceil((rect.top + edge - dragState.y) / 3));
} else if (dragState.y > rect.bottom - edge && dragState.y < rect.bottom + edge) {
dy = Math.min(maxStep, Math.ceil((dragState.y - (rect.bottom - edge)) / 3));
}
if (dx !== 0) wrapper.scrollLeft += dx;
if (dy !== 0) wrapper.scrollTop += dy;
}
dragState.rafId = window.requestAnimationFrame(edgeScrollStep);
}
function startEdgeAutoScroll() {
if (dragState.active) return;
dragState.active = true;
document.addEventListener('pointermove', updatePointerFromEvent, { passive: true });
document.addEventListener('touchmove', updatePointerFromEvent, { passive: true });
document.addEventListener('dragover', updatePointerFromEvent, { passive: true });
dragState.rafId = window.requestAnimationFrame(edgeScrollStep);
}
function stopEdgeAutoScroll() {
if (!dragState.active) return;
dragState.active = false;
if (dragState.rafId) {
window.cancelAnimationFrame(dragState.rafId);
dragState.rafId = 0;
}
document.removeEventListener('pointermove', updatePointerFromEvent);
document.removeEventListener('touchmove', updatePointerFromEvent);
document.removeEventListener('dragover', updatePointerFromEvent);
}
function initSortables() {
document.querySelectorAll('.kanban-cell').forEach(createCellSortable);
}
document.getElementById('btn-add-card').addEventListener('click', function () {
window.KanbanModal.openCreate(boardId, null, null);
});
window.KanbanBoard = {
onCardCreated: function (card) {
KANBAN.cards.push(card);
var cell = document.querySelector(
'.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]'
);
if (cell) {
cell.appendChild(buildCardEl(card));
}
},
onCardUpdated: function (id, jobNumber, jobName) {
var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); });
if (card) {
card.job_number = jobNumber;
card.job_name = jobName;
}
var el = document.querySelector('.kanban-card[data-id="' + id + '"]');
if (el) {
el.querySelector('.card-job-number').textContent = jobNumber;
el.querySelector('.card-job-name').textContent = jobName;
}
},
onCardDeleted: function (id) {
KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); });
var el = document.querySelector('.kanban-card[data-id="' + id + '"]');
if (el) el.remove();
},
addColumn: function (col) {
var grid = document.getElementById('kanban-grid');
var headers = grid.querySelectorAll('.kanban-col-header');
var refNode = headers.length ? headers[headers.length - 1].nextSibling : null;
var hdr = document.createElement('div');
hdr.className = 'kanban-col-header';
hdr.dataset.colId = col.id;
hdr.innerHTML = '' + esc(col.name) + '';
grid.insertBefore(hdr, refNode);
var laneHeaders = grid.querySelectorAll('.kanban-lane-header');
laneHeaders.forEach(function (lh) {
var laneId = lh.dataset.laneId;
var cell = document.createElement('div');
cell.className = 'kanban-cell';
cell.dataset.colId = col.id;
cell.dataset.laneId = laneId;
var row = lh.parentNode;
row.appendChild(cell);
createCellSortable(cell);
});
applyGridTemplate();
},
removeColumn: function (colId) {
document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove();
document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); });
KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); });
applyGridTemplate();
},
addLane: function (lane) {
var grid = document.getElementById('kanban-grid');
var colHeaders = grid.querySelectorAll('.kanban-col-header');
var lh = document.createElement('div');
lh.className = 'kanban-lane-header';
lh.dataset.laneId = lane.id;
lh.innerHTML = '' + esc(lane.name) + '';
grid.appendChild(lh);
colHeaders.forEach(function (ch) {
var cell = document.createElement('div');
cell.className = 'kanban-cell';
cell.dataset.colId = ch.dataset.colId;
cell.dataset.laneId = lane.id;
grid.appendChild(cell);
createCellSortable(cell);
});
applyGridTemplate();
},
removeLane: function (laneId) {
document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove();
document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); });
KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); });
},
renameColumn: function (colId, name) {
var hdr = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"] .col-label');
if (hdr) hdr.textContent = name;
},
renameLane: function (laneId, name) {
var hdr = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"] .lane-label');
if (hdr) hdr.textContent = name;
}
};
applyGridTemplate();
renderCards();
initSortables();
})();