|
|
@@ -448,23 +448,6 @@
|
|
|
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);
|
|
|
-
|
|
|
- if (taskStatusEnabled) {
|
|
|
- const sSel = document.createElement('select');
|
|
|
- sSel.setAttribute('data-assign-status', '');
|
|
|
- sSel.setAttribute('data-sw-id', String(sw.id));
|
|
|
- sSel.setAttribute('aria-label', 'Status');
|
|
|
- sSel.className = 'assign-status-select';
|
|
|
- STATUSES.forEach(function (s) {
|
|
|
- const opt = document.createElement('option');
|
|
|
- opt.value = s;
|
|
|
- opt.textContent = s;
|
|
|
- if (s === 'zugewiesen') { opt.selected = true; }
|
|
|
- sSel.appendChild(opt);
|
|
|
- });
|
|
|
- td.appendChild(sSel);
|
|
|
- }
|
|
|
-
|
|
|
tr.appendChild(td);
|
|
|
});
|
|
|
|
|
|
@@ -647,23 +630,217 @@
|
|
|
.catch((e) => flash(e.message, true));
|
|
|
}
|
|
|
|
|
|
- if (taskStatusEnabled) {
|
|
|
- on(root, 'change', '[data-assign-status]', function () {
|
|
|
- const next = String(this.value || '');
|
|
|
+ // --- Cell popover (replaces the chevron status select) --------------
|
|
|
+ //
|
|
|
+ // Single body-attached panel that opens to the right of the bound
|
|
|
+ // input (admin) or read-only span (non-admin) on click. Carries a
|
|
|
+ // 0..max slider for the days value (admin only — slider is hidden
|
|
|
+ // when there's no input to mirror) plus a vertical list of status
|
|
|
+ // pills with coloured bullets. Slider drag mirrors into
|
|
|
+ // input.value + dispatches `change` so the existing
|
|
|
+ // queueAssign / queueStatus / applyFilters pipelines fire
|
|
|
+ // unchanged. Closes on outside-pointerdown, Escape, scroll, resize,
|
|
|
+ // mouseleave-grace (250 ms), and any status pick (terminal).
|
|
|
+
|
|
|
+ const sliderMax = (function () {
|
|
|
+ const raw = taskSection ? taskSection.getAttribute('data-assignment-slider-max') : '10';
|
|
|
+ const v = parseInt(raw, 10);
|
|
|
+ if (!Number.isFinite(v) || v < 1) { return 10; }
|
|
|
+ return Math.min(100, v);
|
|
|
+ })();
|
|
|
+ const STATUS_LABELS = {
|
|
|
+ zugewiesen: 'Zugewiesen',
|
|
|
+ gestartet: 'Gestartet',
|
|
|
+ abgeschlossen: 'Abgeschlossen',
|
|
|
+ abgebrochen: 'Abgebrochen',
|
|
|
+ };
|
|
|
+
|
|
|
+ let cellPopover = null;
|
|
|
+ let cellPopoverInput = null; // bound <input data-assign> or null (non-admin)
|
|
|
+ let cellPopoverCell = null; // bound <td data-assign-cell>
|
|
|
+ let cellPopoverGrace = null;
|
|
|
+ let cellPopoverOpenAt = 0;
|
|
|
+
|
|
|
+ function cancelCellPopoverGrace() {
|
|
|
+ if (cellPopoverGrace) { clearTimeout(cellPopoverGrace); cellPopoverGrace = null; }
|
|
|
+ }
|
|
|
+ function scheduleCellPopoverGrace() {
|
|
|
+ cancelCellPopoverGrace();
|
|
|
+ cellPopoverGrace = setTimeout(closeCellPopover, 250);
|
|
|
+ }
|
|
|
+
|
|
|
+ function buildCellPopover() {
|
|
|
+ if (cellPopover) { return cellPopover; }
|
|
|
+ const r = document.createElement('div');
|
|
|
+ r.className = 'cell-popover hidden';
|
|
|
+ r.setAttribute('role', 'dialog');
|
|
|
+ r.setAttribute('aria-label', 'Edit cell');
|
|
|
+
|
|
|
+ const grid = document.createElement('div');
|
|
|
+ grid.className = 'cell-popover-grid';
|
|
|
+
|
|
|
+ const sliderWrap = document.createElement('div');
|
|
|
+ sliderWrap.className = 'cell-popover-slider';
|
|
|
+ const valEl = document.createElement('span');
|
|
|
+ valEl.className = 'cell-popover-value';
|
|
|
+ valEl.textContent = '0';
|
|
|
+ const slider = document.createElement('input');
|
|
|
+ slider.type = 'range';
|
|
|
+ slider.min = '0';
|
|
|
+ slider.max = String(sliderMax);
|
|
|
+ slider.step = '0.5';
|
|
|
+ slider.value = '0';
|
|
|
+ slider.setAttribute('aria-label', 'Days');
|
|
|
+ sliderWrap.appendChild(valEl);
|
|
|
+ sliderWrap.appendChild(slider);
|
|
|
+ grid.appendChild(sliderWrap);
|
|
|
+
|
|
|
+ const statusList = document.createElement('div');
|
|
|
+ statusList.className = 'cell-popover-status';
|
|
|
+ STATUSES.forEach(function (s) {
|
|
|
+ const btn = document.createElement('button');
|
|
|
+ btn.type = 'button';
|
|
|
+ btn.className = 'cell-popover-status-btn';
|
|
|
+ btn.setAttribute('data-status-pick', s);
|
|
|
+ const bullet = document.createElement('span');
|
|
|
+ bullet.className = 'cell-popover-bullet bullet-' + s;
|
|
|
+ const lbl = document.createElement('span');
|
|
|
+ lbl.textContent = STATUS_LABELS[s] || s;
|
|
|
+ btn.appendChild(bullet);
|
|
|
+ btn.appendChild(lbl);
|
|
|
+ statusList.appendChild(btn);
|
|
|
+ });
|
|
|
+ grid.appendChild(statusList);
|
|
|
+ r.appendChild(grid);
|
|
|
+ document.body.appendChild(r);
|
|
|
+
|
|
|
+ // Slider → mirror into bound input.value + dispatch change so the
|
|
|
+ // existing 400 ms days-save pipeline fires.
|
|
|
+ slider.addEventListener('input', function () {
|
|
|
+ const v = Number(slider.value);
|
|
|
+ valEl.textContent = fmtDays(v);
|
|
|
+ if (!cellPopoverInput) { return; }
|
|
|
+ cellPopoverInput.value = fmtDays(v);
|
|
|
+ cellPopoverInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
+ });
|
|
|
+
|
|
|
+ // Status pick → set status, save, close (terminal action).
|
|
|
+ statusList.addEventListener('click', function (ev) {
|
|
|
+ const btn = ev.target.closest('[data-status-pick]');
|
|
|
+ if (!btn || !cellPopoverCell) { return; }
|
|
|
+ const next = btn.getAttribute('data-status-pick');
|
|
|
if (STATUSES.indexOf(next) === -1) { return; }
|
|
|
- const cell = this.closest('[data-assign-cell]');
|
|
|
- if (!cell) { return; }
|
|
|
- const tr = this.closest('tr');
|
|
|
- const taskId = parseInt(tr.getAttribute('data-task-id'), 10);
|
|
|
- const swId = parseInt(this.getAttribute('data-sw-id'), 10);
|
|
|
- if (!Number.isFinite(taskId) || !Number.isFinite(swId)) { return; }
|
|
|
-
|
|
|
- STATUSES.forEach((s) => cell.classList.remove('assign-status-' + s));
|
|
|
- cell.classList.add('assign-status-' + next);
|
|
|
- cell.setAttribute('data-status', next);
|
|
|
-
|
|
|
- queueStatus(taskId, swId, next);
|
|
|
+ STATUSES.forEach(function (s) { cellPopoverCell.classList.remove('assign-status-' + s); });
|
|
|
+ cellPopoverCell.classList.add('assign-status-' + next);
|
|
|
+ cellPopoverCell.setAttribute('data-status', next);
|
|
|
+ const tr = cellPopoverCell.closest('tr');
|
|
|
+ const taskId = parseInt(tr ? tr.getAttribute('data-task-id') : 'NaN', 10);
|
|
|
+ const swId = parseInt(cellPopoverCell.getAttribute('data-sw-id') || '', 10);
|
|
|
+ if (Number.isFinite(taskId) && Number.isFinite(swId)) {
|
|
|
+ queueStatus(taskId, swId, next);
|
|
|
+ }
|
|
|
applyFilters();
|
|
|
+ closeCellPopover();
|
|
|
+ });
|
|
|
+
|
|
|
+ r.addEventListener('mouseenter', cancelCellPopoverGrace);
|
|
|
+ r.addEventListener('mouseleave', scheduleCellPopoverGrace);
|
|
|
+
|
|
|
+ cellPopover = r;
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ function positionCellPopover() {
|
|
|
+ if (!cellPopover || !cellPopoverCell) { return; }
|
|
|
+ const anchor = cellPopoverInput
|
|
|
+ || cellPopoverCell.querySelector('[data-assign-readonly]')
|
|
|
+ || cellPopoverCell;
|
|
|
+ const rect = anchor.getBoundingClientRect();
|
|
|
+ const ph = cellPopover.offsetHeight;
|
|
|
+ const pw = cellPopover.offsetWidth;
|
|
|
+ let top = window.scrollY + rect.top + rect.height / 2 - ph / 2;
|
|
|
+ let left = window.scrollX + rect.right + 8;
|
|
|
+ const vw = document.documentElement.clientWidth;
|
|
|
+ if (left + pw > window.scrollX + vw - 8) {
|
|
|
+ left = window.scrollX + rect.left - pw - 8;
|
|
|
+ }
|
|
|
+ if (top < window.scrollY + 8) { top = window.scrollY + 8; }
|
|
|
+ cellPopover.style.top = top + 'px';
|
|
|
+ cellPopover.style.left = left + 'px';
|
|
|
+ }
|
|
|
+
|
|
|
+ function openCellPopover(cell) {
|
|
|
+ if (!taskStatusEnabled || !cell) { return; }
|
|
|
+ buildCellPopover();
|
|
|
+ cellPopoverCell = cell;
|
|
|
+ cellPopoverInput = cell.querySelector('input[data-assign]');
|
|
|
+
|
|
|
+ const slider = cellPopover.querySelector('input[type="range"]');
|
|
|
+ const valEl = cellPopover.querySelector('.cell-popover-value');
|
|
|
+ let cur = 0;
|
|
|
+ if (cellPopoverInput) {
|
|
|
+ cur = Number(cellPopoverInput.value) || 0;
|
|
|
+ } else {
|
|
|
+ const ro = cell.querySelector('[data-assign-readonly]');
|
|
|
+ cur = ro ? (Number(ro.textContent) || 0) : 0;
|
|
|
+ }
|
|
|
+ valEl.textContent = fmtDays(cur);
|
|
|
+ slider.value = String(Math.min(sliderMax, Math.max(0, cur)));
|
|
|
+
|
|
|
+ // Slider only meaningful when an input exists (admin path).
|
|
|
+ const showSlider = cellPopoverInput !== null;
|
|
|
+ slider.disabled = !showSlider;
|
|
|
+ slider.parentElement.style.display = showSlider ? '' : 'none';
|
|
|
+
|
|
|
+ const cs = String(cell.getAttribute('data-status') || 'zugewiesen');
|
|
|
+ cellPopover.querySelectorAll('[data-status-pick]').forEach(function (b) {
|
|
|
+ b.classList.toggle('cell-popover-active',
|
|
|
+ b.getAttribute('data-status-pick') === cs);
|
|
|
+ });
|
|
|
+
|
|
|
+ cancelCellPopoverGrace();
|
|
|
+ cellPopover.classList.remove('hidden');
|
|
|
+ positionCellPopover();
|
|
|
+ cellPopoverOpenAt = Date.now();
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeCellPopover() {
|
|
|
+ cancelCellPopoverGrace();
|
|
|
+ if (cellPopover) { cellPopover.classList.add('hidden'); }
|
|
|
+ cellPopoverInput = null;
|
|
|
+ cellPopoverCell = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (taskStatusEnabled) {
|
|
|
+ on(root, 'click',
|
|
|
+ '[data-assign-cell] input[data-assign], [data-assign-cell] [data-assign-readonly]',
|
|
|
+ function () {
|
|
|
+ const cell = this.closest('[data-assign-cell]');
|
|
|
+ if (cell) { openCellPopover(cell); }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Outside-pointerdown closes (capture so a downstream stopPropagation
|
|
|
+ // can't strand the popup). 50 ms grace skips the opening click itself.
|
|
|
+ document.addEventListener('pointerdown', function (ev) {
|
|
|
+ if (!cellPopover || cellPopover.classList.contains('hidden')) { return; }
|
|
|
+ if (Date.now() - cellPopoverOpenAt < 50) { return; }
|
|
|
+ if (cellPopover.contains(ev.target)) { return; }
|
|
|
+ if (cellPopoverCell && cellPopoverCell.contains(ev.target)) { return; }
|
|
|
+ closeCellPopover();
|
|
|
+ }, true);
|
|
|
+
|
|
|
+ document.addEventListener('keydown', function (ev) {
|
|
|
+ if (ev.key === 'Escape' && cellPopover && !cellPopover.classList.contains('hidden')) {
|
|
|
+ closeCellPopover();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Any scroll / resize closes — don't try to follow a moving anchor.
|
|
|
+ window.addEventListener('scroll', function () {
|
|
|
+ if (cellPopover && !cellPopover.classList.contains('hidden')) { closeCellPopover(); }
|
|
|
+ }, true);
|
|
|
+ window.addEventListener('resize', function () {
|
|
|
+ if (cellPopover && !cellPopover.classList.contains('hidden')) { closeCellPopover(); }
|
|
|
});
|
|
|
}
|
|
|
|