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