/* 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(); })();