Files
Fuchs_Intranet/Fuchs/js/intranet/fis_main.js
T
StefanandClaude Sonnet 5 ecc74cf3c6
Playwright Tests / test (push) Canceled after 0s
Add user-defined invoice set structure dialog (ADR 0016)
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>
2026-09-10 08:55:05 +02:00

352 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
$ocms.init = function (ev) {
var mdl = typeof ev === 'string' ? ev : ((ev.data || {}).fn || '');
if (mdl === '') {
return;
} else if (mdl === 'home') {
$cfr(); $lfr();
$('#topbar').ocmsmenu([], true);
$('#activemodule').text($t.ov);
$fis.ov();
} else {
$cfr(); $lfr();
$('#topbar').ocmsmenu([]);
$ocms.postXT({
url: $ocms.url(mdl + '/auth'), success: function (auth) {
if (typeof $ocms[mdl] === 'undefined') {
$ocms[mdl] = {};
}
$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('.') + av, css: ['/web/fis', mdl, 'css'].join('.') + av, condition: typeof $ocms[mdl].init2 !== 'function'
}, function () { $ocms[mdl].init2(); });
}
}, error: function () {
$('#contentframe').empty();
}
});
}
};
var $fis = { auth: {} };
$fis.db = function () {
$('#mainmenu_activemodule').text($t.ov);
let cf = $(this).empty(), ovf = $$.d({ id: 'dashboard_frame' }).appendTo(cf);
$ocms.postXT({
url: $ocms.url('wdg/my'), success: function (wx) {
$.each(wx, function (ii, wi) {
var wf = $$.dc('wdg_frame', ovf, { 'data-wdg': wi }).ldng(1);
$ocms.wdg.call(wf, { wdg: wi });
});
}, loading: ovf
});
};
$fis.ValidateEmail = function (mail) {
if (/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/.test(mail)) {
return true;
} else {
return false;
}
};
$fis.cf = (reset) => {
let cf = $('#contentframe'); if (bool(reset, false) === true) { cf.empty().rC('hd'); } return cf;
}
$fis.lf = (reset) => {
let lf = $('#listframe'); if (bool(reset, false) === true) { lf.empty().aC('hd').rC('fix'); } return lf;
};
$fis.frm_edit = function (resethd) {
let cf = $fis.cf(false), cf2 = cf.children('.cfrm'), edf = cf.children('.edit_frm');
if (cf2.length < 1) {
cf2 = $$.dc('cfrm hd').prependTo(cf);
} else if (bool(resethd, false) === true) {
cf2.empty();
}
if (edf.length < 1) {
edf = $$.dc('edit_frm').insertAfter(cf2); //must be before list
}
return edf.empty();
};
$fis.frm_list = function (resethd, remedit) {
let cf = $fis.cf(false), cf2 = cf.children('.cfrm'), lf = cf.children('.list_frm');
if (cf2.length < 1) {
cf2 = $$.dc('cfrm hd').prependTo(cf);
} else if (bool(resethd, false) === true) {
cf2.empty();
}
if (bool(remedit, false) === true) {
cf.children('.edit_frm').remove();
}
if (lf.length < 1) {
lf = $$.dc('list_frm').appendTo(cf); //must be after list
}
return lf.empty();
};
$fis.lfm = () => {
let lf = $fis.lf(false) , h = lf.children('.lfrm');
if (h.length < 1) {
h = $$.dc('lfrm').prependTo(lf);
}
return h;
};
$fis.getAuth = (module, force) => {
return new Promise((resolve, reject) => {
if ($fis.auth[module] && bool(force, false) === false) {
resolve($fis.auth[module] || -1);
} else {
$ocms.postXT({
url: $ocms.url('auth'), data: { module: module }, success: (response) => {
$fis.auth[module] = response.auth || -1;
resolve($fis.auth[module] || -1);
}, error: () => {
reject();
}
});
}
});
};
$fis.prepAuth = (modules) => {
return new Promise((resolve, reject) => {
$ocms.postXT({
url: $ocms.url('auth'), data: { module: modules, array: 1 }, success: (response) => {
$.extend($fis.auth, response || {});
}, complete: () => {
resolve();
}
});
});
};
$fis.isAuth = (module, min) => {
return ($fis.auth[module] || -1) >= (min || 1);
};
$fis.resetPass = function (id, fds) {
if (confirm($t.smsc)) {
$ocms.postXT({ url: $ocms.url('account/sms'), data: { fn: 'pwc'} });
$ocms.dlgform($fd.rsp.clone(), {
title: $t.rsp || '',
submit: function (e) {
var c = $(this).ldng(1);
var qs = $.extend({ loginaccount: $ocms.auth.account || '' }, c.serializeObject(true, { typedvalues: true }));
if ((qs.npw || '') !== (qs.npwc || '')) {
c.find('input[name="npw"]:first')[0].setCustomValidity($t.pnm);
} else {
$ocms.postXT({
url: $ocms.url('account/changepassword'), data: qs, success: function (response) {
alert($t.cps);
c.trigger('modal_close');
}, error: function (x) {
alert($t.rspf[x.getResponseHeader('x-ocms-std')]);
}, complete: function () {
c.ldng(0);
}, timeout: 60000
});
}
}
});
}
};
$fis.wdg = function (options) {
let wf = $(this).empty();
$ocms.postXT({
url: $ocms.url('wdg/one'), data: { short_name: options.wdg }, timeout: 90000, success: function (response, textStatus, jqXHR) {
let wi = options.wdg, wx = response[wi];
if (!wx) { wf.ldng(0); return; }
let dbl = $.inArrayRegEx('dblwidth', wx.rendering_options) > -1, tiny = $.inArrayRegEx('tiny', wx.rendering_options) > -1;
wf.toggleClass('dbl', dbl && !tiny).toggleClass('tny', tiny);
let whd = $$.dc('wdg_hd', wf, { title: ne(wx.description, $t.wdc) }).toggleClass('dbl', dbl).text(ne(wx.name, options.wdg)).dblclick(function (ev) {
ev.stopPropagation();
$fis.wdg.call(wf, { wdg: wi });
});
let wct = $$.dc('wdg_cnt', wf).toggleClass('dbl', dbl).hide();
let bgcolix = $.inArrayRegEx('bgcolor', wx.rendering_options);
if (bgcolix > -1) { wct.css('backgroundColor', wx.rendering_options[bgcolix].toString().right(':')); }
switch (wx.type) {
case 'table':
var tblset = $$.tblset({}, wct);
var thr = $$.tr().appendTo(tblset.hd);
var $cc = $t.wdg[wi.indexOf('wdg_ev_') >= 0 ? 'wdg_ev_' : wi] || {};
$.each(wx.columns, function (ci, col) {
var cc = (!$cc[col]) ? col : $cc[col].label;
var thc = $$.th().text(cc).appendTo(thr);
});
$.each(wx.data, function (di, dx) {
var tdr = $$.tr().appendTo(tblset.bdy);
$.each(wx.columns, function (ci, col) {
var tdc = $$.td().appendTo(tdr);
if (dx[col] instanceof Date || $ocms.isJSONDateString(dx[col]) === true) {
tdc.text(fdt(dx[col], $t.dateformat));
} else {
tdc.rwText(dx[col]);
}
});
//if (dx.person_guid || false) { tdr.addClass('clickable').dblclick($.proxy($fis.i_vmm, tdr, dx.person_guid[1])); }
});
if ($.inArray('firstrow_bold', wx.rendering_options) > -1) { thr.nextAll('tr:first').css('font-weight', 'bold'); }
break;
case 'ind':
$$.dc('ind', wct).addClass('sts_' + (wx.data.status || '')).append([$$.dc('ind').text(wx.data.value), $$.lbl(wx.data.label)]);
break;
case 'image_url':
wct.css('background', 'url(\'' + wx.url + '\') no-repeat center center transparent');
break;
case 'image_base64':
wct.css('background', 'url(\'data:image/png;base64,' + wx.image + '\') no-repeat center center transparent');
break;
case 'html':
wct.html(wx.html);
if ($.inArray('reload_10min', wx.rendering_options) > -1) {
var ifr = wct.find('iframe');
setTimeout(function () { ifr.attr('src', function (i, val) { return val; }); }, (10 * 60 * 1000));
}
break;
default:
}
if ($.inArray('reload_30min', wx.rendering_options) > -1 && wx.type !== 'html') {
setTimeout(function () { $fis.wdg.call(wf, { wdg: wi }); }, (30 * 60 * 1000));
}
wct.slideDown(150);
}, error: function (jqXHR) {
wf.slideUp(150);
$fis.failure.call(this, jqXHR);
}, complete: function () {
wf.ldng(0);
}
});
};
$fis.ov = function () {
//$('#mainmenu_activemodule').text("");
$fis.lf(true);
let cf = $('#contentframe').empty(), ovf = $$.d({ id: 'dashboard_frame' }).appendTo(cf);
$ocms.postXT({
url: $ocms.url('wdg/my'), success: function (wx) {
$.each(wx, function (ii, wi) {
var wf = $$.dc('wdg_frame', ovf, { 'data-wdg': wi }).ldng(1);
$fis.wdg.call(wf, { wdg: wi });
});
}, loading: ovf
});
};
$fis.notifications = {
connection: null,
init: function () {
if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) {
return;
}
this.ensureFrame();
this.connection = new signalR.HubConnectionBuilder()
.withUrl('/notifications')
.withAutomaticReconnect()
.build();
this.connection.on('notification', (notification) => {
this.push(notification);
});
// withAutomaticReconnect() only retries a connection that was successfully
// established and later dropped — it does not retry a failed initial start()
// (e.g. the server restarting mid-debug). Without this, a single failed start()
// silently blackholes every notification for the rest of the page's lifetime.
this.connection.onclose(() => {
console.warn('Notification connection closed; retrying in 5s.');
this.connection = null;
setTimeout(() => this.init(), 5000);
});
this.start();
},
start: function () {
this.connection.start().catch((err) => {
console.warn('Notification connection failed to start; retrying in 5s.', err);
this.connection = null;
setTimeout(() => this.init(), 5000);
});
},
ensureFrame: function () {
if ($('#notification_frame').length < 1) {
$('<div/>', { id: 'notification_frame' }).appendTo($('footer:first').length ? 'footer:first' : 'body');
}
},
push: function (notification) {
this.ensureFrame();
notification = notification || {};
let item = $('<div/>', { class: 'notification_item' })
.addClass((notification.severity || 'info').toLowerCase())
.append($('<button/>', { type: 'button', class: 'notification_close', text: '×' }))
.append($('<div/>', { class: 'notification_title', text: notification.title || 'Info' }))
.append($('<div/>', { class: 'notification_message', text: notification.message || '' }));
item.find('.notification_close').on('click', function () {
item.remove();
});
$('#notification_frame').prepend(item);
setTimeout(function () {
item.fadeOut(150, function () { item.remove(); });
}, 9000);
}
};
/* Live draft-editing client (ADR 0006/0007). Separate SignalR connection to the
dedicated /draftpreview hub; the server signals the *one* browser editing a draft
(group = session token) to re-fetch (draftReady), warns before idle expiry
(draftExpiring), and tells it to close on eviction (draftClosed). The editor
(fis.inv_shared.js) registers the open draft via $fis.draft.bind(token, {...}). */
$fis.draft = {
connection: null,
active: null, /* { token, onReady(version), onExpiring(secondsLeft), onClosed(reason) } */
init: function () {
if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) {
return;
}
this.connection = new signalR.HubConnectionBuilder()
.withUrl('/draftpreview')
.withAutomaticReconnect()
.build();
this.connection.on('draftReady', (p) => this._dispatch('onReady', p, (p) => p.version));
this.connection.on('draftExpiring', (p) => this._dispatch('onExpiring', p, (p) => p.secondsLeft));
this.connection.on('draftClosed', (p) => this._dispatch('onClosed', p, (p) => p.reason));
/* Re-join the active draft's group after a (re)connect — group membership is
per-connection and is lost when the socket drops. */
this.connection.onreconnected(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } });
this.connection.onclose(() => {
console.warn('Draft connection closed; retrying in 5s.');
this.connection = null;
setTimeout(() => { this.init(); if (this.active) { this.bind(this.active.token, this.active); } }, 5000);
});
this.start();
},
start: function () {
this.connection.start()
.then(() => { if (this.active) { this._invoke('JoinDraft', this.active.token); } })
.catch((err) => {
console.warn('Draft connection failed to start; retrying in 5s.', err);
this.connection = null;
setTimeout(() => this.init(), 5000);
});
},
/* Registers the currently open draft and joins its signal group. handlers:
{ onReady, onExpiring, onClosed }. */
bind: function (token, handlers) {
if (!token) { return; }
this.active = $.extend({ token: token }, handlers || {});
if (this.connection === null) { this.init(); }
this._invoke('JoinDraft', token);
},
/* Unregisters + leaves the group (editor closed). */
release: function (token) {
if (this.active && (!token || this.active.token === token)) { this.active = null; }
this._invoke('LeaveDraft', token);
},
_invoke: function (method, token) {
if (!token || !this.connection || this.connection.state !== 'Connected') { return; }
this.connection.invoke(method, token).catch((err) => console.warn('Draft ' + method + ' failed', err));
},
_dispatch: function (handler, payload, argOf) {
payload = payload || {};
if (!this.active || this.active.token !== payload.token) { return; }
if (typeof this.active[handler] === 'function') { this.active[handler](argOf(payload)); }
}
};