Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

610 rindas
20KB

  1. /* kanban-board.js - grid rendering and drag-drop between cells */
  2. (function () {
  3. 'use strict';
  4. var boardId = KANBAN.boardId;
  5. var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId);
  6. var collapsedLaneIds = loadCollapsedLaneIds();
  7. var columnShowCount = {};
  8. var columnAgeSettings = {};
  9. var laneAgeSettings = {};
  10. var searchState = {
  11. query: ''
  12. };
  13. var dragState = {
  14. active: false,
  15. x: 0,
  16. y: 0,
  17. rafId: 0
  18. };
  19. function post(url, data, cb) {
  20. var params = new URLSearchParams();
  21. Object.keys(data).forEach(function (k) { params.append(k, data[k]); });
  22. fetch(url, {
  23. method: 'POST',
  24. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  25. body: params.toString()
  26. })
  27. .then(function (r) { return r.json(); })
  28. .then(cb)
  29. .catch(function (e) { console.error(url, e); });
  30. }
  31. function esc(s) {
  32. return String(s)
  33. .replace(/&/g, '&')
  34. .replace(/</g, '&lt;')
  35. .replace(/>/g, '&gt;')
  36. .replace(/"/g, '&quot;');
  37. }
  38. function nowDbString() {
  39. var d = new Date();
  40. function pad(n) { return n < 10 ? '0' + n : n; }
  41. return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' +
  42. pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
  43. }
  44. function ageInDays(cellEnteredAt) {
  45. if (!cellEnteredAt) return 0;
  46. var entered = new Date(String(cellEnteredAt).replace(' ', 'T'));
  47. if (isNaN(entered.getTime())) return 0;
  48. return (Date.now() - entered.getTime()) / (24 * 60 * 60 * 1000);
  49. }
  50. function formatAge(cellEnteredAt) {
  51. var days = ageInDays(cellEnteredAt);
  52. if (!cellEnteredAt || isNaN(days)) return null;
  53. if (days < 0) days = 0;
  54. var totalDays = Math.floor(days);
  55. if (totalDays >= 1) {
  56. return totalDays + ' day' + (totalDays === 1 ? '' : 's');
  57. }
  58. var totalHours = Math.floor(days * 24);
  59. if (totalHours >= 1) {
  60. return totalHours + ' hour' + (totalHours === 1 ? '' : 's');
  61. }
  62. return 'less than an hour';
  63. }
  64. function effectiveCardAgeSettings(card) {
  65. var colSettings = columnAgeSettings[String(card.column_id)] || { showCardAge: false, cardAgeWarningDays: 0 };
  66. var laneSettings = laneAgeSettings[String(card.swim_lane_id)] || { showCardAge: false, cardAgeWarningDays: 0 };
  67. var showCardAge = colSettings.showCardAge || laneSettings.showCardAge;
  68. var warningDays = 0;
  69. if (colSettings.showCardAge) {
  70. warningDays = colSettings.cardAgeWarningDays;
  71. } else if (laneSettings.showCardAge) {
  72. warningDays = laneSettings.cardAgeWarningDays;
  73. }
  74. return { showCardAge: showCardAge, cardAgeWarningDays: warningDays };
  75. }
  76. function applyCardAge(el, card) {
  77. var settings = effectiveCardAgeSettings(card);
  78. if (settings.showCardAge) {
  79. var age = formatAge(card.cell_entered_at);
  80. if (age) {
  81. el.title = 'In this column/lane for ' + age;
  82. } else {
  83. el.removeAttribute('title');
  84. }
  85. } else {
  86. el.removeAttribute('title');
  87. }
  88. var isOverdue = settings.cardAgeWarningDays > 0 &&
  89. ageInDays(card.cell_entered_at) >= settings.cardAgeWarningDays;
  90. el.classList.toggle('kanban-card-overdue', isOverdue);
  91. }
  92. function refreshAllCardAges() {
  93. document.querySelectorAll('.kanban-card').forEach(function (el) {
  94. var card = KANBAN.cards.find(function (c) { return String(c.id) === String(el.dataset.id); });
  95. if (card) applyCardAge(el, card);
  96. });
  97. }
  98. function applyGridTemplate() {
  99. var grid = document.getElementById('kanban-grid');
  100. var colHs = grid.querySelectorAll('.kanban-col-header');
  101. var cols = '240px';
  102. colHs.forEach(function () { cols += ' 230px'; });
  103. grid.style.gridTemplateColumns = cols;
  104. }
  105. function countCardsInColumn(colId) {
  106. var count = 0;
  107. KANBAN.cards.forEach(function (c) {
  108. if (String(c.column_id) === String(colId)) count++;
  109. });
  110. return count;
  111. }
  112. function refreshColumnCount(colId) {
  113. var header = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]');
  114. if (!header) return;
  115. var badge = header.querySelector('.col-count-badge');
  116. if (!columnShowCount[String(colId)]) {
  117. if (badge) badge.remove();
  118. return;
  119. }
  120. if (!badge) {
  121. badge = document.createElement('span');
  122. badge.className = 'col-count-badge';
  123. header.appendChild(badge);
  124. }
  125. badge.textContent = countCardsInColumn(colId);
  126. }
  127. function initColumnCounts() {
  128. KANBAN_COLS.forEach(function (col) {
  129. columnShowCount[String(col.id)] = !!col.show_card_count;
  130. refreshColumnCount(col.id);
  131. });
  132. }
  133. function initCardAgeSettings() {
  134. KANBAN_COLS.forEach(function (col) {
  135. columnAgeSettings[String(col.id)] = {
  136. showCardAge: !!col.show_card_age,
  137. cardAgeWarningDays: parseInt(col.card_age_warning_days, 10) || 0
  138. };
  139. });
  140. KANBAN_LANES.forEach(function (lane) {
  141. laneAgeSettings[String(lane.id)] = {
  142. showCardAge: !!lane.show_card_age,
  143. cardAgeWarningDays: parseInt(lane.card_age_warning_days, 10) || 0
  144. };
  145. });
  146. }
  147. function loadCollapsedLaneIds() {
  148. var laneMap = {};
  149. try {
  150. var raw = window.localStorage.getItem(laneCollapseStorageKey);
  151. if (!raw) return laneMap;
  152. var arr = JSON.parse(raw);
  153. if (!Array.isArray(arr)) return laneMap;
  154. arr.forEach(function (laneId) {
  155. laneMap[String(laneId)] = true;
  156. });
  157. } catch (e) {
  158. console.warn('Failed to load lane collapse state', e);
  159. }
  160. return laneMap;
  161. }
  162. function saveCollapsedLaneIds() {
  163. try {
  164. window.localStorage.setItem(laneCollapseStorageKey, JSON.stringify(Object.keys(collapsedLaneIds)));
  165. } catch (e) {
  166. console.warn('Failed to save lane collapse state', e);
  167. }
  168. }
  169. function setLaneCollapsed(laneId, isCollapsed) {
  170. var laneKey = String(laneId);
  171. var header = document.querySelector('.kanban-lane-header[data-lane-id="' + laneKey + '"]');
  172. if (!header) return;
  173. var laneCells = document.querySelectorAll('.kanban-cell[data-lane-id="' + laneKey + '"]');
  174. header.classList.toggle('lane-collapsed', isCollapsed);
  175. laneCells.forEach(function (cell) {
  176. cell.classList.toggle('lane-collapsed', isCollapsed);
  177. });
  178. var toggleBtn = header.querySelector('.lane-toggle');
  179. if (toggleBtn) {
  180. toggleBtn.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
  181. toggleBtn.title = isCollapsed ? 'Expand swim lane' : 'Collapse swim lane';
  182. toggleBtn.setAttribute('aria-label', toggleBtn.title);
  183. }
  184. if (isCollapsed) {
  185. collapsedLaneIds[laneKey] = true;
  186. } else {
  187. delete collapsedLaneIds[laneKey];
  188. }
  189. saveCollapsedLaneIds();
  190. }
  191. function toggleLaneCollapsed(laneId) {
  192. var laneKey = String(laneId);
  193. setLaneCollapsed(laneKey, !collapsedLaneIds[laneKey]);
  194. }
  195. function bindLaneHeaderToggle(headerEl) {
  196. if (!headerEl) return;
  197. var toggleBtn = headerEl.querySelector('.lane-toggle');
  198. if (!toggleBtn) return;
  199. if (!toggleBtn.dataset.boundToggle) {
  200. toggleBtn.addEventListener('click', function (evt) {
  201. evt.preventDefault();
  202. evt.stopPropagation();
  203. toggleLaneCollapsed(headerEl.dataset.laneId);
  204. });
  205. toggleBtn.dataset.boundToggle = '1';
  206. }
  207. }
  208. function initLaneHeaderToggles() {
  209. document.querySelectorAll('.kanban-lane-header').forEach(function (headerEl) {
  210. bindLaneHeaderToggle(headerEl);
  211. if (collapsedLaneIds[String(headerEl.dataset.laneId)]) {
  212. setLaneCollapsed(headerEl.dataset.laneId, true);
  213. }
  214. });
  215. }
  216. function cardBodyHtml(card) {
  217. var html = '<div class="card-headline">' +
  218. '<span class="card-job-number">' + esc(card.job_number || '') + '</span>';
  219. if (card.customer_name) {
  220. html += '<span class="card-customer">' + esc(card.customer_name) + '</span>';
  221. }
  222. html += '</div>';
  223. return html;
  224. }
  225. function buildCardSearchText(card) {
  226. return [
  227. card.job_number || '',
  228. card.job_name || '',
  229. card.customer_name || '',
  230. card.notes || ''
  231. ].join(' ').toLowerCase();
  232. }
  233. function buildCardEl(card) {
  234. var div = document.createElement('div');
  235. div.className = 'kanban-card';
  236. div.dataset.id = card.id;
  237. div.dataset.columnId = card.column_id;
  238. div.dataset.laneId = card.swim_lane_id;
  239. div.dataset.searchText = buildCardSearchText(card);
  240. div.innerHTML = cardBodyHtml(card);
  241. applyCardAge(div, card);
  242. div.addEventListener('click', function () {
  243. var c = KANBAN.cards.find(function (x) { return String(x.id) === String(div.dataset.id); });
  244. if (!c) return;
  245. 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);
  246. });
  247. return div;
  248. }
  249. function renderCards() {
  250. KANBAN.cards.forEach(function (card) {
  251. var cell = document.querySelector(
  252. '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]'
  253. );
  254. if (cell) {
  255. cell.appendChild(buildCardEl(card));
  256. }
  257. });
  258. applyCardFilter();
  259. }
  260. function applyCardFilter() {
  261. var activeQuery = searchState.query;
  262. document.querySelectorAll('.kanban-card').forEach(function (el) {
  263. var searchableText = (el.dataset.searchText || '').toLowerCase();
  264. var isMatch = activeQuery === '' || searchableText.indexOf(activeQuery) > -1;
  265. el.classList.toggle('kanban-card-hidden', !isMatch);
  266. });
  267. }
  268. function initJobSearch() {
  269. var searchInput = document.getElementById('job-search-input');
  270. if (!searchInput) return;
  271. searchInput.addEventListener('input', function () {
  272. searchState.query = String(searchInput.value || '').toLowerCase().trim();
  273. applyCardFilter();
  274. });
  275. }
  276. function handleDragEnd(evt) {
  277. var cardId = evt.item.dataset.id;
  278. var newColId = evt.to.dataset.colId;
  279. var newLaneId = evt.to.dataset.laneId;
  280. var newPos = evt.newIndex;
  281. var siblings = [];
  282. evt.to.querySelectorAll('.kanban-card').forEach(function (el) {
  283. siblings.push(el.dataset.id);
  284. });
  285. var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); });
  286. var oldColId = card ? card.column_id : null;
  287. var oldLaneId = card ? card.swim_lane_id : null;
  288. if (card) {
  289. if (String(oldColId) !== String(newColId) || String(oldLaneId) !== String(newLaneId)) {
  290. card.cell_entered_at = nowDbString();
  291. }
  292. card.column_id = parseInt(newColId, 10);
  293. card.swim_lane_id = parseInt(newLaneId, 10);
  294. card.position = newPos;
  295. applyCardAge(evt.item, card);
  296. }
  297. evt.item.dataset.columnId = newColId;
  298. evt.item.dataset.laneId = newLaneId;
  299. if (oldColId !== null && String(oldColId) !== String(newColId)) {
  300. refreshColumnCount(oldColId);
  301. }
  302. refreshColumnCount(newColId);
  303. post('/cards/' + cardId + '/move', {
  304. column_id: newColId,
  305. swim_lane_id: newLaneId,
  306. position: newPos,
  307. sibling_ids: siblings.join(',')
  308. }, function (res) {
  309. if (!res.ok) console.error('Move failed', res);
  310. });
  311. }
  312. function createCellSortable(cell) {
  313. Sortable.create(cell, {
  314. group: 'cards',
  315. animation: 150,
  316. ghostClass: 'sortable-ghost',
  317. chosenClass: 'sortable-chosen',
  318. handle: '.kanban-card',
  319. delayOnTouchOnly: true,
  320. delay: 120,
  321. touchStartThreshold: 3,
  322. fallbackTolerance: 4,
  323. scroll: true,
  324. bubbleScroll: true,
  325. scrollSensitivity: 140,
  326. scrollSpeed: 32,
  327. onStart: function () { startEdgeAutoScroll(); },
  328. onEnd: function (evt) {
  329. stopEdgeAutoScroll();
  330. handleDragEnd(evt);
  331. }
  332. });
  333. }
  334. function updatePointerFromEvent(evt) {
  335. if (!evt) return;
  336. if (evt.touches && evt.touches.length > 0) {
  337. dragState.x = evt.touches[0].clientX;
  338. dragState.y = evt.touches[0].clientY;
  339. return;
  340. }
  341. if (evt.clientX !== undefined && evt.clientY !== undefined) {
  342. dragState.x = evt.clientX;
  343. dragState.y = evt.clientY;
  344. }
  345. }
  346. function edgeScrollStep() {
  347. if (!dragState.active) return;
  348. var wrapper = document.querySelector('.kanban-wrapper');
  349. if (wrapper) {
  350. var rect = wrapper.getBoundingClientRect();
  351. var edge = 110;
  352. var maxStep = 40;
  353. var dx = 0;
  354. var dy = 0;
  355. if (dragState.x > 0 && dragState.x < rect.left + edge) {
  356. dx = -Math.min(maxStep, Math.ceil((rect.left + edge - dragState.x) / 3));
  357. } else if (dragState.x > rect.right - edge && dragState.x < rect.right + edge) {
  358. dx = Math.min(maxStep, Math.ceil((dragState.x - (rect.right - edge)) / 3));
  359. }
  360. if (dragState.y > 0 && dragState.y < rect.top + edge) {
  361. dy = -Math.min(maxStep, Math.ceil((rect.top + edge - dragState.y) / 3));
  362. } else if (dragState.y > rect.bottom - edge && dragState.y < rect.bottom + edge) {
  363. dy = Math.min(maxStep, Math.ceil((dragState.y - (rect.bottom - edge)) / 3));
  364. }
  365. if (dx !== 0) wrapper.scrollLeft += dx;
  366. if (dy !== 0) wrapper.scrollTop += dy;
  367. }
  368. dragState.rafId = window.requestAnimationFrame(edgeScrollStep);
  369. }
  370. function startEdgeAutoScroll() {
  371. if (dragState.active) return;
  372. dragState.active = true;
  373. document.addEventListener('pointermove', updatePointerFromEvent, { passive: true });
  374. document.addEventListener('touchmove', updatePointerFromEvent, { passive: true });
  375. document.addEventListener('dragover', updatePointerFromEvent, { passive: true });
  376. dragState.rafId = window.requestAnimationFrame(edgeScrollStep);
  377. }
  378. function stopEdgeAutoScroll() {
  379. if (!dragState.active) return;
  380. dragState.active = false;
  381. if (dragState.rafId) {
  382. window.cancelAnimationFrame(dragState.rafId);
  383. dragState.rafId = 0;
  384. }
  385. document.removeEventListener('pointermove', updatePointerFromEvent);
  386. document.removeEventListener('touchmove', updatePointerFromEvent);
  387. document.removeEventListener('dragover', updatePointerFromEvent);
  388. }
  389. function initSortables() {
  390. document.querySelectorAll('.kanban-cell').forEach(createCellSortable);
  391. }
  392. document.getElementById('btn-add-card').addEventListener('click', function () {
  393. window.KanbanModal.openCreate(boardId, null, null);
  394. });
  395. window.KanbanBoard = {
  396. onCardCreated: function (card) {
  397. KANBAN.cards.push(card);
  398. var cell = document.querySelector(
  399. '.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]'
  400. );
  401. if (cell) {
  402. cell.appendChild(buildCardEl(card));
  403. }
  404. applyCardFilter();
  405. refreshColumnCount(card.column_id);
  406. },
  407. onCardUpdated: function (id, data) {
  408. var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); });
  409. if (card) {
  410. card.job_number = data.job_number || '';
  411. card.job_name = data.job_name || '';
  412. card.customer_name = data.customer_name || '';
  413. card.delivery_date = data.delivery_date || null;
  414. card.quantity = data.quantity || '';
  415. card.notes = data.notes || '';
  416. card.full_note = data.full_note !== undefined ? data.full_note : (card.full_note || '');
  417. }
  418. var el = document.querySelector('.kanban-card[data-id="' + id + '"]');
  419. if (el && card) {
  420. el.innerHTML = cardBodyHtml(card);
  421. el.dataset.searchText = buildCardSearchText(card);
  422. }
  423. applyCardFilter();
  424. },
  425. onCardDeleted: function (id) {
  426. var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); });
  427. var colId = card ? card.column_id : null;
  428. KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); });
  429. var el = document.querySelector('.kanban-card[data-id="' + id + '"]');
  430. if (el) el.remove();
  431. applyCardFilter();
  432. if (colId !== null) refreshColumnCount(colId);
  433. },
  434. addColumn: function (col) {
  435. var grid = document.getElementById('kanban-grid');
  436. var headers = grid.querySelectorAll('.kanban-col-header');
  437. var refNode = headers.length ? headers[headers.length - 1].nextSibling : null;
  438. var hdr = document.createElement('div');
  439. hdr.className = 'kanban-col-header';
  440. hdr.dataset.colId = col.id;
  441. hdr.innerHTML = '<span class="col-label">' + esc(col.name) + '</span>';
  442. grid.insertBefore(hdr, refNode);
  443. columnShowCount[String(col.id)] = false;
  444. columnAgeSettings[String(col.id)] = { showCardAge: false, cardAgeWarningDays: 0 };
  445. var laneHeaders = grid.querySelectorAll('.kanban-lane-header');
  446. laneHeaders.forEach(function (lh) {
  447. var laneId = lh.dataset.laneId;
  448. var cell = document.createElement('div');
  449. cell.className = 'kanban-cell';
  450. cell.dataset.colId = col.id;
  451. cell.dataset.laneId = laneId;
  452. var row = lh.parentNode;
  453. row.appendChild(cell);
  454. createCellSortable(cell);
  455. });
  456. applyGridTemplate();
  457. },
  458. removeColumn: function (colId) {
  459. document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove();
  460. document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); });
  461. KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); });
  462. delete columnShowCount[String(colId)];
  463. delete columnAgeSettings[String(colId)];
  464. applyGridTemplate();
  465. },
  466. addLane: function (lane) {
  467. var grid = document.getElementById('kanban-grid');
  468. var colHeaders = grid.querySelectorAll('.kanban-col-header');
  469. var lh = document.createElement('div');
  470. lh.className = 'kanban-lane-header';
  471. lh.dataset.laneId = lane.id;
  472. lh.innerHTML =
  473. '<button type="button" class="lane-toggle" title="Collapse swim lane" aria-label="Collapse swim lane" aria-expanded="true">' +
  474. '<i class="bi bi-chevron-down" aria-hidden="true"></i>' +
  475. '</button>' +
  476. '<span class="lane-label">' + esc(lane.name) + '</span>';
  477. grid.appendChild(lh);
  478. bindLaneHeaderToggle(lh);
  479. laneAgeSettings[String(lane.id)] = { showCardAge: false, cardAgeWarningDays: 0 };
  480. colHeaders.forEach(function (ch) {
  481. var cell = document.createElement('div');
  482. cell.className = 'kanban-cell';
  483. cell.dataset.colId = ch.dataset.colId;
  484. cell.dataset.laneId = lane.id;
  485. grid.appendChild(cell);
  486. createCellSortable(cell);
  487. });
  488. if (collapsedLaneIds[String(lane.id)]) {
  489. setLaneCollapsed(lane.id, true);
  490. }
  491. applyGridTemplate();
  492. },
  493. removeLane: function (laneId) {
  494. document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove();
  495. document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); });
  496. KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); });
  497. delete laneAgeSettings[String(laneId)];
  498. if (collapsedLaneIds[String(laneId)]) {
  499. delete collapsedLaneIds[String(laneId)];
  500. saveCollapsedLaneIds();
  501. }
  502. },
  503. renameColumn: function (colId, name) {
  504. var hdr = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"] .col-label');
  505. if (hdr) hdr.textContent = name;
  506. },
  507. renameLane: function (laneId, name) {
  508. var hdr = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"] .lane-label');
  509. if (hdr) hdr.textContent = name;
  510. },
  511. setColumnShowCount: function (colId, show) {
  512. columnShowCount[String(colId)] = show;
  513. refreshColumnCount(colId);
  514. },
  515. setColumnCardAge: function (colId, showCardAge, cardAgeWarningDays) {
  516. columnAgeSettings[String(colId)] = {
  517. showCardAge: !!showCardAge,
  518. cardAgeWarningDays: parseInt(cardAgeWarningDays, 10) || 0
  519. };
  520. refreshAllCardAges();
  521. },
  522. setLaneCardAge: function (laneId, showCardAge, cardAgeWarningDays) {
  523. laneAgeSettings[String(laneId)] = {
  524. showCardAge: !!showCardAge,
  525. cardAgeWarningDays: parseInt(cardAgeWarningDays, 10) || 0
  526. };
  527. refreshAllCardAges();
  528. }
  529. };
  530. applyGridTemplate();
  531. initCardAgeSettings();
  532. renderCards();
  533. initSortables();
  534. initJobSearch();
  535. initLaneHeaderToggles();
  536. initColumnCounts();
  537. })();

Powered by TurnKey Linux.