Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

443 linhas
15KB

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

Powered by TurnKey Linux.