|
- /* 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, '>')
- .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 = '<div class="card-headline">' +
- '<span class="card-job-number">' + esc(card.job_number || '') + '</span>';
-
- if (card.customer_name) {
- html += '<span class="card-customer">' + esc(card.customer_name) + '</span>';
- }
-
- html += '</div>';
-
- 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 = '<span class="col-label">' + esc(col.name) + '</span>';
- 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 =
- '<button type="button" class="lane-toggle" title="Collapse swim lane" aria-label="Collapse swim lane" aria-expanded="true">' +
- '<i class="bi bi-chevron-down" aria-hidden="true"></i>' +
- '</button>' +
- '<span class="lane-label">' + esc(lane.name) + '</span>';
- 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();
- })();
|