Playwright Tests / test (push) Canceled after 0s
Lets a back-office user correct/define which invoice line belongs to which "set" and how deep it is nested, instead of relying only on the implicit "everything after a Set header until the next Set" grouping fds__prepInvoice derives. Sets may contain sets; every change applies immediately to the cached draft session and is reflected in the editor and PDF (ADR 0006/0008). Backend: - InvoiceSetStructure: pure parent-link forest (assign, change level, create, place/move-sibling, depth-first linearise + level recompute). - InvoiceDraftSetStructureDeltas: new inv/dpatch targets set.assign, set.level, set.create, set.move. - InvoiceSetPricing.Build is now order-preserving and nesting-aware (governed by the nearest converted ancestor); the set-item switch sums a header's transitive descendants so nested conversions never double-count. - FuchsPdf indents nested set members per level, mirroring the editor. Frontend: - New fis.inv_sets.js: "Set-Struktur bearbeiten" dialog — per-block table of all lines with drag & drop reorder (before/after/into), ▲▼ order and ◀▶ level buttons, fold/unfold per set (view-only), and a create bar for new Set/Text lines. Posts one delta per action; the dialog is a pure view of the server state (ADR 0006). - _Layout.cshtml / fis_main.js: cache-bust the dynamically imported per-module bundles (web/fis.<mdl>.<locale>.js/.css) with an asset version derived from the newest bundle write time, so a gulp rebuild is picked up without a stale cached module. Docs: ADR 0016, live-draft-editing concept, INVOICE_SET_PRICING.md, USER_GUIDE.md, ARCHITECTURE.md updated. Tests: InvoiceSetStructureTests (new) + additions to InvoiceDraftServiceTests/InvoiceSetPricingTests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
201 lines
12 KiB
JavaScript
201 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).append($$.sc('ind').css('width', (lvl * 1.4).toString() + 'rem')).append($$.s($inv.sets.title(co)));
|
|
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;
|
|
}
|
|
};
|