/* kanban-board.js - grid rendering and drag-drop between cells */
(function () {
'use strict';
var boardId = KANBAN.boardId;
var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId);
var collapsedLaneIds = loadCollapsedLaneIds();
var searchState = {
query: ''
};
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 += ' 230px'; });
grid.style.gridTemplateColumns = cols;
}
function loadCollapsedLaneIds() {
var laneMap = {};
try {
var raw = window.localStorage.getItem(laneCollapseStorageKey);
if (!raw) return laneMap;
var arr = JSON.parse(raw);
if (!Array.isArray(arr)) return laneMap;
arr.forEach(function (laneId) {
laneMap[String(laneId)] = true;
});
} catch (e) {
console.warn('Failed to load lane collapse state', e);
}
return laneMap;
}
function saveCollapsedLaneIds() {
try {
window.localStorage.setItem(laneCollapseStorageKey, JSON.stringify(Object.keys(collapsedLaneIds)));
} catch (e) {
console.warn('Failed to save lane collapse state', e);
}
}
function setLaneCollapsed(laneId, isCollapsed) {
var laneKey = String(laneId);
var header = document.querySelector('.kanban-lane-header[data-lane-id="' + laneKey + '"]');
if (!header) return;
var laneCells = document.querySelectorAll('.kanban-cell[data-lane-id="' + laneKey + '"]');
header.classList.toggle('lane-collapsed', isCollapsed);
laneCells.forEach(function (cell) {
cell.classList.toggle('lane-collapsed', isCollapsed);
});
var toggleBtn = header.querySelector('.lane-toggle');
if (toggleBtn) {
toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
toggleBtn.title = isCollapsed ? 'Expand swim lane' : 'Collapse swim lane';
toggleBtn.setAttribute('aria-label', toggleBtn.title);
}
if (isCollapsed) {
collapsedLaneIds[laneKey] = true;
} else {
delete collapsedLaneIds[laneKey];
}
saveCollapsedLaneIds();
}
function toggleLaneCollapsed(laneId) {
var laneKey = String(laneId);
setLaneCollapsed(laneKey, !collapsedLaneIds[laneKey]);
}
function bindLaneHeaderToggle(headerEl) {
if (!headerEl) return;
var toggleBtn = headerEl.querySelector('.lane-toggle');
if (!toggleBtn) return;
if (!toggleBtn.dataset.boundToggle) {
toggleBtn.addEventListener('click', function (evt) {
evt.preventDefault();
evt.stopPropagation();
toggleLaneCollapsed(headerEl.dataset.laneId);
});
toggleBtn.dataset.boundToggle = '1';
}
}
function initLaneHeaderToggles() {
document.querySelectorAll('.kanban-lane-header').forEach(function (headerEl) {
bindLaneHeaderToggle(headerEl);
if (collapsedLaneIds[String(headerEl.dataset.laneId)]) {
setLaneCollapsed(headerEl.dataset.laneId, true);
}
});
}
function cardBodyHtml(card) {
var html = '
' +
'' + esc(card.job_number || '') + '';
if (card.customer_name) {
html += '' + esc(card.customer_name) + '';
}
html += '
';
return html;
}
function buildCardSearchText(card) {
return [
card.job_number || '',
card.job_name || '',
card.customer_name || '',
card.notes || ''
].join(' ').toLowerCase();
}
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.dataset.searchText = buildCardSearchText(card);
div.innerHTML = cardBodyHtml(card);
div.addEventListener('click', function () {
var c = KANBAN.cards.find(function (x) { return String(x.id) === String(div.dataset.id); });
if (!c) return;
window.KanbanModal.openEdit(c.id, c.column_id, c.swim_lane_id, c.job_number, c.job_name, c.customer_name, c.delivery_date, c.quantity, c.notes, c.full_note);
});
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));
}
});
applyCardFilter();
}
function applyCardFilter() {
var activeQuery = searchState.query;
document.querySelectorAll('.kanban-card').forEach(function (el) {
var searchableText = (el.dataset.searchText || '').toLowerCase();
var isMatch = activeQuery === '' || searchableText.indexOf(activeQuery) > -1;
el.classList.toggle('kanban-card-hidden', !isMatch);
});
}
function initJobSearch() {
var searchInput = document.getElementById('job-search-input');
if (!searchInput) return;
searchInput.addEventListener('input', function () {
searchState.query = String(searchInput.value || '').toLowerCase().trim();
applyCardFilter();
});
}
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));
}
applyCardFilter();
},
onCardUpdated: function (id, data) {
var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); });
if (card) {
card.job_number = data.job_number || '';
card.job_name = data.job_name || '';
card.customer_name = data.customer_name || '';
card.delivery_date = data.delivery_date || null;
card.quantity = data.quantity || '';
card.notes = data.notes || '';
card.full_note = data.full_note !== undefined ? data.full_note : (card.full_note || '');
}
var el = document.querySelector('.kanban-card[data-id="' + id + '"]');
if (el && card) {
el.innerHTML = cardBodyHtml(card);
el.dataset.searchText = buildCardSearchText(card);
}
applyCardFilter();
},
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();
applyCardFilter();
},
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);
bindLaneHeaderToggle(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);
});
if (collapsedLaneIds[String(lane.id)]) {
setLaneCollapsed(lane.id, true);
}
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); });
if (collapsedLaneIds[String(laneId)]) {
delete collapsedLaneIds[String(laneId)];
saveCollapsedLaneIds();
}
},
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();
initJobSearch();
initLaneHeaderToggles();
})();