Files
Fuchs_Intranet/Fuchs/js/intranet/modules/fis.inv_sets.js
T
StefanandClaude Sonnet 5 6cc79122b3
Playwright Tests / test (push) Canceled after 0s
Improve Set-Struktur editor: show parent set inline, clamp long descriptions
Removes the per-row indentation (span.ind) in favor of a small "belongs to
set" tag shown above each row's title, so membership is visible without
tracing indentation depth even while sets are expanded. Description text
is now clamped to 2 lines with ellipsis by default and expands to a
scrollable ~12-line view on click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 16:17:53 +02:00

203 lines
12 KiB
JavaScript

/* ── User-defined set structure dialog (ADR 0016) ────────────────────────────
Lets the user define which line belongs to which set (and how deep) instead of relying only
on the implicit "everything after a Set header until the next Set" derivation that
fds__prepInvoice delivers as the initial structure. The dialog is a pure view of the
authoritative server state (state.req[].itm — the co shape with SetItmId + lvl): every action
posts ONE delta (set.assign / set.level / set.create) via $inv.d.sync, the server rewrites the
cached block (parent links, depth-first order, levels) and the normal draftReady/dstate
refresh re-renders both the editor rows and this dialog (applyState -> $inv.sets.render).
The browser never restructures anything itself (ADR 0006/0008). */
$inv.sets = {
o: null, /* open dialog handle ($ocms.dlg result) or null */
c: null, /* dialog content container */
drag: null, /* { id, bid } of the row currently being dragged */
/* Reorder = set.move (▲▼ or drag before/after a row), membership = set.assign (drag into a set /
root zone), depth = set.level (◀▶). Folding is purely a view state kept in $inv.sets.collapsed. */
isOpen: () => !!$inv.sets.o,
open: function () {
let tbl = $inv.d.tbl();
if (tbl.length < 1 || $inv.sets.isOpen()) { return; }
if ($inv.d.token() === '' && !tbl.data('dstate')) { return; } /* no draft session (e.g. reminder editor) — nothing to structure */
let c = $$.dc('setstruct');
$inv.sets.c = c;
$inv.sets.o = $ocms.dlg(c, {
title: $ict.setst, size: [-1, 960], exclusive: false,
close: () => { $inv.sets.o = null; $inv.sets.c = null; $inv.sets.drag = null; }
});
let state = tbl.data('dstate');
if (state) { $inv.sets.render(state); } else { $inv.d.refresh(); }
},
/* plain-text title of a co line (co.t is HTML for text lines / titles) */
title: (co) => {
let t = $$.d().html(co.t || '').text().replace(/\s+/g, ' ').trim();
return t !== '' ? t : ($ict.setsNoTitle || '');
},
typLbl: (co) => {
let k = (co.typ || '').toString().toLowerCase();
return ($ict.settyp || {})[k] || co.typ || '';
},
isSet: (co) => (co.typ || '').toString().toLowerCase() === 'set',
/* Normalised parent id of a line, mirroring InvoiceSetStructure.ParentOf: SetItmId when it
names ANOTHER set line of the same block; '' for top level / self-reference / unknown. */
parentOf: (co, byId) => {
let p = (co.SetItmId == null ? '' : co.SetItmId).toString(), id = (co.id || '').toString();
if (p === '' || p === id) { return ''; }
let t = byId[p];
return (t && $inv.sets.isSet(t)) ? p : '';
},
isDescendant: (candidateId, ancestorId, byId) => {
let seen = {}, cur = byId[candidateId];
while (cur) {
let p = $inv.sets.parentOf(cur, byId);
if (p === '' || seen[p]) { return false; }
if (p === ancestorId) { return true; }
seen[p] = true; cur = byId[p];
}
return false;
},
/* Whether a line may be dropped onto the given target set (same block, not itself, no cycle). */
canDrop: (drag, targetId, bid, byId) =>
!!drag && drag.bid === bid && drag.id !== targetId && !$inv.sets.isDescendant(targetId, drag.id, byId),
sync: (delta) => {
if ($inv.sets.c) { $inv.sets.c.aC('busy'); }
return $inv.d.sync(delta);
},
render: function (state) {
let c = $inv.sets.c; if (!c) { return; }
let bdy = c.closest('.modal-body'), scroll = bdy.length ? bdy.scrollTop() : 0;
c.empty().rC('busy');
$$.dc('shint', c).text($ict.setsHint || '');
(state.req || []).forEach((b) => {
if (!b) { return; }
let bid = (b.Id || '').toString(); if (bid === '') { return; }
let itms = (b.itm || []).filter((co) => co && (co.id || '') !== ''), byId = {};
itms.forEach((co) => { byId[(co.id || '').toString()] = co; });
let blk = $$.dc('sblock', c).data({ bid: bid });
$inv.sets.rootZone($$.dc('sbhd', blk).append($$.sc('nme', b.text || b.nme || bid)), bid, byId);
let ts = $$.tblset({ class: 'stbl' }, blk);
let thr = $$.tr(ts.hd);
($ict.setsHR || []).forEach((h) => $$.th(thr, h));
itms.forEach((co) => $inv.sets.row(ts.bdy, co, bid, itms, byId));
$inv.sets.rootZone($$.dc('sroot', blk).text($ict.setsRoot || ''), bid, byId);
$inv.sets.createBar(blk, bid, itms, byId);
});
if (bdy.length) { bdy.scrollTop(scroll); }
},
/* Ancestor-collapsed test: a row is hidden while any set above it is folded. */
collapsed: {}, /* set id -> true (survives re-renders; default: everything expanded) */
hiddenByCollapse: function (co, byId) {
let seen = {}, cur = co;
while (cur) {
let p = $inv.sets.parentOf(cur, byId);
if (p === '' || seen[p]) { return false; }
if ($inv.sets.collapsed[p]) { return true; }
seen[p] = true; cur = byId[p];
}
return false;
},
/* Drop zone of a pointer position within a row: top edge = before, bottom edge = after,
middle = into (sets only; for other rows the middle counts as "after"). */
zone: function (ev, tr, isSet) {
let oe = ev.originalEvent || ev, r = tr[0].getBoundingClientRect(), y = (oe.clientY || 0) - r.top, h = r.height || 1;
if (y < h * 0.3) { return 'before'; }
if (y > h * 0.7 || !isSet) { return 'after'; }
return 'into';
},
clearDrop: () => { $('.setstruct .dropok, .setstruct .dropbefore, .setstruct .dropafter').rC('dropok dropbefore dropafter'); },
/* One line of the structure table: drag handle, fold toggle, pos, type, qty, indented title,
order buttons (▲▼ among siblings) and level buttons (◀▶). */
row: function (tbody, co, bid, itms, byId) {
let id = (co.id || '').toString(), lvl = parseInt(co.lvl || 0, 10) || 0, isSet = $inv.sets.isSet(co);
let parent = $inv.sets.parentOf(co, byId), folded = isSet && !!$inv.sets.collapsed[id];
let tr = $$.tr(tbody, { draggable: 'true' }).aC('srow lvl' + lvl).tC('isset', isSet).tC('folded', folded).data({ id: id, bid: bid });
if ($inv.sets.hiddenByCollapse(co, byId)) { tr.aC('hid'); }
$$.tdc('grip', tr).append(gi('move')).attr('title', $rct.mR);
$$.tdc('pos', tr).text(co.p != null ? co.p : '');
let typ = $$.tdc('typ', tr);
if (isSet) { /* fold toggle sits in the type column, in front of "Set" */
typ.append($$.sc('fold').attr('title', folded ? $ict.setsExpand : $ict.setsCollapse).append(gi(folded ? 'triangle-right' : 'triangle-bottom')).click((ev) => {
ev.stopPropagation();
if (folded) { delete $inv.sets.collapsed[id]; } else { $inv.sets.collapsed[id] = true; }
let state = $inv.d.tbl().data('dstate'); if (state) { $inv.sets.render(state); }
}));
}
typ.append($$.s($inv.sets.typLbl(co)));
$$.tdc('qty', tr).text(co.q || '');
let ttl = $$.tdc('ttl', tr);
if (parent !== '') { ttl.append($$.sc('seti', $inv.sets.title(byId[parent]))); }
ttl.append($$.sc('txt', $inv.sets.title(co)).click(function (ev) { ev.stopPropagation(); $(this).tC('exp'); }));
if (isSet) {
let n = itms.filter((x) => $inv.sets.parentOf(x, byId) === id).length;
ttl.append($$.sc('cnt', ' (' + n + ')'));
}
/* siblings (same parent, current order) drive the ▲▼ buttons; a preceding sibling set drives ▶ */
let sibs = itms.filter((x) => $inv.sets.parentOf(x, byId) === parent), si = sibs.findIndex((x) => (x.id || '').toString() === id);
let hasSibSet = sibs.slice(0, si).some((x) => $inv.sets.isSet(x));
let act = $$.tdc('act', tr);
let btn = (cls, glyph, title, enabled, delta) => $$.dc('ibtn ' + cls, act, { title: title }).append(gi(glyph)).tC('disabled', !enabled)
.click(() => { if (enabled) { $inv.sets.sync(delta); } });
btn('up', 'arrow-up', $ict.setsUp, si > 0, { Target: 'set.move', Ref: id, Value: -1 });
btn('down', 'arrow-down', $ict.setsDown, si >= 0 && si < sibs.length - 1, { Target: 'set.move', Ref: id, Value: 1 });
btn('out', 'chevron-left', $ict.setsOut, lvl > 0, { Target: 'set.level', Ref: id, Value: -1 });
btn('in', 'chevron-right', $ict.setsIn, hasSibSet, { Target: 'set.level', Ref: id, Value: 1 });
tr.on('dragstart', (ev) => {
$inv.sets.drag = { id: id, bid: bid };
tr.aC('dragging');
try { ev.originalEvent.dataTransfer.setData('text/plain', id); ev.originalEvent.dataTransfer.effectAllowed = 'move'; } catch (e) { /* ignore */ }
}).on('dragend', () => { tr.rC('dragging'); $inv.sets.drag = null; $inv.sets.clearDrop(); });
/* Every row is a target: before / after (reorder, become the anchor's sibling — works on a
folded set too, its subtree travels along) and, for sets, into (become its last member). */
let ok = () => $inv.sets.canDrop($inv.sets.drag, id, bid, byId);
tr.on('dragover', (ev) => {
if (!ok()) { return; }
ev.preventDefault();
let z = $inv.sets.zone(ev, tr, isSet);
tr.rC('dropok dropbefore dropafter').aC(z === 'into' ? 'dropok' : (z === 'before' ? 'dropbefore' : 'dropafter'));
}).on('dragleave', () => { tr.rC('dropok dropbefore dropafter'); }).on('drop', (ev) => {
ev.preventDefault(); $inv.sets.clearDrop();
let d = $inv.sets.drag; $inv.sets.drag = null;
if (!$inv.sets.canDrop(d, id, bid, byId)) { return; }
let z = $inv.sets.zone(ev, tr, isSet);
if (z === 'into') { $inv.sets.sync({ Target: 'set.assign', Ref: d.id, Value: { parent: id } }); }
else { $inv.sets.sync({ Target: 'set.move', Ref: d.id, Value: { anchor: id, where: z } }); }
});
return tr;
},
/* Drop target for "pull out of its set" (top level of the block). */
rootZone: function (el, bid, byId) {
let ok = () => { let d = $inv.sets.drag; return !!d && d.bid === bid && !!byId[d.id] && $inv.sets.parentOf(byId[d.id], byId) !== ''; };
el.on('dragover', (ev) => { if (ok()) { ev.preventDefault(); el.aC('dropok'); } })
.on('dragleave', () => { el.rC('dropok'); })
.on('drop', (ev) => {
ev.preventDefault(); el.rC('dropok');
let d = $inv.sets.drag; $inv.sets.drag = null;
if (d && d.bid === bid && byId[d.id] && $inv.sets.parentOf(byId[d.id], byId) !== '') {
$inv.sets.sync({ Target: 'set.assign', Ref: d.id, Value: { parent: '' } });
}
});
return el;
},
/* "New line" bar per block: type (Set/Text), target set (top level or any set), title. */
createBar: function (blk, bid, itms, byId) {
let bar = $$.dc('screate', blk);
let typ = $('<select></select>').attr({ name: 'typ' }).append([$$.opt('set', $ict.settyp.set), $$.opt('text', $ict.settyp.text)]);
let par = $('<select></select>').attr({ name: 'parent' }).append($$.opt('', $ict.setsTop));
itms.forEach((co) => {
if (!$inv.sets.isSet(co)) { return; }
let lvl = parseInt(co.lvl || 0, 10) || 0;
par.append($$.opt((co.id || '').toString(), ' '.repeat(lvl * 3) + $inv.sets.title(co)));
});
let ttl = $('<input type="text" />').attr({ name: 'title', placeholder: $ict.setsTitle, maxlength: 250 });
let add = () => {
let t = (ttl.val() || '').toString().trim();
if (t === '') { ttl.focus(); return; }
$inv.sets.sync({ Target: 'set.create', Ref: bid, Value: { typ: typ.val(), title: t, parent: par.val() || '' } });
ttl.val('');
};
ttl.on('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); add(); } });
bar.append([$$.sc('lbl', $ict.setsNew), typ, $$.sc('lbl', $ict.setsInto), par, ttl, $$.bbtn($ict.setsAdd, 'sadd').click(add)]);
return bar;
}
};