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:
@@ -17,8 +17,11 @@
|
||||
}
|
||||
$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 () {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/* ── 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;
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -81,6 +81,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
.emailccfrm {
|
||||
position: absolute;
|
||||
font-size: 0.7rem;
|
||||
top: 2.2rem;
|
||||
left: 0.5rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
cursor: pointer;
|
||||
|
||||
.content {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.content:empty::before {
|
||||
content: 'Zusatzempfänger hinzufügen';
|
||||
min-width: 5rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
div.axf {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admfrm {
|
||||
position: absolute;
|
||||
right: 3rem;
|
||||
@@ -250,6 +281,12 @@
|
||||
&.sethdr td.txt {
|
||||
font-weight: bold;
|
||||
}
|
||||
/* Nested set members (ADR 0016): indent per level, mirroring the PDF's per-level indent. */
|
||||
@for $i from 1 through 6 {
|
||||
&.lvl#{$i} td.txt {
|
||||
padding-left: calc(0.4rem + #{$i * 1.4rem});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> tfoot > tr {
|
||||
@@ -493,3 +530,71 @@
|
||||
color: #445;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Set structure dialog (ADR 0016, see $inv.sets in fis.inv_sets.js) ─────────────────── */
|
||||
.modal .setstruct {
|
||||
&.busy { opacity: 0.6; pointer-events: none; }
|
||||
|
||||
div.shint { font-size: 0.85rem; color: #666; margin: 0 0 0.8rem 0; }
|
||||
|
||||
div.sblock {
|
||||
margin: 0 0 1.4rem 0;
|
||||
|
||||
div.sbhd {
|
||||
font-weight: bold;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-top: 1.5px solid #000;
|
||||
border-bottom: 1px solid #ddd;
|
||||
&.dropok { background-color: #e6f2e6; outline: 2px dashed #5a5; }
|
||||
}
|
||||
|
||||
table.stbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th { text-align: left; font-size: 0.8rem; color: #666; padding: 0.3rem 0.4rem; border-bottom: 1px solid #ddd; }
|
||||
td { padding: 0.3rem 0.4rem; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
|
||||
td.grip { width: 1.6rem; cursor: grab; color: #999; }
|
||||
td.pos, td.qty { width: 4rem; white-space: nowrap; }
|
||||
td.typ { width: 7rem; white-space: nowrap; color: #666; font-size: 0.85rem;
|
||||
span.fold { cursor: pointer; margin-right: 0.35rem; color: #555; display: inline-block; width: 1rem; }
|
||||
}
|
||||
td.ttl {
|
||||
span.ind { display: inline-block; }
|
||||
span.seti { margin-right: 0.3rem; color: #888; }
|
||||
span.cnt { color: #999; font-weight: normal; font-size: 0.85rem; }
|
||||
}
|
||||
td.act { width: 7.5rem; white-space: nowrap; text-align: right;
|
||||
div.ibtn { display: inline-block; cursor: pointer; padding: 0 0.25rem; &.disabled { opacity: 0.25; cursor: default; } }
|
||||
}
|
||||
|
||||
tr.srow {
|
||||
&.isset td.ttl { font-weight: bold; }
|
||||
&.hid { display: none; }
|
||||
&.folded td.ttl { font-style: italic; }
|
||||
&.dragging { opacity: 0.4; }
|
||||
&.dropok td { background-color: #e6f2e6; }
|
||||
&.dropbefore td { border-top: 3px solid #5a5; }
|
||||
&.dropafter td { border-bottom: 3px solid #5a5; }
|
||||
}
|
||||
}
|
||||
|
||||
div.sroot {
|
||||
margin: 0.3rem 0 0.5rem 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px dashed #bbb;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
&.dropok { background-color: #e6f2e6; border-color: #5a5; color: #363; }
|
||||
}
|
||||
|
||||
div.screate {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background-color: #f7f7f7;
|
||||
span.lbl { font-size: 0.85rem; color: #666; }
|
||||
select, input[type="text"] { padding: 0.2rem 0.3rem; }
|
||||
input[type="text"] { flex: 1 1 14rem; min-width: 10rem; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,24 @@
|
||||
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.',
|
||||
|
||||
Reference in New Issue
Block a user