Add user-defined invoice set structure dialog (ADR 0016)
Playwright Tests / test (push) Canceled after 0s
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>
This commit is contained in:
@@ -86,7 +86,7 @@ $inv.iMn = (ix) => {
|
||||
yet been explicitly switched to a set-display mode (admin.setmode empty/undefined) — once set,
|
||||
changing it again is not supported (the user edits the line items directly instead); it
|
||||
reappears only if 'Änderungen verwerfen' reverts to a DB state that was never switched. */
|
||||
$inv.emOpt = (admin) => 'iss,p13b,ctp' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
|
||||
$inv.emOpt = (admin) => 'iss,p13b,ctp,setst' + (((admin || {}).setmode || '') === '' ? ',setm' : '');
|
||||
$inv.eM = (r, re, opt) => {
|
||||
let m = [];
|
||||
if (booln(r, false) === true || booln(re, false) === true) {
|
||||
@@ -104,6 +104,9 @@ $inv.eM = (r, re, opt) => {
|
||||
if ((opt || '').split(',').includes('setm') === true) {
|
||||
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
|
||||
}
|
||||
if ((opt || '').split(',').includes('setst') === true) { /* user-defined set structure dialog (ADR 0016) */
|
||||
m.push({ lbl: $ict.setst, fnc: () => $inv.sets.open() });
|
||||
}
|
||||
if ((opt || '').split(',').includes('iss') === true) { /* backend-authoritative draft (ADR 0006) */
|
||||
m.push({ lbl: 'Änderungshistorie', fnc: () => $inv.d.history() });
|
||||
m.push({ lbl: 'Änderungen verwerfen', fnc: () => $inv.d.discard() });
|
||||
@@ -172,12 +175,13 @@ $inv.d = {
|
||||
if (typeof curVer === 'number' && typeof state.version === 'number' && state.version < curVer) {
|
||||
return;
|
||||
}
|
||||
tbl.data('dver', state.version).data('serverSums', state.sums);
|
||||
tbl.data('dver', state.version).data('serverSums', state.sums).data('dstate', state);
|
||||
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
|
||||
$inv.d.notes(state.notes || []);
|
||||
$inv.d.validation(state.validation || []);
|
||||
$inv.d.applyItems(tbl, state.req || []);
|
||||
$inv.d.applySetDisplay(tbl, state.setDisplay || {});
|
||||
if ($inv.sets && $inv.sets.isOpen()) { $inv.sets.render(state); } /* the set-structure dialog is a view of the same state (ADR 0016) */
|
||||
/* Keep the menu's "Set-Preisanzeige" entry in sync with the authoritative admin.setmode:
|
||||
hides it right after a mode switch, and restores it if a discard reverted the draft to
|
||||
a DB state that was never switched (ADR 0006 backend-authoritative refresh). */
|
||||
@@ -223,13 +227,15 @@ $inv.d = {
|
||||
let changed = isNew || (dta.Type || '') !== (co.typ || '')
|
||||
|| dta.net !== val(co.v) || dta.net_val !== val(co.vt) || dta.vat_val !== val(co.vv)
|
||||
|| dta.svcnet_val !== val(co.vs) || dta.svcvat_val !== val(co.vsv)
|
||||
|| (dta.position || '') !== (co.p != null ? co.p : '');
|
||||
|| (dta.position || '') !== (co.p != null ? co.p : '')
|
||||
|| (dta.lvl || 0) !== (co.lvl || 0) || (dta.SetItmId || null) !== (co.SetItmId != null ? co.SetItmId : null);
|
||||
$.extend(dta, {
|
||||
Id: id, Type: co.typ,
|
||||
net: val(co.v), net_val: val(co.vt), vat_val: val(co.vv),
|
||||
svcnet_val: val(co.vs), svcvat_val: val(co.vsv),
|
||||
vat: co.vat, position: (co.p != null ? co.p : ''),
|
||||
SetItmId: (co.SetItmId != null ? co.SetItmId : null)
|
||||
SetItmId: (co.SetItmId != null ? co.SetItmId : null),
|
||||
lvl: (co.lvl || 0) /* nesting depth (ADR 0016) — rendered as an indent */
|
||||
});
|
||||
if (isNew) { $.extend(dta, { htmltext: co.t, quantity: co.q }); } // text only for server-created rows (e.g. the set row)
|
||||
rw.data(dta);
|
||||
@@ -311,7 +317,7 @@ $inv.d = {
|
||||
/* Map an inline recipient field to its delta target and send it. No token-guard here for the
|
||||
same reason as syncChanged: sync() waits for an in-flight seed instead of dropping the edit. */
|
||||
syncField: function (nme, val) {
|
||||
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
|
||||
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', invoiceemailcc: 'emailcc', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
|
||||
let target = map[nme]; if (!target) { return; }
|
||||
$inv.d.sync({ Target: target, Value: val });
|
||||
},
|
||||
@@ -898,6 +904,7 @@ $inv.cntInv = function (data) { //invoice continuation
|
||||
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
|
||||
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
||||
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
|
||||
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
|
||||
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
|
||||
|
||||
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
|
||||
@@ -960,6 +967,7 @@ $inv.cSt = function (data) {
|
||||
fm('adrfrm', inv.SendToAddress, 'invoiceaddress', 0, null);
|
||||
fm('locfrm', inv.ProvisionLocation, 'loc', 1, null);
|
||||
fm('emailfrm', inv.SendToEmail, 'invoiceemail', 0, null);
|
||||
fm('emailccfrm', inv.SendToEmailCc, 'invoiceemailcc', 0, null);
|
||||
$$.dc('sndfrm', frm).append($$.dc('content').text(response.admin.sender));
|
||||
|
||||
fm('admfrm', inv.ProvisionPeriod, 'provisionperiod', ((inv.ProvisionPeriod || '').includes('-') === true ? $rct.provP : $rct.provD), 1, null);
|
||||
@@ -1101,7 +1109,7 @@ $inv.eHtml = function (ev) {
|
||||
Leistungsdatum). The backend sanitises HTML too (single source of truth, ADR 0006), but
|
||||
keeping these plain here avoids the UI briefly holding the wrapped value. Multi-line fields
|
||||
(invoiceaddress, loc) stay HTML-capable and are normalised to newlines server-side. */
|
||||
let isPlainText = ['invoiceemail', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
|
||||
let isPlainText = ['invoiceemail', 'invoiceemailcc', 'provisionperiod', 'invoicetitle'].includes(ev.data.nme);
|
||||
let flds = isPlainText
|
||||
? [{ name: 'txt', label: 'Text', type: 'text', value: frmct.text() }]
|
||||
: [{ name: 'txt', label: 'Text', type: 'html', value: frmct.html(), tinymce: true, attr: { style: 'height: 300px' } }];
|
||||
@@ -1234,7 +1242,7 @@ $inv.rrw = function () {
|
||||
} else if (rw.is('.itm.osum') === true) {
|
||||
co = { invrqid: dta.InvRqId, id: 'osum' + rw.index(), typ: 'osum', p: '', q: null, t: oHtml(dta.tbl.tbl), tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: false };
|
||||
} else {
|
||||
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null };
|
||||
co = { invrqid: dta.InvRqId, id: dta.Id || '', typ: dta.Type || 'other', p: '', q: null, t: '', tt: null, v: null, vt: dta.net_val, vs: dta.svcnet_val, vat: dta.vat, vv: dta.vat_val, vsv: dta.svcvat_val, det: (dta.Note || '') !== '' && hn === false, SetItmId: dta.SetItmId || null, lvl: (dta.lvl || 0) };
|
||||
$$.dc('ibtn ico move', axf, { title: $rct.mR }); /* should be the last added */
|
||||
|
||||
co.p = dta.position || (dta.SortOrder || '');
|
||||
@@ -1274,6 +1282,7 @@ $inv.rrw = function () {
|
||||
}
|
||||
rw.empty().attr('class', ph ? 'placeholder' : 'itm').aC(co.Typ)
|
||||
.tC('sethdr', (co.typ || '').toString().toLowerCase() === 'set') /* emphasise set rows (fn1 header + block set row) */
|
||||
.aC((co.lvl || 0) > 0 ? 'lvl' + Math.min(co.lvl, 6) : '') /* indent nested set members (ADR 0016) */
|
||||
.tC('hidenote', hn).append(tda);
|
||||
dta.co = co;
|
||||
};
|
||||
@@ -1304,8 +1313,23 @@ $inv.invSumUpdate = function () {
|
||||
let tbl = $(this);
|
||||
let ba = [];
|
||||
let bds = tbl.children('tbody');
|
||||
/* Hierarchical position numbering (ADR 0017), spanning the whole invoice: a running counter
|
||||
per nesting depth, ported from InvoiceLineNumbering.Compute. A set header gets a number at
|
||||
its own level ("3"); its members get subsidiary numbers one level deeper ("3.1", "3.2"). A
|
||||
row's numbering never depends on the MFR SortOrder value — including for a Text/Title
|
||||
header row synced with SortOrder -1, which used to leak "-1" straight into the Pos. column
|
||||
here because it never went through this loop at all. */
|
||||
let posCounters = [];
|
||||
let nextPositionLabel = (numbered, lvl) => {
|
||||
if (!numbered) { return ''; }
|
||||
let l = Math.max(lvl || 0, 0);
|
||||
while (posCounters.length <= l) { posCounters.push(0); }
|
||||
posCounters.length = l + 1;
|
||||
posCounters[l]++;
|
||||
return posCounters.join('.');
|
||||
};
|
||||
bds.each((bi, bdy) => {
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm'), iso = 0, ipos = 0;
|
||||
let b = $(bdy), rx = b.data() || {}, i = [], citems = [], itm = b.find('tr.itm');
|
||||
b.tC('empty', itm.length < 1);
|
||||
itm.each((ti, tx) => {
|
||||
let rrx = $(tx).data() || {};
|
||||
@@ -1318,14 +1342,12 @@ $inv.invSumUpdate = function () {
|
||||
by the id !== '' && !== citem.id check below). */
|
||||
let citem = $inv.itemToContract(rrx);
|
||||
let sid = (rrx.SetItmId || '').toString();
|
||||
if (citem.type !== 'set' && sid !== '' && sid !== citem.id) { citem.setId = sid; }
|
||||
/* a nested set (ADR 0016) is itself a member of its parent set, so the type is not excluded here */
|
||||
if (sid !== '' && sid !== citem.id) { citem.setId = sid; }
|
||||
citems.push(citem);
|
||||
if (((typeof rrx.SortOrder === 'undefined' || rrx.SortOrder === null) ? -1 : rrx.SortOrder) > -1) {
|
||||
if (['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false) { ipos++; }
|
||||
rrx.SortOrder = iso;
|
||||
rrx.position = ipos;
|
||||
$inv.rrw.call(tx);
|
||||
}
|
||||
let numbered = ['text', 'title'].includes((rrx.Type || 'other').toLowerCase()) === false;
|
||||
rrx.position = nextPositionLabel(numbered, (rrx.co || {}).lvl || 0);
|
||||
$inv.rrw.call(tx);
|
||||
});
|
||||
ba.push({ Id: rx.Id, nme: rx.Name, text: rx.text, itm: i, items: citems });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user