Refactor code structure for improved readability and maintainability

This commit is contained in:
Stefan
2026-07-10 14:29:51 +02:00
parent af445c015e
commit 42997c4f49
18 changed files with 1237 additions and 615 deletions
+44
View File
@@ -405,6 +405,50 @@ table.if td.num {
animation: fis_spin 0.8s linear infinite;
}
/* Backend-authoritative draft editing (ADR 0006): server-side plausibility findings
rendered above the editor, and the change-history dialog table. */
.edit_frm .invoice_layout .dvalidation {
margin: 0 0 1rem 0;
}
.edit_frm .invoice_layout .dvalidation.hidden {
display: none;
}
.edit_frm .invoice_layout .dvalidation .dvmsg {
padding: 0.4rem 0.7rem;
margin: 0.25rem 0;
border-radius: 0.2rem;
font-size: 0.9rem;
border-left: 4px solid #999;
background: #f5f5f5;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.error {
border-left-color: #c0392b;
background: #fdecea;
color: #922;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.warning {
border-left-color: #e0a800;
background: #fff8e1;
color: #7a5c00;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.info {
border-left-color: #3498db;
background: #eaf4fb;
color: #1c5a82;
}
.dhist .invtbl {
width: 100%;
}
.dhist td, .dhist th {
padding: 0.3rem 0.6rem;
text-align: left;
vertical-align: top;
}
.dhist tbody tr:nth-child(even) {
background: #fafafa;
}
.modal-body .lstfrm {
display: block;
position: relative;
+216 -77
View File
@@ -646,11 +646,202 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
}
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() });
}
if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
}
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer +
validation from the authoritative server state. It is invoice-only: reminders
never obtain a token (invSumUpdate, the seed trigger, is only bound for invoices),
so they keep the legacy stateless flow untouched. */
$inv.d = {
tbl: () => $('div.invoice_layout table.invi'),
layout: () => $('div.invoice_layout'),
token: function () { return $inv.d.tbl().data('dtoken') || ''; },
/* Hash each assembled block so only genuinely-changed blocks are sent as deltas. */
hashes: function () {
let bai = $inv.d.tbl().data('bai') || [], h = {};
$.each(bai, (i, b) => { h[(b.Id || '').toString()] = JSON.stringify(b); });
return h;
},
/* Seed the authoritative server session from the assembled editor payload. Called
once from invSumUpdate on open; thereafter the backend is the source of truth. */
seed: function (payload) {
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes());
$fis.draft.bind(r.token, {
onReady: () => $inv.d.refresh(),
onExpiring: (s) => $inv.d.warnExpiry(s),
onClosed: (reason) => $inv.d.closed(reason)
});
$inv.d.refresh();
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding'); }
});
},
/* Re-fetch the authoritative state and render the totals footer + validation from it. */
refresh: function (cb) {
let t = $inv.d.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('inv/dstate'), data: { token: t }, success: (state) => {
$inv.d.applyState(state); if (typeof cb === 'function') { cb(state); }
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } },
complete: () => { $inv.d.layout().rC('freeze'); }
});
},
applyState: function (state) {
let tbl = $inv.d.tbl(); if (tbl.length < 1) { return; }
tbl.data('dver', state.version).data('serverSums', state.sums);
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.validation(state.validation || []);
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
sync: function (delta) {
let t = $inv.d.token(); if (t === '') { return; }
$inv.d.layout().aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
success: () => { $inv.d.refresh(); },
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
});
},
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. */
syncChanged: function (tbl) {
if (($inv.d.token()) === '') { return; }
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
tbl.data('dhashes', next);
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
},
/* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) {
if ($inv.d.token() === '') { return; }
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val });
},
/* Render the totals footer from the server sums (port of invSumUpdate's footer half). */
footer: function (tbl, sums, admin) {
let ft = tbl.children('tfoot').empty(); tbl.nextAll('.fnote').remove();
let p13b = bool(admin.p13b, false);
let rwcy = (lbl, val, cls) => $$.tdc('currency', $$.tr(ft, { class: cls || 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text(lbl)]), fnum(val, $rct.cst));
let fn = (t) => $$.dc('fnote').insertAfter(tbl).rwText(t);
rwcy('Netto', sums.total_net || 0);
if (p13b === false) { $.each(sums.vat || {}, (rate, amt) => rwcy($rct.vat + ' ' + rate + '%', amt, 'tvat')); }
rwcy('Summe', sums.total_gross || 0);
let itype = (admin.type || '');
if (itype === 'i') { fn($rct.note2); fn($rct.note4); }
else if (itype === 'c') { fn($rct.note2); }
else {
fn(string($rct.note3, [fnum(((sums.service_net || 0) + (sums.service_vat || 0)) * (admin.tax_servicerefund || 0), $rct.cst)])).aC('ntax');
fn($rct.note2);
fn(string($rct.note1, [fnum((sums.service_net || 0) + (sums.service_vat || 0), $rct.cst), fnum(sums.service_net || 0, $rct.cst), fnum(sums.service_vat || 0, $rct.cst)]));
}
if (p13b === true) { fn($rct.note13b); }
},
validation: function (msgs) {
let frm = $('div.invoice_layout'); if (frm.length < 1) { return; }
let box = frm.children('.dvalidation');
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
box.empty().tC('hidden', (msgs || []).length < 1);
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
},
/* PDF preview straight from the cache; confirm = flush + finalise, cancel = discard. */
preview: function () {
let t = $inv.d.token(); if (t === '') { return; }
let l = $inv.d.layout();
let email = ($inv.d.tbl().data('new') || {}).invoiceemail || '';
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI,
confirm: function (e) {
let ct = $(this); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
$ocms.init('req'); $inv.rReload();
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
});
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
save: function () {
let t = $inv.d.token(); if (t === '') { return; }
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (r) => { $inv.d.tbl().data('invid', r.invid); },
error: () => { alert($ict.eis); }, complete: () => { l.rC('freeze'); }
});
},
history: function () {
let t = $inv.d.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('inv/dhistory'), data: { token: t }, success: (r) => {
let c = $$.dc('dhist');
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
else {
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
}
$ocms.dlg(c, { width: 800, form: false });
}
});
},
discard: function () {
let invid = $inv.d.tbl().data('invid') || '';
if (invid === '') { alert('Es wurde noch kein Zwischenstand gespeichert, der wiederhergestellt werden könnte.'); return; }
if (confirm('Alle Änderungen verwerfen und den zuletzt gespeicherten Stand neu laden?') === false) { return; }
$inv.d.close();
$inv.cntInv({ id: invid });
},
warnExpiry: function (secondsLeft) {
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Rechnungsentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
},
closed: function (reason) {
let t = $inv.d.token();
$inv.d.tbl().removeData('dtoken');
if (t !== '') { $fis.draft.release(t); }
$fis.frm_edit().remove(); $fis.lf(true);
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Rechnungsentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Rechnungsentwurf wurde geschlossen.' });
try { $inv.rReload(); } catch (e) { }
},
close: function () {
let t = $inv.d.token();
if (t !== '') { $ocms.postXT({ url: $ocms.url('inv/dclose'), data: { token: t } }); $fis.draft.release(t); }
$inv.d.tbl().removeData('dtoken');
}
};
$inv.cInv2 = function (data) {
let fr = $$.dc('rfrm').ldng(1);
let o = $ocms.dlg(fr, { width: 1000 });
@@ -1046,6 +1237,8 @@ $inv.eHtml = function (ev) {
if (typeof change === 'function') {
change(response.txt);
}
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
},
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
}
@@ -1260,6 +1453,13 @@ $inv.invSumUpdate = function () {
}
tbl.data('sms', sms);
tbl.data('bai', ba);
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
model to the server session and switch to server-driven totals from then on. */
if ((tbl.data('dtoken') || '') === '' && bool(tbl.data('dseeding'), false) === false && ((tbl.data('admin') || {}).type != null)) {
tbl.data('dseeding', true);
$inv.d.seed($.extend($inv.invcPayload(tbl.data()), { invid: tbl.data('invid') || '' }));
}
};
$inv.worknotes = function (rx) {
let wn = '';
@@ -1347,7 +1547,13 @@ $inv.rendersrq = function () {
$$.tdc('currency isumval', istr);
}
};
$inv.t_fds_inv = () => { $('div.invoice_layout table.invi').trigger('fds.inv'); };
$inv.t_fds_inv = () => {
let tbl = $('div.invoice_layout table.invi');
tbl.trigger('fds.inv');
/* After any local item mutation, push the changed block(s) to the authoritative
server session as granular deltas (invoice drafts only — reminders have no token). */
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
};
$inv.sedit = () => {
$inv.sprev(true);
};
@@ -1402,6 +1608,7 @@ $inv.sp13b = () => {
} else {
}
tbl.trigger('fds.inv');
$inv.d.sync({ Target: 'p13b', Value: d.admin.p13b });
};
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
@@ -1456,6 +1663,7 @@ $inv.setSetmode = (mode) => {
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(',');
$inv.d.sync({ Target: 'setmode', Value: mode });
};
$inv.sctp = () => {
let flds = $invcol.ctp;
@@ -1472,6 +1680,7 @@ $inv.sctp = () => {
cvo.contactEmail = response.email;
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
l.find('.ctpfrm').text(ne(response.name, response.email));
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
}, typedvalues: true
});
@@ -1496,82 +1705,12 @@ $inv.invcPayload = function (d) {
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
};
$inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv();
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
$inv.cntInv({ id: response.id });
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
$inv.sprev = (change) => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
change = bool(change, false);
$inv.t_fds_inv();
l.aC('freeze');
//console.debug({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new });
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
if (bool(confirm($ict.ivE + $ict.ivEc),false) === false) {
l.rC('freeze');
return;
}
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
$$.dc('note warn', c).text($ict.tpe);
}
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
});
for (let ic = (response.img || []).length + 1; ic <= invtp; ic++) {
$$.dc('pdfp ph', c).append($$.dc('note', $ict.pna));
}
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
let ct = $(this);
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) {
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
}
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}, complete: () => {
l.rC('freeze');
}
});
}, cancel: function (e) {
let ct = $(this);
if (confirm($ict.cdI)) {
$ocms.postXT({
url: $ocms.url('req/sdel'), data: {
id: invid
}
});
}
$inv.rReload();
}
});
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
/* Zwischenspeichern and preview now run against the backend-authoritative session
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
unchanged and is invoked from the preview modal's confirm handler. */
$inv.ssave = () => { $inv.d.save(); };
$inv.sprev = (change) => { $inv.d.preview(); };
$inv.rReload = () => {
try {
let s = $('#listframe ul.rql:first').data();
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
+44
View File
@@ -542,6 +542,50 @@ table.if th.keep, table.if td.keep {
animation: fis_spin 0.8s linear infinite;
}
/* Backend-authoritative draft editing (ADR 0006): server-side plausibility findings
rendered above the editor, and the change-history dialog table. */
.edit_frm .invoice_layout .dvalidation {
margin: 0 0 1rem 0;
}
.edit_frm .invoice_layout .dvalidation.hidden {
display: none;
}
.edit_frm .invoice_layout .dvalidation .dvmsg {
padding: 0.4rem 0.7rem;
margin: 0.25rem 0;
border-radius: 0.2rem;
font-size: 0.9rem;
border-left: 4px solid #999;
background: #f5f5f5;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.error {
border-left-color: #c0392b;
background: #fdecea;
color: #922;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.warning {
border-left-color: #e0a800;
background: #fff8e1;
color: #7a5c00;
}
.edit_frm .invoice_layout .dvalidation .dvmsg.info {
border-left-color: #3498db;
background: #eaf4fb;
color: #1c5a82;
}
.dhist .invtbl {
width: 100%;
}
.dhist td, .dhist th {
padding: 0.3rem 0.6rem;
text-align: left;
vertical-align: top;
}
.dhist tbody tr:nth-child(even) {
background: #fafafa;
}
.modal-body .lstfrm {
display: block;
position: relative;
+216 -77
View File
@@ -627,11 +627,202 @@ $inv.eM = (r, re, opt) => {
if ((opt || '').split(',').includes('setm') === true) {
m.push({ lbl: $ict.setm, fnc: $inv.ssetmode });
}
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() });
}
if (booln(r, false) === true) {
m.push({ lbl: $ict.rel, fnc: $inv.rReload });
}
return $('#topbar').ocmsmenu(m); /* empty Array => empty menu */
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer +
validation from the authoritative server state. It is invoice-only: reminders
never obtain a token (invSumUpdate, the seed trigger, is only bound for invoices),
so they keep the legacy stateless flow untouched. */
$inv.d = {
tbl: () => $('div.invoice_layout table.invi'),
layout: () => $('div.invoice_layout'),
token: function () { return $inv.d.tbl().data('dtoken') || ''; },
/* Hash each assembled block so only genuinely-changed blocks are sent as deltas. */
hashes: function () {
let bai = $inv.d.tbl().data('bai') || [], h = {};
$.each(bai, (i, b) => { h[(b.Id || '').toString()] = JSON.stringify(b); });
return h;
},
/* Seed the authoritative server session from the assembled editor payload. Called
once from invSumUpdate on open; thereafter the backend is the source of truth. */
seed: function (payload) {
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
$inv.d.tbl().data('dtoken', r.token).data('dver', r.version).data('dhashes', $inv.d.hashes());
$fis.draft.bind(r.token, {
onReady: () => $inv.d.refresh(),
onExpiring: (s) => $inv.d.warnExpiry(s),
onClosed: (reason) => $inv.d.closed(reason)
});
$inv.d.refresh();
}, error: () => { l.rC('freeze'); }, complete: () => { $inv.d.tbl().removeData('dseeding'); }
});
},
/* Re-fetch the authoritative state and render the totals footer + validation from it. */
refresh: function (cb) {
let t = $inv.d.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('inv/dstate'), data: { token: t }, success: (state) => {
$inv.d.applyState(state); if (typeof cb === 'function') { cb(state); }
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } },
complete: () => { $inv.d.layout().rC('freeze'); }
});
},
applyState: function (state) {
let tbl = $inv.d.tbl(); if (tbl.length < 1) { return; }
tbl.data('dver', state.version).data('serverSums', state.sums);
$inv.d.footer(tbl, state.sums || {}, state.admin || {});
$inv.d.validation(state.validation || []);
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
sync: function (delta) {
let t = $inv.d.token(); if (t === '') { return; }
$inv.d.layout().aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
success: () => { $inv.d.refresh(); },
error: (xhr) => { $inv.d.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.d.closed('expired'); } }
});
},
/* Diff the freshly-rebuilt blocks against the last-synced state and send only the
changed/removed blocks as granular block.replace / block.remove deltas. */
syncChanged: function (tbl) {
if (($inv.d.token()) === '') { return; }
let bai = tbl.data('bai') || [], prev = tbl.data('dhashes') || {}, next = {}, changed = [], removed = [];
$.each(bai, (i, b) => { let id = (b.Id || '').toString(), h = JSON.stringify(b); next[id] = h; if (prev[id] !== h) { changed.push(b); } });
$.each(prev, (id) => { if (next[id] === undefined) { removed.push(id); } });
tbl.data('dhashes', next);
changed.forEach((b) => $inv.d.sync({ Target: 'block.replace', Ref: (b.Id || '').toString(), Value: b }));
removed.forEach((id) => $inv.d.sync({ Target: 'block.remove', Ref: id }));
},
/* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) {
if ($inv.d.token() === '') { return; }
let map = { invoicetitle: 'title', invoiceaddress: 'address', invoiceemail: 'email', loc: 'provisionlocation', provisionlocation: 'provisionlocation', provisionperiod: 'provisionperiod' };
let target = map[nme]; if (!target) { return; }
$inv.d.sync({ Target: target, Value: val });
},
/* Render the totals footer from the server sums (port of invSumUpdate's footer half). */
footer: function (tbl, sums, admin) {
let ft = tbl.children('tfoot').empty(); tbl.nextAll('.fnote').remove();
let p13b = bool(admin.p13b, false);
let rwcy = (lbl, val, cls) => $$.tdc('currency', $$.tr(ft, { class: cls || 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 4 }).text(lbl)]), fnum(val, $rct.cst));
let fn = (t) => $$.dc('fnote').insertAfter(tbl).rwText(t);
rwcy('Netto', sums.total_net || 0);
if (p13b === false) { $.each(sums.vat || {}, (rate, amt) => rwcy($rct.vat + ' ' + rate + '%', amt, 'tvat')); }
rwcy('Summe', sums.total_gross || 0);
let itype = (admin.type || '');
if (itype === 'i') { fn($rct.note2); fn($rct.note4); }
else if (itype === 'c') { fn($rct.note2); }
else {
fn(string($rct.note3, [fnum(((sums.service_net || 0) + (sums.service_vat || 0)) * (admin.tax_servicerefund || 0), $rct.cst)])).aC('ntax');
fn($rct.note2);
fn(string($rct.note1, [fnum((sums.service_net || 0) + (sums.service_vat || 0), $rct.cst), fnum(sums.service_net || 0, $rct.cst), fnum(sums.service_vat || 0, $rct.cst)]));
}
if (p13b === true) { fn($rct.note13b); }
},
validation: function (msgs) {
let frm = $('div.invoice_layout'); if (frm.length < 1) { return; }
let box = frm.children('.dvalidation');
if (box.length < 1) { box = $$.dc('dvalidation'); frm.prepend(box); }
box.empty().tC('hidden', (msgs || []).length < 1);
$.each(msgs || [], (i, m) => $$.dc('dvmsg', box).aC(m.severity).text(m.message));
},
/* PDF preview straight from the cache; confirm = flush + finalise, cancel = discard. */
preview: function () {
let t = $inv.d.token(); if (t === '') { return; }
let l = $inv.d.layout();
let email = ($inv.d.tbl().data('new') || {}).invoiceemail || '';
if ($fis.ValidateEmail(email) === false) { if (bool(confirm($ict.ivE + $ict.ivEc), false) === false) { return; } }
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dpreview'), data: { token: t }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), total = response.total;
if (total > 10) { $$.dc('note warn', c).text($ict.tpe); }
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
for (let ic = (response.img || []).length + 1; ic <= total; ic++) { $$.dc('pdfp ph', c).append($$.dc('note', $ict.pna)); }
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI,
confirm: function (e) {
let ct = $(this); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: sv.invid }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) { window.open($ocms.url('req/idoc') + '?id=' + sv.invid, '_blank'); }
$inv.d.close();
$ocms.init('req'); $inv.rReload();
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
});
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.d.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($ict.eis); }
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
save: function () {
let t = $inv.d.token(); if (t === '') { return; }
let l = $inv.d.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('inv/dsave'), data: { token: t }, success: (r) => { $inv.d.tbl().data('invid', r.invid); },
error: () => { alert($ict.eis); }, complete: () => { l.rC('freeze'); }
});
},
history: function () {
let t = $inv.d.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('inv/dhistory'), data: { token: t }, success: (r) => {
let c = $$.dc('dhist');
if ((r.history || []).length < 1) { $$.dc('note', c).text('Noch keine Änderungen erfasst.'); }
else {
let ts = $$.tblset({ class: 'invtbl fullwidth' }, c);
$$.tr(ts.hd).append([$$.th().text('Zeit'), $$.th().text('Feld'), $$.th().text('Alt'), $$.th().text('Neu')]);
$.each(r.history, (i, h) => $$.tr(ts.bdy).append([$$.tdc('keep', fdt(h.timestamp)), $$.td().text(h.target), $$.td().text(h.oldValue), $$.td().text(h.newValue)]));
}
$ocms.dlg(c, { width: 800, form: false });
}
});
},
discard: function () {
let invid = $inv.d.tbl().data('invid') || '';
if (invid === '') { alert('Es wurde noch kein Zwischenstand gespeichert, der wiederhergestellt werden könnte.'); return; }
if (confirm('Alle Änderungen verwerfen und den zuletzt gespeicherten Stand neu laden?') === false) { return; }
$inv.d.close();
$inv.cntInv({ id: invid });
},
warnExpiry: function (secondsLeft) {
let mins = Math.max(1, Math.round((secondsLeft || 0) / 60));
$fis.notifications.push({ severity: 'info', title: 'Entwurf läuft ab', message: 'Der Rechnungsentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
},
closed: function (reason) {
let t = $inv.d.token();
$inv.d.tbl().removeData('dtoken');
if (t !== '') { $fis.draft.release(t); }
$fis.frm_edit().remove(); $fis.lf(true);
$fis.notifications.push({ severity: 'error', title: 'Entwurf geschlossen', message: reason === 'expired' ? 'Der Rechnungsentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Rechnungsentwurf wurde geschlossen.' });
try { $inv.rReload(); } catch (e) { }
},
close: function () {
let t = $inv.d.token();
if (t !== '') { $ocms.postXT({ url: $ocms.url('inv/dclose'), data: { token: t } }); $fis.draft.release(t); }
$inv.d.tbl().removeData('dtoken');
}
};
$inv.cInv2 = function (data) {
let fr = $$.dc('rfrm').ldng(1);
let o = $ocms.dlg(fr, { width: 1000 });
@@ -1027,6 +1218,8 @@ $inv.eHtml = function (ev) {
if (typeof change === 'function') {
change(response.txt);
}
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
},
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
}
@@ -1241,6 +1434,13 @@ $inv.invSumUpdate = function () {
}
tbl.data('sms', sms);
tbl.data('bai', ba);
/* Backend-authoritative seeding (ADR 0006): on the first calculation of an invoice
draft (invSumUpdate is only bound for invoices, never reminders), hand the assembled
model to the server session and switch to server-driven totals from then on. */
if ((tbl.data('dtoken') || '') === '' && bool(tbl.data('dseeding'), false) === false && ((tbl.data('admin') || {}).type != null)) {
tbl.data('dseeding', true);
$inv.d.seed($.extend($inv.invcPayload(tbl.data()), { invid: tbl.data('invid') || '' }));
}
};
$inv.worknotes = function (rx) {
let wn = '';
@@ -1328,7 +1528,13 @@ $inv.rendersrq = function () {
$$.tdc('currency isumval', istr);
}
};
$inv.t_fds_inv = () => { $('div.invoice_layout table.invi').trigger('fds.inv'); };
$inv.t_fds_inv = () => {
let tbl = $('div.invoice_layout table.invi');
tbl.trigger('fds.inv');
/* After any local item mutation, push the changed block(s) to the authoritative
server session as granular deltas (invoice drafts only — reminders have no token). */
if ((tbl.data('dtoken') || '') !== '') { $inv.d.syncChanged(tbl); }
};
$inv.sedit = () => {
$inv.sprev(true);
};
@@ -1383,6 +1589,7 @@ $inv.sp13b = () => {
} else {
}
tbl.trigger('fds.inv');
$inv.d.sync({ Target: 'p13b', Value: d.admin.p13b });
};
/* Maps an item row's data to the backend item contract consumed by InvoiceSetPricing
/ FuchsPdf.ApplyInvoice: { id, type, title (plain), desc (html), qty, price_net,
@@ -1437,6 +1644,7 @@ $inv.setSetmode = (mode) => {
let opts = (d.inv.InvoiceOptions || '').split(',').filter(x => x !== '' && x.indexOf('setmode:') !== 0);
if (mode && mode !== 'setprice') { opts.push('setmode:' + mode); }
d.inv.InvoiceOptions = opts.join(',');
$inv.d.sync({ Target: 'setmode', Value: mode });
};
$inv.sctp = () => {
let flds = $invcol.ctp;
@@ -1453,6 +1661,7 @@ $inv.sctp = () => {
cvo.contactEmail = response.email;
d.new.CustomValues = JSON.stringify(cvo); //Assign it to new Values so that this is submitted also
l.find('.ctpfrm').text(ne(response.name, response.email));
$inv.d.sync({ Target: 'contact', Value: { name: response.name, email: response.email } });
}, typedvalues: true
});
@@ -1477,82 +1686,12 @@ $inv.invcPayload = function (d) {
adm.customerid = (adm.customerid != null ? adm.customerid : adm.CustomerId);
return { admin: adm, req: d.bai, sms: d.sms, new: nw };
};
$inv.ssave = () => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
$inv.t_fds_inv();
l.aC('freeze');
$ocms.postXT({
url: $ocms.url('req/save'), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid || '' }, success: (response) => {
$inv.cntInv({ id: response.id });
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
$inv.sprev = (change) => {
var l = $('div.invoice_layout'), d = l.find('table.invi').data();
change = bool(change, false);
$inv.t_fds_inv();
l.aC('freeze');
//console.debug({ admin: d.admin, req: d.bai, sms: d.sms, new: d.new });
if ($fis.ValidateEmail(d.new.invoiceemail || '') === false) {
if (bool(confirm($ict.ivE + $ict.ivEc),false) === false) {
l.rC('freeze');
return;
}
}
$ocms.postXT({
url: $ocms.url('req/' + (change === true ?'sedit':'sprep')), data: { invc: JSON.stringify($inv.invcPayload(d)), id: d.invid ||'' }, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id, invtp = response.total;
if (invtp > 10) {
$$.dc('note warn', c).text($ict.tpe);
}
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
});
for (let ic = (response.img || []).length + 1; ic <= invtp; ic++) {
$$.dc('pdfp ph', c).append($$.dc('note', $ict.pna));
}
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $rct.crI, confirm: function (e) {
let ct = $(this);
l.aC('freeze'); /* spinner while the invoice is finalized/emailed on the backend */
$ocms.postXT({
url: $ocms.url('req/sconf'), data: { id: invid }, success: (cresp) => {
ct.trigger('modal_close');
if (cresp.hasFile === true) {
window.open($ocms.url('req/idoc') + '?id=' + invid, '_blank'); /* open pdf in new tab, only if a file was actually created */
}
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}, complete: () => {
l.rC('freeze');
}
});
}, cancel: function (e) {
let ct = $(this);
if (confirm($ict.cdI)) {
$ocms.postXT({
url: $ocms.url('req/sdel'), data: {
id: invid
}
});
}
$inv.rReload();
}
});
}, error: () => {
alert($ict.eis);
}, complete: () => {
l.rC('freeze');
}
});
};
/* Zwischenspeichern and preview now run against the backend-authoritative session
(ADR 0006): no full-invoice re-upload — the cached draft is flushed / rendered by
token. See $inv.d.save / $inv.d.preview. The finalise + email path (req/sconf) is
unchanged and is invoked from the preview modal's confirm handler. */
$inv.ssave = () => { $inv.d.save(); };
$inv.sprev = (change) => { $inv.d.preview(); };
$inv.rReload = () => {
try {
let s = $('#listframe ul.rql:first').data();
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