Add user-defined invoice set structure dialog (ADR 0016)
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:
2026-09-10 08:55:05 +02:00
co-authored by Claude Sonnet 5
parent c1936a327e
commit ecc74cf3c6
31 changed files with 3003 additions and 147 deletions
+189
View File
@@ -1,3 +1,4 @@
@charset "UTF-8";
/* basics */
/* Color SCHEME */
/* #8396bd */
@@ -169,6 +170,33 @@ table.if td.num {
.edit_frm .invoice_layout .emailfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .emailccfrm {
position: absolute;
font-size: 0.7rem;
top: 2.2rem;
left: 0.5rem;
width: auto;
height: auto;
cursor: pointer;
}
.edit_frm .invoice_layout .emailccfrm .content {
color: #000;
}
.edit_frm .invoice_layout .emailccfrm {
/* Unlike the other optional fields (plain "-" placeholder), this one names the action —
there is no default "Zusatzempfänger" from MFR, so an empty state must read as an
offer to add one, not as missing data. */
}
.edit_frm .invoice_layout .emailccfrm .content:empty::before {
content: "Zusatzempfänger hinzufügen";
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
.edit_frm .invoice_layout .emailccfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .admfrm {
position: absolute;
right: 3rem;
@@ -302,6 +330,27 @@ table.if td.num {
.edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt {
font-weight: bold;
}
.edit_frm .invoice_layout table.invi > tbody > tr {
/* Nested set members (ADR 0016): indent per level, mirroring the PDF's per-level indent. */
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl1 td.txt {
padding-left: calc(0.4rem + 1.4rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl2 td.txt {
padding-left: calc(0.4rem + 2.8rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl3 td.txt {
padding-left: calc(0.4rem + 4.2rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl4 td.txt {
padding-left: calc(0.4rem + 5.6rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl5 td.txt {
padding-left: calc(0.4rem + 7rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl6 td.txt {
padding-left: calc(0.4rem + 8.4rem);
}
.edit_frm .invoice_layout table.invi th.currency, .edit_frm .invoice_layout table.invi th.num, .edit_frm .invoice_layout table.invi td.currency, .edit_frm .invoice_layout table.invi td.num {
text-align: right;
white-space: nowrap;
@@ -510,4 +559,144 @@ table.if td.num {
}
.adr-conformity .tx {
color: #445;
}
/* ── Set structure dialog (ADR 0016, see $inv.sets in fis.inv_sets.js) ─────────────────── */
.modal .setstruct.busy {
opacity: 0.6;
pointer-events: none;
}
.modal .setstruct div.shint {
font-size: 0.85rem;
color: #666;
margin: 0 0 0.8rem 0;
}
.modal .setstruct div.sblock {
margin: 0 0 1.4rem 0;
}
.modal .setstruct div.sblock div.sbhd {
font-weight: bold;
padding: 0.4rem 0.6rem;
border-top: 1.5px solid #000;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock div.sbhd.dropok {
background-color: #e6f2e6;
outline: 2px dashed #5a5;
}
.modal .setstruct div.sblock table.stbl {
width: 100%;
border-collapse: collapse;
}
.modal .setstruct div.sblock table.stbl th {
text-align: left;
font-size: 0.8rem;
color: #666;
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock table.stbl td {
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.modal .setstruct div.sblock table.stbl td.grip {
width: 1.6rem;
cursor: grab;
color: #999;
}
.modal .setstruct div.sblock table.stbl td.pos, .modal .setstruct div.sblock table.stbl td.qty {
width: 4rem;
white-space: nowrap;
}
.modal .setstruct div.sblock table.stbl td.typ {
width: 7rem;
white-space: nowrap;
color: #666;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.typ span.fold {
cursor: pointer;
margin-right: 0.35rem;
color: #555;
display: inline-block;
width: 1rem;
}
.modal .setstruct div.sblock table.stbl td.ttl span.ind {
display: inline-block;
}
.modal .setstruct div.sblock table.stbl td.ttl span.seti {
margin-right: 0.3rem;
color: #888;
}
.modal .setstruct div.sblock table.stbl td.ttl span.cnt {
color: #999;
font-weight: normal;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.act {
width: 7.5rem;
white-space: nowrap;
text-align: right;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn {
display: inline-block;
cursor: pointer;
padding: 0 0.25rem;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn.disabled {
opacity: 0.25;
cursor: default;
}
.modal .setstruct div.sblock table.stbl tr.srow.isset td.ttl {
font-weight: bold;
}
.modal .setstruct div.sblock table.stbl tr.srow.hid {
display: none;
}
.modal .setstruct div.sblock table.stbl tr.srow.folded td.ttl {
font-style: italic;
}
.modal .setstruct div.sblock table.stbl tr.srow.dragging {
opacity: 0.4;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropok td {
background-color: #e6f2e6;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropbefore td {
border-top: 3px solid #5a5;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropafter td {
border-bottom: 3px solid #5a5;
}
.modal .setstruct div.sblock div.sroot {
margin: 0.3rem 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border: 1px dashed #bbb;
color: #888;
font-size: 0.85rem;
}
.modal .setstruct div.sblock div.sroot.dropok {
background-color: #e6f2e6;
border-color: #5a5;
color: #363;
}
.modal .setstruct div.sblock div.screate {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
background-color: #f7f7f7;
}
.modal .setstruct div.sblock div.screate span.lbl {
font-size: 0.85rem;
color: #666;
}
.modal .setstruct div.sblock div.screate select, .modal .setstruct div.sblock div.screate input[type=text] {
padding: 0.2rem 0.3rem;
}
.modal .setstruct div.sblock div.screate input[type=text] {
flex: 1 1 14rem;
min-width: 10rem;
}
+257 -15
View File
@@ -65,6 +65,7 @@ let $rct = {
invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
}
}, $rcol = {
@@ -154,6 +155,24 @@ let $ict = {
setprice: 'Set mit Preis – Positionen ohne Preis',
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
},
/* user-defined set structure dialog (ADR 0016) */
setst: 'Set-Struktur bearbeiten',
setsHint: 'Reihenfolge: Zeile per Drag & Drop an den oberen/unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲▼ nutzen. Zuordnung: Zeile auf die Mitte eines Sets ziehen (hinein) bzw. auf die Auftragszeile oder "oberste Ebene" (aus dem Set lösen); ◀▶ ändert die Ebene. Sets lassen sich über das Dreieck zu-/aufklappen und auch zugeklappt verschieben – ihre Positionen wandern mit. Änderungen werden sofort übernommen.',
setsUp: 'Nach oben (innerhalb der Ebene)',
setsDown: 'Nach unten (innerhalb der Ebene)',
setsCollapse: 'Set zuklappen',
setsExpand: 'Set aufklappen',
setsHR: ['', 'Pos.', 'Typ', 'Menge', 'Bezeichnung', 'Reihenfolge / Ebene'],
setsRoot: 'Hierher ziehen = aus dem Set lösen (oberste Ebene)',
setsOut: 'Eine Ebene höher (aus dem Set lösen)',
setsIn: 'Eine Ebene tiefer (dem vorangehenden Set zuordnen)',
setsNew: 'Neu:',
setsInto: 'in',
setsTop: 'oberste Ebene',
setsTitle: 'Bezeichnung',
setsAdd: 'Hinzufügen',
setsNoTitle: '(ohne Bezeichnung)',
settyp: { set: 'Set', text: 'Text', title: 'Text', material: 'Material', service: 'Arbeitsleistung', equipment: 'Ausrüstung', other: 'Position', osum: 'Zusammenfassung', intinvoiceallocation: 'Abschlag', intinvoiceallocationdiff: 'Abschlag (offen)' },
ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
@@ -629,7 +648,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) {
@@ -647,6 +666,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() });
@@ -715,12 +737,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). */
@@ -766,13 +789,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);
@@ -854,7 +879,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 });
},
@@ -1441,6 +1466,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);
@@ -1503,6 +1529,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);
@@ -1644,7 +1671,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' } }];
@@ -1777,7 +1804,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 || '');
@@ -1817,6 +1844,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;
};
@@ -1847,8 +1875,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() || {};
@@ -1861,14 +1904,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 });
});
@@ -2403,3 +2444,204 @@ $inv.mfrrel = (id) => {
}
});
};
/* ── 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;
}
};
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -2882,8 +2882,11 @@ $ocms.init = function (ev) {
}
$ocms[mdl].auth = auth;
if (auth.manage > 0) {
/* ?v=<assetversion> (newest bundle write time, emitted by _Layout) busts the browser
cache of the dynamically imported module bundle + css after a gulp rebuild. */
let av = '?v=' + encodeURIComponent($ocms.auth.assetversion || '0');
$ocms.getScript({
module: mdl, script: ['/web/fis', mdl, $ocms.auth.locale || 'de', 'js'].join('.'), css: ['/web/fis', mdl, 'css'].join('.'), condition: typeof $ocms[mdl].init2 !== 'function'
module: mdl, script: ['/web/fis', mdl, $ocms.auth.locale || 'de', 'js'].join('.') + av, css: ['/web/fis', mdl, 'css'].join('.') + av, condition: typeof $ocms[mdl].init2 !== 'function'
}, function () { $ocms[mdl].init2(); });
}
}, error: function () {
+1 -1
View File
File diff suppressed because one or more lines are too long
+189
View File
@@ -1,3 +1,4 @@
@charset "UTF-8";
/* basics */
/* Color SCHEME */
/* #8396bd */
@@ -306,6 +307,33 @@ table.if th.keep, table.if td.keep {
.edit_frm .invoice_layout .emailfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .emailccfrm {
position: absolute;
font-size: 0.7rem;
top: 2.2rem;
left: 0.5rem;
width: auto;
height: auto;
cursor: pointer;
}
.edit_frm .invoice_layout .emailccfrm .content {
color: #000;
}
.edit_frm .invoice_layout .emailccfrm {
/* Unlike the other optional fields (plain "-" placeholder), this one names the action —
there is no default "Zusatzempfänger" from MFR, so an empty state must read as an
offer to add one, not as missing data. */
}
.edit_frm .invoice_layout .emailccfrm .content:empty::before {
content: "Zusatzempfänger hinzufügen";
min-width: 5rem;
height: 1rem;
display: block;
position: absolute;
}
.edit_frm .invoice_layout .emailccfrm:hover div.axf {
display: block;
}
.edit_frm .invoice_layout .admfrm {
position: absolute;
right: 3rem;
@@ -439,6 +467,27 @@ table.if th.keep, table.if td.keep {
.edit_frm .invoice_layout table.invi > tbody > tr.sethdr td.txt {
font-weight: bold;
}
.edit_frm .invoice_layout table.invi > tbody > tr {
/* Nested set members (ADR 0016): indent per level, mirroring the PDF's per-level indent. */
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl1 td.txt {
padding-left: calc(0.4rem + 1.4rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl2 td.txt {
padding-left: calc(0.4rem + 2.8rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl3 td.txt {
padding-left: calc(0.4rem + 4.2rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl4 td.txt {
padding-left: calc(0.4rem + 5.6rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl5 td.txt {
padding-left: calc(0.4rem + 7rem);
}
.edit_frm .invoice_layout table.invi > tbody > tr.lvl6 td.txt {
padding-left: calc(0.4rem + 8.4rem);
}
.edit_frm .invoice_layout table.invi th.currency, .edit_frm .invoice_layout table.invi th.num, .edit_frm .invoice_layout table.invi td.currency, .edit_frm .invoice_layout table.invi td.num {
text-align: right;
white-space: nowrap;
@@ -647,4 +696,144 @@ table.if th.keep, table.if td.keep {
}
.adr-conformity .tx {
color: #445;
}
/* ── Set structure dialog (ADR 0016, see $inv.sets in fis.inv_sets.js) ─────────────────── */
.modal .setstruct.busy {
opacity: 0.6;
pointer-events: none;
}
.modal .setstruct div.shint {
font-size: 0.85rem;
color: #666;
margin: 0 0 0.8rem 0;
}
.modal .setstruct div.sblock {
margin: 0 0 1.4rem 0;
}
.modal .setstruct div.sblock div.sbhd {
font-weight: bold;
padding: 0.4rem 0.6rem;
border-top: 1.5px solid #000;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock div.sbhd.dropok {
background-color: #e6f2e6;
outline: 2px dashed #5a5;
}
.modal .setstruct div.sblock table.stbl {
width: 100%;
border-collapse: collapse;
}
.modal .setstruct div.sblock table.stbl th {
text-align: left;
font-size: 0.8rem;
color: #666;
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #ddd;
}
.modal .setstruct div.sblock table.stbl td {
padding: 0.3rem 0.4rem;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.modal .setstruct div.sblock table.stbl td.grip {
width: 1.6rem;
cursor: grab;
color: #999;
}
.modal .setstruct div.sblock table.stbl td.pos, .modal .setstruct div.sblock table.stbl td.qty {
width: 4rem;
white-space: nowrap;
}
.modal .setstruct div.sblock table.stbl td.typ {
width: 7rem;
white-space: nowrap;
color: #666;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.typ span.fold {
cursor: pointer;
margin-right: 0.35rem;
color: #555;
display: inline-block;
width: 1rem;
}
.modal .setstruct div.sblock table.stbl td.ttl span.ind {
display: inline-block;
}
.modal .setstruct div.sblock table.stbl td.ttl span.seti {
margin-right: 0.3rem;
color: #888;
}
.modal .setstruct div.sblock table.stbl td.ttl span.cnt {
color: #999;
font-weight: normal;
font-size: 0.85rem;
}
.modal .setstruct div.sblock table.stbl td.act {
width: 7.5rem;
white-space: nowrap;
text-align: right;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn {
display: inline-block;
cursor: pointer;
padding: 0 0.25rem;
}
.modal .setstruct div.sblock table.stbl td.act div.ibtn.disabled {
opacity: 0.25;
cursor: default;
}
.modal .setstruct div.sblock table.stbl tr.srow.isset td.ttl {
font-weight: bold;
}
.modal .setstruct div.sblock table.stbl tr.srow.hid {
display: none;
}
.modal .setstruct div.sblock table.stbl tr.srow.folded td.ttl {
font-style: italic;
}
.modal .setstruct div.sblock table.stbl tr.srow.dragging {
opacity: 0.4;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropok td {
background-color: #e6f2e6;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropbefore td {
border-top: 3px solid #5a5;
}
.modal .setstruct div.sblock table.stbl tr.srow.dropafter td {
border-bottom: 3px solid #5a5;
}
.modal .setstruct div.sblock div.sroot {
margin: 0.3rem 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border: 1px dashed #bbb;
color: #888;
font-size: 0.85rem;
}
.modal .setstruct div.sblock div.sroot.dropok {
background-color: #e6f2e6;
border-color: #5a5;
color: #363;
}
.modal .setstruct div.sblock div.screate {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
background-color: #f7f7f7;
}
.modal .setstruct div.sblock div.screate span.lbl {
font-size: 0.85rem;
color: #666;
}
.modal .setstruct div.sblock div.screate select, .modal .setstruct div.sblock div.screate input[type=text] {
padding: 0.2rem 0.3rem;
}
.modal .setstruct div.sblock div.screate input[type=text] {
flex: 1 1 14rem;
min-width: 10rem;
}
+257 -15
View File
@@ -65,6 +65,7 @@ let $rct = {
invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
}
}, $rcol = {
@@ -154,6 +155,24 @@ let $ict = {
setprice: 'Set mit Preis – Positionen ohne Preis',
setonly: 'Nur Set mit Preis – Positionen ausgeblendet'
},
/* user-defined set structure dialog (ADR 0016) */
setst: 'Set-Struktur bearbeiten',
setsHint: 'Reihenfolge: Zeile per Drag & Drop an den oberen/unteren Rand einer anderen Zeile ziehen (davor/danach) oder ▲▼ nutzen. Zuordnung: Zeile auf die Mitte eines Sets ziehen (hinein) bzw. auf die Auftragszeile oder "oberste Ebene" (aus dem Set lösen); ◀▶ ändert die Ebene. Sets lassen sich über das Dreieck zu-/aufklappen und auch zugeklappt verschieben – ihre Positionen wandern mit. Änderungen werden sofort übernommen.',
setsUp: 'Nach oben (innerhalb der Ebene)',
setsDown: 'Nach unten (innerhalb der Ebene)',
setsCollapse: 'Set zuklappen',
setsExpand: 'Set aufklappen',
setsHR: ['', 'Pos.', 'Typ', 'Menge', 'Bezeichnung', 'Reihenfolge / Ebene'],
setsRoot: 'Hierher ziehen = aus dem Set lösen (oberste Ebene)',
setsOut: 'Eine Ebene höher (aus dem Set lösen)',
setsIn: 'Eine Ebene tiefer (dem vorangehenden Set zuordnen)',
setsNew: 'Neu:',
setsInto: 'in',
setsTop: 'oberste Ebene',
setsTitle: 'Bezeichnung',
setsAdd: 'Hinzufügen',
setsNoTitle: '(ohne Bezeichnung)',
settyp: { set: 'Set', text: 'Text', title: 'Text', material: 'Material', service: 'Arbeitsleistung', equipment: 'Ausrüstung', other: 'Position', osum: 'Zusammenfassung', intinvoiceallocation: 'Abschlag', intinvoiceallocationdiff: 'Abschlag (offen)' },
ctp: 'Ansprechpartner festlegen',
mfr: 'Von MFR neu abrufen',
rq1: 'Auftragsdaten werden von MFR abgerufen.\nDer Vorgang kann bis zu 90Sek dauern.',
@@ -610,7 +629,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) {
@@ -628,6 +647,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() });
@@ -696,12 +718,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). */
@@ -747,13 +770,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);
@@ -835,7 +860,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 });
},
@@ -1422,6 +1447,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);
@@ -1484,6 +1510,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);
@@ -1625,7 +1652,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' } }];
@@ -1758,7 +1785,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 || '');
@@ -1798,6 +1825,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;
};
@@ -1828,8 +1856,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() || {};
@@ -1842,14 +1885,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 });
});
@@ -2384,3 +2425,204 @@ $inv.mfrrel = (id) => {
}
});
};
/* ── 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;
}
};
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long