|
@@ -332,169 +332,57 @@
|
|
|
const taskTbody = qs('[data-task-tbody]');
|
|
const taskTbody = qs('[data-task-tbody]');
|
|
|
const hasTaskUi = !!taskTbody;
|
|
const hasTaskUi = !!taskTbody;
|
|
|
|
|
|
|
|
- function sprintWorkerHeaders() {
|
|
|
|
|
- return qsa('[data-task-table] thead th[data-sort-col^="sw-"]').map(function (th) {
|
|
|
|
|
- const col = String(th.getAttribute('data-sort-col'));
|
|
|
|
|
- // strip the inline sort indicator
|
|
|
|
|
- const clone = th.cloneNode(true);
|
|
|
|
|
- qsa('.sort-ind', clone).forEach((s) => s.remove());
|
|
|
|
|
- return { id: parseInt(col.slice(3), 10), name: clone.textContent.trim() };
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function ownerChoices() {
|
|
|
|
|
- const out = [];
|
|
|
|
|
- qsa('[data-owner-filter-opt]').forEach(function (inp) {
|
|
|
|
|
- const v = String(inp.value);
|
|
|
|
|
- if (v === '' || v === '__none__') { return; }
|
|
|
|
|
- const id = parseInt(v, 10);
|
|
|
|
|
- if (!Number.isFinite(id)) { return; }
|
|
|
|
|
- const span = inp.closest('label') ? inp.closest('label').querySelector('span') : null;
|
|
|
|
|
- const name = span ? span.textContent.trim() : '';
|
|
|
|
|
- out.push({ id, name });
|
|
|
|
|
- });
|
|
|
|
|
- return out;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
- // Build a task <tr> from an object — vanilla JS DOM construction.
|
|
|
|
|
|
|
+ // Clone the server-rendered <template data-task-row-template> shell and
|
|
|
|
|
+ // populate it from the create endpoint's task envelope. Single source of
|
|
|
|
|
+ // truth lives in views/sprints/_task_row.twig (R02-N02): this used to be
|
|
|
|
|
+ // a hand-rolled DOM-construction mirror that drifted three times — once
|
|
|
|
|
+ // for the owner dropdown selector (hotfix 7c298d3), once for the
|
|
|
|
|
+ // data-col attributes (hotfix 23ab365), once for whitespace-nowrap.
|
|
|
function buildTaskRow(task, assignments) {
|
|
function buildTaskRow(task, assignments) {
|
|
|
assignments = assignments || {};
|
|
assignments = assignments || {};
|
|
|
- const tr = document.createElement('tr');
|
|
|
|
|
- tr.setAttribute('data-task-row', '');
|
|
|
|
|
- tr.className = 'hover:bg-slate-50 dark:hover:bg-slate-700';
|
|
|
|
|
- tr.setAttribute('data-task-id', String(task.id));
|
|
|
|
|
- tr.setAttribute('data-prio', String(task.priority));
|
|
|
|
|
- tr.setAttribute('data-owner', task.owner_worker_id || '');
|
|
|
|
|
- tr.setAttribute('data-sort-order', String(task.sort_order));
|
|
|
|
|
|
|
+ const tpl = qs('[data-task-row-template]');
|
|
|
|
|
+ if (!tpl || !tpl.content) {
|
|
|
|
|
+ throw new Error('[sprint-planner] task row template missing');
|
|
|
|
|
+ }
|
|
|
|
|
+ const tr = tpl.content.firstElementChild.cloneNode(true);
|
|
|
|
|
+
|
|
|
|
|
+ const ownerVal = task.owner_worker_id == null ? '' : String(task.owner_worker_id);
|
|
|
|
|
+ tr.setAttribute('data-task-id', String(task.id));
|
|
|
|
|
+ tr.setAttribute('data-prio', String(task.priority));
|
|
|
|
|
+ tr.setAttribute('data-owner', ownerVal);
|
|
|
|
|
+ tr.setAttribute('data-sort-order', String(task.sort_order));
|
|
|
tr.setAttribute('data-description', task.description || '');
|
|
tr.setAttribute('data-description', task.description || '');
|
|
|
tr.setAttribute('data-url', task.url || '');
|
|
tr.setAttribute('data-url', task.url || '');
|
|
|
tr.setAttribute('data-task-title', task.title || '');
|
|
tr.setAttribute('data-task-title', task.title || '');
|
|
|
tr.setAttribute('data-links', JSON.stringify(Array.isArray(task.links) ? task.links : []));
|
|
tr.setAttribute('data-links', JSON.stringify(Array.isArray(task.links) ? task.links : []));
|
|
|
|
|
|
|
|
- // hamburger trigger
|
|
|
|
|
- const tdMenu = document.createElement('td');
|
|
|
|
|
- tdMenu.className = 'px-2 py-1';
|
|
|
|
|
- const trig = document.createElement('button');
|
|
|
|
|
- trig.type = 'button';
|
|
|
|
|
- trig.setAttribute('data-task-menu-trigger', '');
|
|
|
|
|
- trig.className = 'task-menu-trigger inline-flex items-center justify-center w-6 h-6 rounded text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400';
|
|
|
|
|
- trig.setAttribute('aria-haspopup', 'true');
|
|
|
|
|
- trig.setAttribute('aria-expanded', 'false');
|
|
|
|
|
- trig.setAttribute('aria-label', 'Task actions');
|
|
|
|
|
- trig.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">'
|
|
|
|
|
- + '<line x1="2" y1="4" x2="14" y2="4" stroke="currentColor" stroke-width="2"/>'
|
|
|
|
|
- + '<line x1="2" y1="8" x2="14" y2="8" stroke="currentColor" stroke-width="2"/>'
|
|
|
|
|
- + '<line x1="2" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="2"/>'
|
|
|
|
|
- + '</svg>';
|
|
|
|
|
- tdMenu.appendChild(trig);
|
|
|
|
|
- tr.appendChild(tdMenu);
|
|
|
|
|
-
|
|
|
|
|
- // title cell — title input + small "open URL" anchor; description /
|
|
|
|
|
- // refs / actions all live in the hamburger popup.
|
|
|
|
|
- const tdTitle = document.createElement('td');
|
|
|
|
|
- tdTitle.className = 'px-2 py-1 min-w-[14rem]';
|
|
|
|
|
- const titleWrap = document.createElement('div');
|
|
|
|
|
- titleWrap.className = 'flex items-center gap-1.5';
|
|
|
|
|
- const title = document.createElement('input');
|
|
|
|
|
- title.type = 'text';
|
|
|
|
|
- title.setAttribute('data-title', '');
|
|
|
|
|
- title.value = task.title || '';
|
|
|
|
|
- title.className = 'flex-1 rounded border border-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
|
|
|
|
|
- titleWrap.appendChild(title);
|
|
|
|
|
-
|
|
|
|
|
- const urlLink = document.createElement('a');
|
|
|
|
|
- urlLink.setAttribute('data-task-url-link', '');
|
|
|
|
|
- urlLink.target = '_blank';
|
|
|
|
|
- urlLink.rel = 'noopener noreferrer';
|
|
|
|
|
- urlLink.href = task.url || '';
|
|
|
|
|
- urlLink.title = 'Open task link';
|
|
|
|
|
- urlLink.setAttribute('aria-label', 'Open task link');
|
|
|
|
|
- urlLink.className = 'task-url-link inline-flex items-center justify-center w-5 h-5 rounded text-blue-600 hover:bg-slate-100 dark:text-blue-400 dark:hover:bg-slate-700';
|
|
|
|
|
- if (!task.url) { urlLink.classList.add('hidden'); }
|
|
|
|
|
- urlLink.innerHTML = '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">'
|
|
|
|
|
- + '<path d="M9 2h5v5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
|
|
|
|
- + '<path d="M14 2L7 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
|
|
|
|
- + '<path d="M12 9v4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
|
|
|
|
- + '</svg>';
|
|
|
|
|
- titleWrap.appendChild(urlLink);
|
|
|
|
|
-
|
|
|
|
|
- tdTitle.appendChild(titleWrap);
|
|
|
|
|
- tr.appendChild(tdTitle);
|
|
|
|
|
-
|
|
|
|
|
- // owner
|
|
|
|
|
- const tdOwner = document.createElement('td');
|
|
|
|
|
- tdOwner.className = 'px-2 py-1';
|
|
|
|
|
- tdOwner.setAttribute('data-col', 'owner');
|
|
|
|
|
- const ownerSel = document.createElement('select');
|
|
|
|
|
- ownerSel.setAttribute('data-owner-select', '');
|
|
|
|
|
- ownerSel.className = 'w-full rounded border border-slate-200 px-2 py-1 bg-white focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
|
|
|
|
|
- const empty = document.createElement('option');
|
|
|
|
|
- empty.value = '';
|
|
|
|
|
- empty.textContent = '—';
|
|
|
|
|
- ownerSel.appendChild(empty);
|
|
|
|
|
- ownerChoices().forEach(function (o) {
|
|
|
|
|
- const opt = document.createElement('option');
|
|
|
|
|
- opt.value = String(o.id);
|
|
|
|
|
- opt.textContent = o.name;
|
|
|
|
|
- if (Number(o.id) === Number(task.owner_worker_id)) { opt.selected = true; }
|
|
|
|
|
- ownerSel.appendChild(opt);
|
|
|
|
|
- });
|
|
|
|
|
- tdOwner.appendChild(ownerSel);
|
|
|
|
|
- tr.appendChild(tdOwner);
|
|
|
|
|
-
|
|
|
|
|
- // priority
|
|
|
|
|
- const tdPrio = document.createElement('td');
|
|
|
|
|
- tdPrio.className = 'px-2 py-1 text-center';
|
|
|
|
|
- tdPrio.setAttribute('data-col', 'prio');
|
|
|
|
|
- const prioSel = document.createElement('select');
|
|
|
|
|
- prioSel.setAttribute('data-prio-select', '');
|
|
|
|
|
- prioSel.className = 'rounded border border-slate-200 px-2 py-1 bg-white font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
|
|
|
|
|
- ['1', '2'].forEach(function (p) {
|
|
|
|
|
- const opt = document.createElement('option');
|
|
|
|
|
- opt.value = p;
|
|
|
|
|
- opt.textContent = p;
|
|
|
|
|
- if (String(task.priority) === p) { opt.selected = true; }
|
|
|
|
|
- prioSel.appendChild(opt);
|
|
|
|
|
- });
|
|
|
|
|
- tdPrio.appendChild(prioSel);
|
|
|
|
|
- tr.appendChild(tdPrio);
|
|
|
|
|
|
|
+ const titleInp = qs('[data-title]', tr);
|
|
|
|
|
+ if (titleInp) { titleInp.value = task.title || ''; }
|
|
|
|
|
+
|
|
|
|
|
+ const urlLink = qs('[data-task-url-link]', tr);
|
|
|
|
|
+ if (urlLink) {
|
|
|
|
|
+ urlLink.href = task.url || '';
|
|
|
|
|
+ setHidden(urlLink, !task.url);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const ownerSel = qs('[data-owner-select]', tr);
|
|
|
|
|
+ if (ownerSel) { ownerSel.value = ownerVal; }
|
|
|
|
|
+
|
|
|
|
|
+ const prioSel = qs('[data-prio-select]', tr);
|
|
|
|
|
+ if (prioSel) { prioSel.value = String(task.priority); }
|
|
|
|
|
|
|
|
- // tot
|
|
|
|
|
let tot = 0;
|
|
let tot = 0;
|
|
|
Object.keys(assignments).forEach((k) => { tot += Number(assignments[k]) || 0; });
|
|
Object.keys(assignments).forEach((k) => { tot += Number(assignments[k]) || 0; });
|
|
|
- const tdTot = document.createElement('td');
|
|
|
|
|
- tdTot.className = 'px-2 py-1 text-center font-mono font-semibold';
|
|
|
|
|
- tdTot.setAttribute('data-col', 'tot');
|
|
|
|
|
- tdTot.setAttribute('data-task-tot', '');
|
|
|
|
|
- tdTot.textContent = fmtDays(tot);
|
|
|
|
|
- tr.appendChild(tdTot);
|
|
|
|
|
-
|
|
|
|
|
- // per-worker assignment cells
|
|
|
|
|
- sprintWorkerHeaders().forEach(function (sw) {
|
|
|
|
|
- const v = Number(assignments[sw.id] || 0);
|
|
|
|
|
- const td = document.createElement('td');
|
|
|
|
|
- td.className = 'px-1 py-1 text-center whitespace-nowrap';
|
|
|
|
|
- td.setAttribute('data-col', 'sw-' + sw.id);
|
|
|
|
|
- td.setAttribute('data-sort-value-sw-' + sw.id, v.toFixed(2));
|
|
|
|
|
-
|
|
|
|
|
- if (taskStatusEnabled) {
|
|
|
|
|
- td.classList.add('assign-status-zugewiesen');
|
|
|
|
|
- td.setAttribute('data-assign-cell', '');
|
|
|
|
|
- td.setAttribute('data-status', 'zugewiesen');
|
|
|
|
|
- td.setAttribute('data-sw-id', String(sw.id));
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const totEl = qs('[data-task-tot]', tr);
|
|
|
|
|
+ if (totEl) { totEl.textContent = fmtDays(tot); }
|
|
|
|
|
|
|
|
- const inp = document.createElement('input');
|
|
|
|
|
- inp.type = 'number';
|
|
|
|
|
- inp.min = '0';
|
|
|
|
|
- inp.step = '0.5';
|
|
|
|
|
- inp.value = fmtDays(v);
|
|
|
|
|
- inp.setAttribute('data-assign', '');
|
|
|
|
|
- inp.setAttribute('data-sw-id', String(sw.id));
|
|
|
|
|
- inp.className = 'w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
|
|
|
|
|
- td.appendChild(inp);
|
|
|
|
|
- tr.appendChild(td);
|
|
|
|
|
|
|
+ Object.keys(assignments).forEach(function (swIdStr) {
|
|
|
|
|
+ const v = Number(assignments[swIdStr]) || 0;
|
|
|
|
|
+ const cell = qs('td[data-col="sw-' + swIdStr + '"]', tr);
|
|
|
|
|
+ if (!cell) { return; }
|
|
|
|
|
+ cell.setAttribute('data-sort-value-sw-' + swIdStr, v.toFixed(2));
|
|
|
|
|
+ const inp = qs('[data-assign]', cell);
|
|
|
|
|
+ if (inp) { inp.value = fmtDays(v); }
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
return tr;
|
|
return tr;
|