Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

560 Zeilen
18KB

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

Powered by TurnKey Linux.