Changed Reminder Systematics analogue to invoices

This commit is contained in:
Stefan
2026-07-10 22:50:26 +02:00
parent 83d1c28b29
commit 5c0fdc6c1d
24 changed files with 1810 additions and 145 deletions
+175 -46
View File
@@ -313,6 +313,161 @@ $inv.d = {
$inv.d.tbl().removeData('dtoken');
}
};
/* ── Backend-authoritative reminder draft editing (ADR 0006/0007) ─────────────
The reminder mirror of $inv.d: the server holds the truth for a reminder draft in
an in-memory session; this object seeds it (rem/dopen), sends single edits as deltas
(rem/dpatch), and renders the open-amount footer + validation from the authoritative
server state (rem/dstate). Preview renders straight from the cache (rem/dpreview);
confirm flushes (rem/dsave) then finalises + emails (rem/conf). It coexists with $inv.d
on the same DOM: each keys off its own token (rdtoken vs dtoken), so the shared inline
editor safely no-ops for the mode that is not active. */
$inv.rd = {
tbl: () => $('div.invoice_layout table.invi'),
layout: () => $('div.invoice_layout'),
token: function () { return $inv.rd.tbl().data('rdtoken') || ''; },
/* Seed the authoritative server session from the assembled reminder editor payload. */
seed: function (payload) {
let l = $inv.rd.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dopen'), data: { payload: JSON.stringify(payload) }, success: (r) => {
$inv.rd.tbl().data('rdtoken', r.token).data('rdver', r.version);
$fis.draft.bind(r.token, {
onReady: () => $inv.rd.refresh(),
onExpiring: (s) => $inv.rd.warnExpiry(s),
onClosed: (reason) => $inv.rd.closed(reason)
});
$inv.rd.refresh();
}, error: () => { l.rC('freeze'); }
});
},
/* Re-fetch the authoritative state and render the open-amount footer + validation from it. */
refresh: function (cb) {
let t = $inv.rd.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('rem/dstate'), data: { token: t }, success: (state) => {
$inv.rd.applyState(state); if (typeof cb === 'function') { cb(state); }
}, error: (xhr) => { if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } },
complete: () => { $inv.rd.layout().rC('freeze'); }
});
},
applyState: function (state) {
let tbl = $inv.rd.tbl(); if (tbl.length < 1) { return; }
tbl.data('rdver', state.version).data('serverSums', state.sums).data('remid', state.remid || '');
$inv.rd.footer(tbl, state.sums || {});
$inv.rd.validation(state.validation || []);
},
/* Send one change to the server; the draftReady signal and this success both refresh. */
sync: function (delta) {
let t = $inv.rd.token(); if (t === '') { return; }
$inv.rd.layout().aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dpatch'), data: { token: t, delta: JSON.stringify(delta) },
success: () => { $inv.rd.refresh(); },
error: (xhr) => { $inv.rd.layout().rC('freeze'); if (xhr && xhr.status === 410) { $inv.rd.closed('expired'); } }
});
},
/* Map an inline recipient field to its delta target and send it. */
syncField: function (nme, val) {
if ($inv.rd.token() === '') { return; }
let map = { subject: 'subject', invoiceaddress: 'address', invoiceemail: 'email', text: 'text' };
let target = map[nme]; if (!target) { return; }
$inv.rd.sync({ Target: target, Value: val });
},
/* Amount / amount-paid come from the item-row dialog; send both as their own deltas. */
syncAmount: function (amount, amount_payed) {
if ($inv.rd.token() === '') { return; }
$inv.rd.sync({ Target: 'amount', Value: (amount != null ? amount : 0).toString() });
$inv.rd.sync({ Target: 'amount_payed', Value: (amount_payed != null ? amount_payed : 0).toString() });
},
/* Render the open-amount footer from the server sums. */
footer: function (tbl, sums) {
let ft = tbl.children('tfoot').empty();
let tr = $$.tr(ft, { class: 'tsum' }).append([$$.tdc('aux'), $$.td({ colspan: 3 }).text('Offener Betrag')]);
$$.tdc('currency', tr, fnum(sums.amount_open || 0, $rct.cst));
},
validation: function (msgs) {
let frm = $inv.rd.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 + email, cancel = discard. */
preview: function () {
let t = $inv.rd.token(); if (t === '') { return; }
let l = $inv.rd.layout();
let email = (($inv.rd.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('rem/dpreview'), data: { token: t }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88);
$.each(response.img || [], (ii, img) => { $$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px')); });
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd,
confirm: function (e) {
let ct = $(this); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dsave'), data: { token: t }, success: (sv) => {
$ocms.postXT({
url: $ocms.url('rem/conf'), data: { id: sv.remid }, success: () => {
ct.trigger('modal_close');
window.open($ocms.url('rem/idoc') + '?id=' + sv.remid, '_blank');
$inv.rd.close();
$ocms.init('req'); $inv.rReload();
}, error: () => { alert($t.f1); ct.trigger('modal_close'); }, complete: () => { l.rC('freeze'); }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
});
},
cancel: function (e) { if (confirm($ict.cdI)) { $inv.rd.close(); $inv.rReload(); } }
});
}, error: () => { l.rC('freeze'); alert($t.f1); }
});
},
/* Zwischenspeichern: flush the cache to the DB (no re-upload); stay in the editor. */
save: function () {
let t = $inv.rd.token(); if (t === '') { return; }
let l = $inv.rd.layout(); l.aC('freeze');
$ocms.postXT({
url: $ocms.url('rem/dsave'), data: { token: t }, success: (r) => { $inv.rd.tbl().data('remid', r.remid); },
error: () => { alert($t.f1); }, complete: () => { l.rC('freeze'); }
});
},
history: function () {
let t = $inv.rd.token(); if (t === '') { return; }
$ocms.postXT({
url: $ocms.url('rem/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 });
}
});
},
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 Mahnentwurf läuft in etwa ' + mins + ' Minute(n) ab. Bitte zwischenspeichern, sonst gehen die Änderungen verloren.' });
},
closed: function (reason) {
let t = $inv.rd.token();
$inv.rd.tbl().removeData('rdtoken');
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 Mahnentwurf ist wegen Inaktivität abgelaufen. Nicht gespeicherte Änderungen sind verloren.' : 'Der Mahnentwurf wurde geschlossen.' });
try { $inv.rReload(); } catch (e) { }
},
close: function () {
let t = $inv.rd.token();
if (t !== '') { $ocms.postXT({ url: $ocms.url('rem/dclose'), data: { token: t } }); $fis.draft.release(t); }
$inv.rd.tbl().removeData('rdtoken');
}
};
$inv.cInv2 = function (data) {
let fr = $$.dc('rfrm').ldng(1);
let o = $ocms.dlg(fr, { width: 1000 });
@@ -711,8 +866,11 @@ $inv.eHtml = function (ev) {
if (typeof change === 'function') {
change(response.txt);
}
/* backend-authoritative: mirror the inline recipient-field edit to the server session */
/* backend-authoritative: mirror the inline recipient-field edit to the server session.
Invoice and reminder editors share this DOM; each syncField no-ops unless its own
draft token is present, so only the active mode's session receives the delta. */
$inv.d.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
$inv.rd.syncField(ev.data.nme, isPlainText ? (response.txt || '') : response.txt);
},
tinymce: { valid_elements: 'br', hidemenu: true, hidetoolbar: true }
}
@@ -1367,6 +1525,8 @@ $inv.eRowR = function (ev) {
$.extend(tdta.rm, res);
tbl.data(tdta);
$inv.rRemRw.call(row, tdta);
/* backend-authoritative: mirror the edited amount / amount-paid to the server session */
$inv.rd.syncAmount(tdta.rm.amount, tdta.rm.amount_payed);
}, typedvalues: true
});
};
@@ -1407,57 +1567,26 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
rif.tbl.children('tbody').each($inv.bdysort);
rif.tbl.trigger('fds.inv'); /* trigger calculations */
/* Seed the authoritative server session (ADR 0006). Amounts join the recipient
fields in the 'new' block; the reference invoice data goes into 'rem'. From here
the backend owns the open-amount computation and validation; inline edits and the
item-row dialog post single deltas (see $inv.rd). */
let nw = rif.tbl.data('new');
nw.amount = rem.amount; nw.amount_payed = rem.amount_payed;
$inv.rd.seed({
rem: { invid: rem.invid, type: rem.type, invoiceid: rem.invoiceid, invoicedate: rem.invoicedate },
new: nw
});
}, complete: () => {
//o.c.trigger('modal_close');
}
});
};
$inv.rprev = () => {
var l = $('div.invoice_layout'), tbl = l.find('table.invi'), d = tbl.data();
$.extend(d.new, tbl.find('tbody > tr:first').data());
l.aC('freeze');
//console.debug({ rem: d.rm, 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('rem/prep'), data: { remc: JSON.stringify({ rem: d.rm, new: d.new }), id: d.invid || '' }, success: (response) => {
l.rC('freeze');
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), remid = response.id;
$.each(response.img || [], function (ii, img) {
$$.dc('pdfp', c).append($$.img(img).css('max-height', (vhr - rpx(6)).toString() + 'px'));
});
$ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false, button: $ict.remd, confirm: function (e) {
let ct = $(this);
$ocms.postXT({
url: $ocms.url('rem/conf'), data: { id: remid }, success: () => {
ct.trigger('modal_close');
window.open($ocms.url('rem/idoc') + '?id=' + remid, '_blank'); /* open pdf in new tab */
$ocms.init('req'); /* go back to request list */
$inv.rReload();
}, error: () => {
alert($t.f1);
ct.trigger('modal_close');
}
});
}, cancel: function (e) {
let ct = $(this);
if (confirm($ict.cdI)) {
$ocms.postXT({
url: $ocms.url('rem/del'), data: {
id: remid
}
});
}
$inv.rReload();
}
});
}
});
/* Preview + finalise now run through the backend-authoritative session ($inv.rd):
the PDF renders straight from the server cache (no rem/prep DB write), and confirm
flushes (rem/dsave) then finalises + emails (rem/conf). */
$inv.rd.preview();
};
$inv.sis = (id) => {
if (confirm($ict.sisc)) {