Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

493 строки
16KB

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

Powered by TurnKey Linux.