Add row context menus, fix glyphicon copy corruption, combine CC email editing

- Fuchs/gulpfile.js: pass encoding:false to gulp.src() in the copy task —
  vinyl-fs otherwise UTF-8-decodes binary files by default, which was
  silently corrupting the glyphicons webfont (and any other binary asset
  copyconfig.json matches) on every `gulp copy`/`gulp all` run.
- Invoice/reminder editor: replace the row's flat action icons (edit/
  delete/insert/set-price) with a single context button opening a
  dropdown menu, reusing the existing $ocms.menu toolbar mechanism.
- Fix "-1" leaking into the Pos. column for Text/Title rows: $inv.rrw's
  `dta.position || dta.SortOrder` fallback treated the numbering's
  intentional empty string as unset and fell back to SortOrder (-1 for
  such rows); now only a genuinely uncomputed position falls back.
- Zusatzempfänger (CC) is now edited together with the main invoice
  email in one combined dialog ($inv.emailFrm/$inv.eEmail), wired into
  all three entry points (create, continue, storno) instead of a
  separate hover-revealed row with its own edit target.
- Add tooltips to every row/header edit control, including several that
  had none before (header field pencils, the request/section title
  edit button), phrased as object+verb ($rct.frmTT).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 19:38:03 +02:00
co-authored by Claude Sonnet 5
parent 071982caec
commit c182d4fd69
12 changed files with 447 additions and 131 deletions
+99 -23
View File
@@ -31,6 +31,9 @@ let $rct = {
sV: 'USt festlegen',
cD: 'Löschen?',
mR: 'Zeile verschieben',
ctxM: 'Weitere Aktionen',
noteToggle: 'Notiz ein-/ausblenden',
sectionTitleEditTT: 'Auftragsbezeichnung ändern',
svcPart: 'Service-Anteil',
vat: 'Umsatzsteuer',
combP: 'Positionen zusammenfassen',
@@ -62,11 +65,21 @@ let $rct = {
, '-': '-'
}, invHR: ['Pos.', 'Menge', 'Artikelbezeichnung', 'VK', 'Summe']
, frm: {
invoiceaddress: 'Adresse'
invoicetitle: 'Titel'
, invoiceaddress: 'Adresse'
, loc: 'Leistungsort / Lieferadresse'
, invoiceemail: 'Email'
, invoiceemailcc: 'Zusatzempfänger (CC)'
, provisionperiod: 'Leistungsdatum / -zeitraum'
}
/* Tooltip text for each header-field pencil — object+verb phrasing ("<was> <tun>"),
distinct from frm[] above (used for dialog titles / field labels, which stay short nouns). */
, frmTT: {
invoicetitle: 'Rechnungstitel ändern'
, invoiceaddress: 'Rechnungsempfänger-Adresse ändern'
, loc: 'Leistungsort oder Lieferadresse ändern'
, invoiceemail: 'Email-Adresse ändern und zusätzliche Email-Empfänger festlegen'
, provisionperiod: 'Leistungsdatum oder -zeitraum ändern'
}
}, $rcol = {
req: new fields_definition('Auftrag', 'Aufträge', [
@@ -1308,13 +1321,13 @@ $inv.ccInv = function (ev) { //normale rechnung
let admin = response.admin || {}, fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', admin.invoicetitle, 'invoicetitle', 0, null);
fm('adrfrm', admin.invoiceaddress, 'invoiceaddress', 0, null);
fm('locfrm', '', 'loc', 1, { list: deepCopy(response.locations), lbl: 'ref', property: 'address' });
fm('emailfrm', admin.invoiceemail, 'invoiceemail', 0, null);
$inv.emailFrm(frm, rif, admin.invoiceemail, admin.invoiceemailcc);
$$.dc('sndfrm', frm).append($$.dc('content').text(admin.sender));
let admtxt, admlbl;
@@ -1383,13 +1396,13 @@ $inv.ccStInv = function (ev) { // Stornorechnung
let admin = response.admin || {}, fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', admin.invoicetitle, 'invoicetitle', 0, null);
fm('adrfrm', admin.invoiceaddress, 'invoiceaddress', 0, null);
fm('locfrm', admin.provisionlocation, 'loc', 1, { list: deepCopy(response.locations), lbl: 'ref', property: 'address' });
fm('emailfrm', admin.invoiceemail, 'invoiceemail', 0, null);
$inv.emailFrm(frm, rif, admin.invoiceemail, admin.invoiceemailcc);
$$.dc('sndfrm', frm).append($$.dc('content').text(admin.sender));
let admtxt, admlbl;
@@ -1459,14 +1472,13 @@ $inv.cntInv = function (data) { //invoice continuation
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', inv.InvoiceTitle, 'invoicetitle', 0, null);
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);
$inv.emailFrm(frm, rif, inv.SendToEmail, inv.SendToEmailCc);
$$.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);
@@ -1522,14 +1534,13 @@ $inv.cSt = function (data) {
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', inv.InvoiceTitle, 'invoicetitle', 0, null);
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);
$inv.emailFrm(frm, rif, inv.SendToEmail, inv.SendToEmailCc);
$$.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);
@@ -1657,6 +1668,45 @@ $inv.eProvisionPeriod = function (ev) {
});
};
/* Main recipient + CC ("Zusatzempfänger") edited together in one dialog — there is no separate
pencil on the CC row, only on the email row (see $inv.emailFrm), which passes both content
spans through ev.data.t / ev.data.ccT. */
$inv.eEmail = function (ev) {
let flds = [
{ name: 'email', label: $rct.frm.invoiceemail, type: 'text', value: (ev.data.t || $$.s()).text() },
{ name: 'emailcc', label: $rct.frm.invoiceemailcc, type: 'text', value: (ev.data.ccT || $$.s()).text() }
];
$ocms.dlgform(flds, {
title: $rct.frm.invoiceemail,
success: function (res) {
let email = ('' + (res.email || '')).trim(), cc = ('' + (res.emailcc || '')).trim();
if (ev.data && ev.data.t) { ev.data.t.text(email); }
if (ev.data && ev.data.ccT) { ev.data.ccT.text(cc); }
if (typeof (ev.data || {}).change === 'function') { ev.data.change(email); }
if (typeof (ev.data || {}).changeCc === 'function') { ev.data.changeCc(cc); }
/* backend-authoritative: mirror both fields to the server session (see $inv.eHtml's
generic path, which this mirrors for a single field). */
$inv.d.syncField('invoiceemail', email);
$inv.rd.syncField('invoiceemail', email);
$inv.d.syncField('invoiceemailcc', cc);
$inv.rd.syncField('invoiceemailcc', cc);
}
});
};
/* Builds the email + CC ("Zusatzempfänger") header rows together: only the email row gets a
pencil, opening the combined $inv.eEmail dialog for both fields (see there). */
$inv.emailFrm = function (frm, rif, email, emailcc) {
let ccfr = $$.dc('inpfrm emailccfrm', frm), ccfc = $$.dc('content', ccfr).rwText(emailcc);
let efr = $$.dc('inpfrm emailfrm', frm), efc = $$.dc('content', efr).rwText(email);
$$.dc('axf', efr).append($$.dc('ibtn edit', { title: $rct.frmTT.invoiceemail }).append(gi('pencil')).click({
t: efc, ccT: ccfc, nme: 'invoiceemail',
change: (nv) => { rif.tbl.data('new').invoiceemail = nv; },
changeCc: (nv) => { rif.tbl.data('new').invoiceemailcc = nv; }
}, $inv.eHtml));
rif.tbl.data('new').invoiceemail = email;
rif.tbl.data('new').invoiceemailcc = emailcc;
};
$inv.eHtml = function (ev) {
/* The recipient address and the service date/period are edited through structured dialogs
whenever an invoice draft is active; reminders (no invoice draft token) keep the plain
@@ -1665,6 +1715,9 @@ $inv.eHtml = function (ev) {
if (ev.data.nme === 'invoiceaddress') { return $inv.eAddress.call(this, ev); }
if (ev.data.nme === 'provisionperiod') { return $inv.eProvisionPeriod.call(this, ev); }
}
/* The CC/Zusatzempfänger recipient is edited together with the main email, in one dialog —
there is no separate pencil on the CC row (see $inv.emailFrm). */
if (ev.data && !(ev.data instanceof jQuery) && ev.data.nme === 'invoiceemail' && ev.data.ccT) { return $inv.eEmail.call(this, ev); }
let t = $(this), frmct = ev.data instanceof jQuery ? ev.data : ev.data.t;
/* Single-line fields must stay plain text — the TinyMCE/html editor wraps the value in <p>
tags, which used to get posted and persisted verbatim (e.g. <p>18.06.2026</p> in the
@@ -1778,15 +1831,29 @@ $inv.eRw = function(row, dta, flds) {
changed block(s) to the backend session (t_fds_inv -> syncChanged). Committing on drop (rather
than on every mid-drag hover-swap) avoids rebuilding the row that is currently being dragged. */
$inv.bdysort = (i, e) => { $(e).Sortable({ dragItem: false, dragHandleClass: 'ico', parentident: 'tr', onend: () => { $inv.t_fds_inv(); } }) }
/* Single "Kontext-Button" per row/field, opening a dropdown of the row's edit actions
(reuses $ocms.menu, the same mechanism that already drives the toolbar menus — see
$inv.eM/$inv.iMn). Calling .ocmsmenu() directly on a <nav> we built ourselves (rather than
letting $ocms.menu create one inside a bar) keeps our own sizing class instead of being
replaced. Every entry carries both its label and a matching `title` tooltip. */
$inv.rowCtxMenu = function (items) {
let nav = $$[0]('nav').aC('rowctx');
nav.ocmsmenu([{ glyph: 'glyphicon-option-vertical', aclass: 'ctxbtn', attr: { title: $rct.ctxM }, itm: items }]);
/* $ocms.menu puts `attr` (incl. our title tooltip) on the <li>, not the <a> a viewer actually
hovers — mirror it onto the <a> too so the native tooltip is guaranteed to fire regardless
of how the browser resolves title on a non-interactive ancestor. */
nav.find('li[title]').each(function () { let li = $(this); li.children('a').attr('title', li.attr('title')); });
return nav;
};
$inv.rrw = function () {
let rw = $(this), dta = rw.data(), co = {}, ph = rw.is('.placeholder'), hn = rw.is('.hidenote');
let oHtml = (e) => $$.d().append(e).html();
let bc = [
$$.dc('ibtn insb', { title: $rct.iRb }).append(gi('indent-left')).click(rw, $inv.inRow)
let mi = [
{ lbl: $rct.iRb, attr: { title: $rct.iRb }, data: rw, fnc: $inv.inRow }
];
if (ph === false) {
bc.unshift($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(rw, $inv.eRow));
bc.push($$.dc('ibtn del', { title: $rct.dR }).append(gi('trash')).click(function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } }));
mi.unshift({ lbl: $rct.cP, attr: { title: $rct.cP }, data: rw, fnc: $inv.eRow });
mi.push({ lbl: $rct.dR, attr: { title: $rct.dR }, fnc: function (e) { if (confirm($rct.cD)) { rw.remove(); $inv.t_fds_inv(); } } });
/* "Auf Setpreis umstellen": offered on a Set header item that does not yet carry a
price. fds__prepInvoice's [SetItmId] now anchors on the still-unconverted (price 0)
header, so the header row's own SetItmId self-references its own id rather than being
@@ -1795,10 +1862,10 @@ $inv.rrw = function () {
Irreversible: the actual sum/clear mutation happens server-side (backend-authoritative,
see InvoiceDraftEditService.ApplyItemSetPrice) via the "item.setprice" patch target. */
if ((dta.Type || '').toLowerCase() === 'set' && (dta.net_val || 0) === 0) {
bc.push($$.dc('ibtn tosetp', { title: $rct.toSetP }).append(gi('scale')).click(rw, $inv.toSetPrice));
mi.push({ lbl: $rct.toSetP, attr: { title: $rct.toSetP }, data: rw, fnc: $inv.toSetPrice });
}
}
let axf = $$.dc('axf').append(bc);
let axf = $$.dc('axf').append($inv.rowCtxMenu(mi));
if (ph === true) {
co = { id: '', typ: 'placeholder' };
} else if (rw.is('.itm.osum') === true) {
@@ -1807,7 +1874,12 @@ $inv.rrw = function () {
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 || '');
/* dta.position is '' (not unset) for a Text/Title row once invSumUpdate has numbered the
invoice (ADR 0017) — an intentional "no number", not a missing value, so it must NOT
fall through to SortOrder (which is -1 for such rows and would leak "-1" into Pos.).
Only an as-yet-uncomputed row (freshly inserted, before the next invSumUpdate pass)
has position === undefined and should still fall back to SortOrder as a placeholder. */
co.p = dta.position != null ? dta.position : (dta.SortOrder || '');
if (co.id === '') {
co.t = '';
} else if (['Text', 'Title'].includes(co.typ) && (dta.net_val || 0) === 0) {
@@ -1821,7 +1893,7 @@ $inv.rrw = function () {
}
}
if ((dta.Note || '') !== '') {
$$.dc('ibtn add', axf).append(gi('object-align-left')).click(function (e) {
$$.dc('ibtn add', axf, { title: $rct.noteToggle }).append(gi('object-align-left')).click(function (e) {
$inv.rrw.call(rw.tC('hidenote'));
});
}
@@ -1966,7 +2038,7 @@ $inv.rendersrq = function () {
$$.dc('ibtn insb', aux, { title: $rct.iRb }).append(gi('indent-left')).click(rtr, $inv.inRow);
let stxt = $$.sc('text', rx.text);
let nme = $$.td(rtr, { colspan: onesum ? 4 : 5 }).append(stxt), oidta, numkeys = ['net_val', 'vat_val', 'svcnet_val', 'svcvat_val', 'net'];
$$.dc('ibtn edit', aux).data('dialog', $rcol.req.lbl().Name).append(gi('pencil')).click({ t: stxt, change: (nv) => { rx.text = nv; $inv.t_fds_inv(); } }, $inv.eHtml)
$$.dc('ibtn edit', aux, { title: $rct.sectionTitleEditTT }).data('dialog', $rcol.req.lbl().Name).append(gi('pencil')).click({ t: stxt, change: (nv) => { rx.text = nv; $inv.t_fds_inv(); } }, $inv.eHtml)
if (onesum) {
$$.tdc('currency isumval', rtr);
oidta = { Id: rx.Id.toString() + '_osum', net_val: 0, vat_val: 0, svcnet_val: 0, svcvat_val: 0, net: 0 };
@@ -2343,7 +2415,8 @@ $inv.ccRem = function (id, InvoiceId) { //reminder creation
$inv.rRemRw = function (dta) {
let tr = $(this), rem = dta.rm || {};
tr.empty().data({ invoiceid: rem.invoiceid, invoicedate: rem.invoicedate, amount: rem.amount, amount_payed: rem.amount_payed });
let axf = $$.dc('axf').append($$.dc('ibtn edit', { title: $rct.cP }).append(gi('pencil')).click(tr, $inv.eRowR));
let mi = [{ lbl: $rct.cP, attr: { title: $rct.cP }, data: tr, fnc: $inv.eRowR }];
let axf = $$.dc('axf').append($inv.rowCtxMenu(mi));
tr.append([$$.tdc('aux').append(axf), $$.tdc('keep', rem.invoiceid), $$.tdc('keep', fdt(rem.invoicedate, 'dd.MM.yy')), $$.tdc('currency', fnum(rem.amount, $rct.cst)), $$.tdc('currency', fnum(rem.amount_payed, $rct.cst)), $$.tdc('currency', fnum(rem.amount - rem.amount_payed, $rct.cst))]);
};
$inv.eRowR = function (ev) {
@@ -2384,9 +2457,12 @@ $inv.ccRem_s2 = function (id, sets) { //reminder creation
$inv.rRemRw.call($$.tr(rif.bdy), rif.tbl.data());
rif.ft.appendTo(rif.tbl);
/* Reminders have no CC/Zusatzempfänger field (see $inv.emailFrm, invoice-only), so the
email pencil here keeps a plain tooltip instead of $rct.frmTT.invoiceemail. */
let remTT = { invoiceemail: 'Email-Adresse ändern', subject: 'Betreff ändern' };
let fm = (cls, txt, nme, hd, dta) => {
let fr = $$.dc('inpfrm', frm).aC(cls).append(typeof hd === 'string' ? $$.dc('ahd', hd) : (hd > 0 ? $$.dc('ahd', $rct.frm[nme]) : null)), fc = $$.dc('content', fr).rwText(txt);
$$.dc('axf', fr).append($$.dc('ibtn edit').data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
$$.dc('axf', fr).append($$.dc('ibtn edit', { title: remTT[nme] || $rct.frmTT[nme] || $rct.frm[nme] }).data('dialog', $rct.frm[nme]).append(gi('pencil')).click($.extend({ t: fc, nme: nme, change: (nv) => { rif.tbl.data('new')[nme] = nv; } }, dta), $inv.eHtml));
rif.tbl.data('new')[nme] = txt;
};
fm('tfrm', rem.subject, 'subject', 0, null);