', {
'class': 'col-sm-10 col-sm-offset-2'
}).appendTo(jQuery('
', {
'class': 'form-group'
}).appendTo(body)));
this.wid_text = jQuery('
', {
'type': 'text',
'class': 'form-control input-sm',
'placeholder': Sao.i18n.gettext('Search'),
'name': attributes.name,
}).appendTo(group);
if (!attributes.completion || attributes.completion == '1') {
this.wid_completion = Sao.common.get_completion(
group,
this._update_completion.bind(this),
this._completion_match_selected.bind(this));
this.wid_text.completion = this.wid_completion;
}
this.but_add = jQuery('
', {
'class': 'btn btn-default btn-sm',
'type': 'button',
'aria-label': Sao.i18n.gettext("Add"),
'title': Sao.i18n.gettext("Add"),
}).append(Sao.common.ICONFACTORY.get_icon_img('tryton-add')
).appendTo(jQuery('
', {
'class': 'input-group-btn'
}).appendTo(group));
this.but_add.click(this.add.bind(this));
this._readonly = false;
this._record_id = null;
this._popup = false;
},
_required_el: function() {
return this.wid_text;
},
_invalid_el: function() {
return this.wid_text;
},
add: function() {
var context = this.field.get_context(this.record);
var value = this.wid_text.val();
var domain = this.field.get_domain(this.record);
if (this._popup) {
return;
} else {
this._popup = true;
}
const callback = result => {
if (!jQuery.isEmptyObject(result)) {
var ids = result.map(function(e) {
return e[0];
});
this.add_new_keys(ids);
}
this.wid_text.val('');
this._popup = false;
};
var parser = new Sao.common.DomainParser();
new Sao.Window.Search(this.schema_model,
callback, {
sel_multi: true,
context: context,
domain: domain,
new_: false,
search_filter: parser.quote(value),
title: this.attributes.string
});
},
add_new_keys: function(ids) {
var field = this.field;
field.add_new_keys(ids, this.record)
.then(new_names => {
this.send_modified();
var value = this.field.get_client(this.record);
for (const key of new_names) {
value[key] = null;
}
this.field.set_client(this.record, value);
this._display().then(() => {
this.fields[new_names[0]].input.focus();
});
});
},
remove: function(key, modified=true) {
delete this.fields[key];
this.rows[key].remove();
delete this.rows[key];
if (modified) {
this.send_modified();
this.set_value(this.record, this.field);
}
},
set_value: function() {
this.field.set_client(this.record, this.get_value());
},
get_value: function() {
var value = {};
for (var key in this.fields) {
var widget = this.fields[key];
value[key] = widget.get_value();
}
return value;
},
get modified() {
if (this.record && this.field) {
var value = this.field.get_client(this.record);
for (var key in this.fields) {
var widget = this.fields[key];
if (widget.modified(value)) {
return true;
}
}
}
return false;
},
set_readonly: function(readonly) {
Sao.View.Form.Dict._super.set_readonly.call(this, readonly);
this._set_button_sensitive();
for (var key in this.fields) {
var widget = this.fields[key];
widget.set_readonly(readonly);
}
this.wid_text.prop('disabled', readonly);
},
_set_button_sensitive: function() {
var create = this.attributes.create;
if (create === undefined) {
create = 1;
} else if (typeof create == 'string') {
create = Boolean(parseInt(create, 10));
}
var delete_ = this.attributes['delete'];
if (delete_ === undefined) {
delete_ = 1;
} else if (typeof delete_ == 'string') {
delete_ = Boolean(parseInt(delete_, 10));
}
this.but_add.prop('disabled', this._readonly || !create);
for (var key in this.fields) {
var button = this.fields[key].button;
button.prop('disabled', this._readonly || !delete_);
}
},
add_line: function(key, position) {
var field, row;
var key_schema = this.field.keys[key];
this.fields[key] = field = new (
this.get_entries(key_schema.type))(key, this);
this.rows[key] = row = jQuery('
', {
'class': 'form-group'
});
var text = key_schema.string + Sao.i18n.gettext(':');
var label = jQuery('
', {
'text': text
}).appendTo(jQuery('
', {
'class': 'dict-label col-sm-2 control-label'
}).appendTo(row));
field.el.addClass('col-sm-10').appendTo(row);
label.uniqueId();
field.labelled.uniqueId();
field.labelled.attr('aria-labelledby', label.attr('id'));
label.attr('for', field.labelled.attr('id'));
field.button.click(() => {
this.remove(key, true);
});
var previous = null;
if (position > 0) {
previous = this.container.children().eq(position - 1);
}
if (previous) {
previous.after(row);
} else {
this.container.prepend(row);
}
},
display: function() {
this._display();
},
_display: function() {
Sao.View.Form.Dict._super.display.call(this);
var record = this.record;
var field = this.field;
if (!field) {
return;
}
var record_id = record ? record.id : null;
var key;
if (record_id != this._record_id) {
for (key in this.fields) {
this.remove(key, false);
}
this._record_id = record_id;
}
var value = field.get_client(record);
var new_key_names = Object.keys(value).filter(
e => !this.fields[e]);
var prm;
if (!jQuery.isEmptyObject(new_key_names)) {
prm = field.add_keys(new_key_names, record);
} else {
prm = jQuery.when();
}
prm.then(() => {
var i, len, key;
var keys = Object.keys(value)
.filter(function(key) {
return field.keys[key];
})
.sort(function(key1, key2) {
var seq1 = field.keys[key1].sequence;
var seq2 = field.keys[key2].sequence;
if (seq1 < seq2) {
return -1;
} else if (seq1 > seq2) {
return 1;
} else {
return 0;
}
});
// We remove first the old keys in order to keep the order
// inserting the new ones
var removed_key_names = Object.keys(this.fields).filter(
function(e) {
return !(e in value);
});
for (i = 0, len = removed_key_names.length; i < len; i++) {
key = removed_key_names[i];
this.remove(key, false);
}
var decoder = new Sao.PYSON.Decoder();
var inversion = new Sao.common.DomainInversion();
for (i = 0, len = keys.length; i < len; i++) {
key = keys[i];
var val = value[key];
if (!this.fields[key]) {
this.add_line(key, i);
}
var widget = this.fields[key];
widget.set_value(val);
widget.set_readonly(this._readonly);
var key_domain = (decoder.decode(field.keys[key].domain ||
'null'));
if (key_domain !== null) {
if (!inversion.eval_domain(key_domain, value)) {
widget.el.addClass('has-error');
} else {
widget.el.removeClass('has-error');
}
}
}
});
this._set_button_sensitive();
return prm;
},
_update_completion: function(text) {
if (this.wid_text.prop('disabled')) {
return jQuery.when();
}
if (!this.record) {
return jQuery.when();
}
return Sao.common.update_completion(
this.wid_text, this.record, this.field, this.schema_model);
},
_completion_match_selected: function(value) {
this.add_new_keys([value.id]);
this.wid_text.val('');
},
get_entries: function(type) {
switch (type) {
case 'char':
return Sao.View.Form.Dict.Entry;
case 'boolean':
return Sao.View.Form.Dict.Boolean;
case 'selection':
return Sao.View.Form.Dict.Selection;
case 'multiselection':
return Sao.View.Form.Dict.MultiSelection;
case 'integer':
return Sao.View.Form.Dict.Integer;
case 'float':
return Sao.View.Form.Dict.Float;
case 'numeric':
return Sao.View.Form.Dict.Numeric;
case 'date':
return Sao.View.Form.Dict.Date;
case 'datetime':
return Sao.View.Form.Dict.DateTime;
}
}
});
Sao.View.Form.Dict.Entry = Sao.class_(Object, {
init: function(name, parent_widget) {
this.name = name;
this.definition = parent_widget.field.keys[name];
this.parent_widget = parent_widget;
this.create_widget();
if (this.definition.help) {
this.el.attr('title', this.definition.help);
}
},
create_widget: function() {
this.el = jQuery('
', {
'class': this.class_
});
var group = jQuery('
', {
'class': 'input-group input-group-sm'
}).appendTo(this.el);
this.input = this.labelled = jQuery('
', {
'type': 'text',
'class': 'form-control input-sm mousetrap',
'name': this.name,
}).appendTo(group);
this.button = jQuery('
', {
'class': 'btn btn-default',
'type': 'button',
'arial-label': Sao.i18n.gettext("Remove"),
'title': Sao.i18n.gettext("Remove"),
}).append(Sao.common.ICONFACTORY.get_icon_img('tryton-remove')
).appendTo(jQuery('
', {
'class': 'input-group-btn'
}).appendTo(group));
this.el.on('keydown',
this.parent_widget.send_modified.bind(this.parent_widget));
this.el.change(
this.parent_widget.focus_out.bind(this.parent_widget));
},
modified: function(value) {
return (JSON.stringify(this.get_value()) !=
JSON.stringify(value[this.name]));
},
get_value: function() {
return this.input.val();
},
set_value: function(value) {
this.input.val(value || '');
},
set_readonly: function(readonly) {
this._readonly = readonly;
this.input.prop('readonly', readonly);
}
});
Sao.View.Form.Dict.Char = Sao.class_(Sao.View.Form.Dict.Entry, {
class_: 'dict-char',
modified: function(value) {
return (JSON.stringify(this.get_value()) !=
JSON.stringify(value[this.name] || ""));
}
});
Sao.View.Form.Dict.Boolean = Sao.class_(Sao.View.Form.Dict.Entry, {
class_: 'dict-boolean',
create_widget: function() {
Sao.View.Form.Dict.Boolean._super.create_widget.call(this);
this.input.attr('type', 'checkbox');
this.input.change(
this.parent_widget.focus_out.bind(this.parent_widget));
},
get_value: function() {
return this.input.prop('checked');
},
set_readonly: function(readonly) {
this._readonly = readonly;
this.input.prop('disabled', readonly);
},
set_value: function(value) {
this.input.prop('checked', value);
}
});
Sao.View.Form.Dict.SelectionEntry = Sao.class_(Sao.View.Form.Dict.Entry, {
create_widget: function() {
Sao.View.Form.Dict.SelectionEntry._super.create_widget.call(this);
var select = jQuery('
', {
'class': 'form-control input-sm mousetrap',
'name': this.name,
});
select.change(
this.parent_widget.focus_out.bind(this.parent_widget));
this.input.replaceWith(select);
this.input = this.labelled = select;
var selection = jQuery.extend([], this.definition.selection);
if (this.definition.sort === undefined || this.definition.sort) {
selection.sort(function(a, b) {
return a[1].localeCompare(b[1]);
});
}
for (const e of selection) {
select.append(jQuery('
', {
'value': JSON.stringify(e[0]),
'text': e[1],
'title': this.definition.help_selection[e[0]],
}));
}
},
set_readonly: function(readonly) {
this._readonly = readonly;
this.input.prop('disabled', readonly);
}
});
Sao.View.Form.Dict.Selection = Sao.class_(
Sao.View.Form.Dict.SelectionEntry, {
class_: 'dict-selection',
create_widget: function() {
Sao.View.Form.Dict.Selection._super.create_widget.call(this);
this.input.prepend(jQuery('
', {
'value': JSON.stringify(null),
'text': '',
}));
},
get_value: function() {
return JSON.parse(this.input.val());
},
set_value: function(value) {
this.input.val(JSON.stringify(value));
var title = this.definition.help_selection[value] || null;
if (this.definition.help && title) {
title = this.definition.help + '\n' + title;
}
this.input.attr('title', title);
},
});
Sao.View.Form.Dict.MultiSelection = Sao.class_(
Sao.View.Form.Dict.SelectionEntry, {
class_: 'dict-multiselection',
create_widget: function() {
Sao.View.Form.Dict.MultiSelection._super
.create_widget.call(this);
this.input.prop('multiple', true);
var widget_help = this.definition.help;
if (widget_help) {
this.input.children().each(function() {
var option = jQuery(this);
var help = option.attr('title');
if (help) {
help = widget_help + '\n' + help;
option.attr('title', help);
}
});
}
},
get_value: function() {
var value = this.input.val();
return value.map(function(e) { return JSON.parse(e); });
},
set_value: function(value) {
if (value) {
value = value.map(function(e) { return JSON.stringify(e); });
}
this.input.val(value);
}
});
Sao.View.Form.Dict.Integer = Sao.class_(Sao.View.Form.Dict.Entry, {
class_: 'dict-integer',
create_widget: function() {
Sao.View.Form.Dict.Integer._super.create_widget.call(this);
this.input_text = this.labelled = integer_input(this.input);
},
get_value: function() {
var value = parseInt(this.input.val(), 10);
if (isNaN(value)) {
return null;
}
return value;
},
set_value: function(value, options) {
if (value !== null) {
this.input.val(value);
this.input_text.val(value.toLocaleString(
Sao.i18n.BC47(Sao.i18n.getlang()), options));
} else {
this.input.val('');
this.input_text.val('');
}
},
set_readonly: function(readonly) {
Sao.View.Form.Dict.Integer._super.set_readonly.call(this, readonly);
this.input_text.prop('readonly', readonly);
},
});
Sao.View.Form.Dict.Float = Sao.class_(Sao.View.Form.Dict.Integer, {
class_: 'dict-float',
get digits() {
var record = this.parent_widget.record;
if (record) {
var digits = record.expr_eval(this.definition.digits);
if (!digits || !digits.every(function(e) {
return e !== null;
})) {
return null;
}
return digits;
} else {
return null;
}
},
get_value: function() {
var value = this.input.val();
if (!value && (value !== 0)) {
return null;
}
value = Number(value);
if (isNaN(value)) {
return null;
}
return value;
},
set_value: function(value) {
var step = 'any',
options = {};
var digits = this.digits;
if (digits) {
step = Math.pow(10, -digits[1]).toFixed(digits[1]);
options.minimumFractionDigits = digits[1];
options.maximumFractionDigits = digits[1];
}
this.input.attr('step', step);
Sao.View.Form.Dict.Float._super.set_value.call(this, value, options);
},
});
Sao.View.Form.Dict.Numeric = Sao.class_(Sao.View.Form.Dict.Float, {
class_: 'dict-numeric',
get_value: function() {
var value = this.input.val();
if (!value && (value !== 0)) {
return null;
}
value = new Sao.Decimal(value);
if (isNaN(value.valueOf())) {
return null;
}
return value;
}
});
Sao.View.Form.Dict.Date = Sao.class_(Sao.View.Form.Dict.Entry, {
class_: 'dict-date',
format: '%x',
_input: 'date',
_input_format: '%Y-%m-%d',
_format: Sao.common.format_date,
_parse: Sao.common.parse_date,
create_widget: function() {
Sao.View.Form.Dict.Date._super.create_widget.call(this);
var group = this.input.parent().find('.input-group-btn');
this.input_date = jQuery('
', {
'type': this._input,
'role': 'button',
'tabindex': -1,
});
this.input_date.click(() => {
var value = this.get_value();
value = this._format(this._input_format, value);
this.input_date.val(value);
});
this.input_date.change(() => {
var value = this.input_date.val();
if (value) {
value = this._parse(this._input_format, value);
value = this._format(this.format, value);
this.input.val(value).change();
this.input.focus();
}
});
if (this.input_date[0].type == this._input) {
var icon = jQuery('
', {
'class': 'btn btn-default',
'aria-label': Sao.i18n.gettext("Open the calendar"),
'title': Sao.i18n.gettext("Open the calendar"),
}).prependTo(group);
this.input_date.appendTo(icon);
Sao.common.ICONFACTORY.get_icon_img('tryton-date')
.appendTo(icon);
}
var mousetrap = new Mousetrap(this.el[0]);
mousetrap.bind('enter', (e, combo) => {
var value = this._parse(this.format, this.input.val());
value = this._format(this.format, value);
this.input.val(value).change();
});
mousetrap.bind('=', (e, combo) => {
e.preventDefault();
this.input.val(this._format(this.format, moment())).change();
});
Sao.common.DATE_OPERATORS.forEach(operator => {
mousetrap.bind(operator[0], (e, combo) => {
e.preventDefault();
var date = this.get_value() || Sao.DateTime();
date.add(operator[1]);
this.input.val(this._format(this.format, date)).change();
});
});
},
get_value: function() {
return this._parse(this.format, this.input.val());
},
set_value: function(value) {
this.input.val(this._format(this.format, value));
},
});
Sao.View.Form.Dict.DateTime = Sao.class_(Sao.View.Form.Dict.Date, {
class_: 'dict-datetime',
format: '%x %X',
_input: 'datetime-local',
_input_format: '%Y-%m-%dT%H:%M:%S',
_format: Sao.common.format_datetime,
_parse: Sao.common.parse_datetime,
});
Sao.View.Form.PYSON = Sao.class_(Sao.View.Form.Char, {
class_: 'form-pyson',
init: function(view, attributes) {
Sao.View.Form.PYSON._super.init.call(this, view, attributes);
this.encoder = new Sao.PYSON.Encoder({});
this.decoder = new Sao.PYSON.Decoder({}, true);
this.el.keyup(this.validate_pyson.bind(this));
this.icon = jQuery('
![]()
', {
'class': 'icon form-control-feedback',
}).appendTo(this.group);
this.group.addClass('has-feedback');
},
display: function() {
Sao.View.Form.PYSON._super.display.call(this);
this.validate_pyson();
},
get_encoded_value: function() {
var value = this.input.val();
if (!value) {
return value;
}
try {
return this.encoder.encode(eval_pyson(value));
}
catch (err) {
return null;
}
},
set_value: function() {
// avoid modification because different encoding
var value = this.get_encoded_value();
var record = this.record;
var field = this.field;
var previous = field.get_client(record);
if (value && previous && Sao.common.compare(
value, this.encoder.encode(this.decoder.decode(previous)))) {
value = previous;
}
field.set_client(record, value);
},
get_client_value: function() {
var value = Sao.View.Form.PYSON._super.get_client_value.call(this);
if (value) {
value = Sao.PYSON.toString(this.decoder.decode(value));
}
return value;
},
validate_pyson: function() {
var icon = 'ok';
if (this.get_encoded_value() === null) {
icon = 'error';
}
Sao.common.ICONFACTORY.get_icon_url('tryton-' + icon)
.then(url => {
this.icon.attr('src', url);
});
},
focus_out: function() {
this.validate_pyson();
Sao.View.Form.PYSON._super.focus_out.call(this);
}
});
let botuiInstance = null;
let previousValue = null;
Sao.View.Form.HTMLViewer = Sao.class_(Sao.View.Form.Widget, {
class_: 'form-htmlviewer',
expand: true,
init: function(view, attributes) {
Sao.View.Form.HTMLViewer._super.init.call(this, view, attributes);
this._collapsed = false;
this._collapse_button = null;
this._collapse_item = null;
this._collapse_target = null;
this._expanded_min_height = null;
this._expanded_child_min_height = null;
this.el = jQuery('
', {
'class': this.class_ + ' panel panel-default',
});
this.container = jQuery('
', {
'class': 'panel-body htmlviewer-content',
}).appendTo(this.el);
if (window.MutationObserver) {
this._theme_observer = new MutationObserver(mutations => {
var changed = mutations.some(mutation =>
mutation.attributeName == 'data-theme-mode');
if (!changed) {
return;
}
this._sync_theme_classes();
if (!document.documentElement.contains(this.el[0])) {
this._theme_observer.disconnect();
this._theme_observer = null;
return;
}
this.container.find('iframe[data-htmlviewer-dashboard="1"]')
.each(function() {
Sao.applyHTMLViewerThemeToIFrame(this);
});
if (!String(this._last_value || '').startsWith('d3:')) {
return;
}
window.requestAnimationFrame(() => this.display());
});
this._theme_observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme-mode'],
});
}
},
_ensure_collapse_control: function() {
var resolve_context = () => {
var target = this.el;
var button_parent = this.el;
var in_panel_heading = false;
var panel = this.el.closest('fieldset.form-panel');
if (panel.length) {
var legend = panel.children('legend').first();
var items = panel.find('> .form-container > .form-item');
if (legend.length) {
button_parent = legend;
in_panel_heading = true;
var other_items = items.filter(function() {
return !jQuery(this).hasClass('form-empty') &&
!jQuery(this).find(
'.form-htmlviewer, .form-purchase-summary')
.length;
});
if (!other_items.length) {
target = panel;
}
}
}
return {
target: target,
button_parent: button_parent,
in_panel_heading: in_panel_heading,
};
};
var context = resolve_context();
if (this._collapse_button) {
if (context.button_parent.length &&
this._collapse_button.parent()[0] !=
context.button_parent[0]) {
this._collapse_button.appendTo(context.button_parent);
}
this._collapse_target = context.target;
this._collapse_item = context.target.closest('.form-item');
this._collapse_button.toggleClass(
'htmlviewer-collapse-toggle-heading',
context.in_panel_heading);
this.el.toggleClass(
'htmlviewer-self-collapsible',
!context.in_panel_heading);
this._sync_collapse_state();
return;
}
this._collapse_target = context.target;
this._collapse_item = context.target.closest('.form-item');
this._collapse_button = jQuery('
', {
'type': 'button',
'class': 'htmlviewer-collapse-toggle',
'aria-label': Sao.i18n.gettext('Hide viewer'),
'title': Sao.i18n.gettext('Hide viewer'),
}).appendTo(context.button_parent);
this._collapse_button.on('click', evt => {
evt.preventDefault();
evt.stopPropagation();
this._collapsed = !this._collapsed;
this._sync_collapse_state();
});
if (context.in_panel_heading) {
this._collapse_button.addClass(
'htmlviewer-collapse-toggle-heading');
} else {
this.el.addClass('htmlviewer-self-collapsible');
}
this._sync_collapse_state();
},
_sync_collapse_state: function() {
if (!this._collapse_button || !this._collapse_target) {
return;
}
this._collapse_target.toggleClass(
'htmlviewer-collapsed', this._collapsed);
if (this._collapse_item) {
this._collapse_item.toggleClass(
'htmlviewer-collapsed-item', this._collapsed);
}
if (this._collapsed) {
this._expanded_min_height = this.el[0].style.minHeight;
this._expanded_child_min_height = this.container[0].style.minHeight;
this.el[0].style.setProperty('min-height', '0', 'important');
this.container[0].style.setProperty('min-height', '0', 'important');
} else {
this.el[0].style.removeProperty('min-height');
this.container[0].style.removeProperty('min-height');
if (this._expanded_min_height) {
this.el[0].style.minHeight = this._expanded_min_height;
}
if (this._expanded_child_min_height) {
this.container[0].style.minHeight =
this._expanded_child_min_height;
}
}
if (this._collapse_item) {
var form_container = this._collapse_item.parent('.form-container');
var container = form_container.data('sao-form-container');
if (container) {
container.set_grid_template();
}
}
this._collapse_button
.toggleClass('htmlviewer-collapse-toggle-collapsed',
this._collapsed)
.attr('aria-expanded', this._collapsed ? 'false' : 'true')
.attr('aria-label', Sao.i18n.gettext(
this._collapsed ? 'Show viewer' : 'Hide viewer'))
.attr('title', Sao.i18n.gettext(
this._collapsed ? 'Show viewer' : 'Hide viewer'));
},
_sync_theme_classes: function() {
var theme_mode = document.documentElement.getAttribute(
'data-theme-mode') || '';
var is_dark = theme_mode == 'dark';
this.container
.toggleClass('htmlviewer-theme-dark', is_dark)
.toggleClass('htmlviewer-theme-light', !is_dark);
this.el
.toggleClass('htmlviewer-theme-dark', is_dark)
.toggleClass('htmlviewer-theme-light', !is_dark);
},
display: function() {
Sao.View.Form.HTMLViewer._super.display.call(this);
window.setTimeout(() => this._ensure_collapse_control());
window.setTimeout(() => this._ensure_collapse_control(), 50);
var value = '';
var record = this.record;
if (record) {
value = record.field_get_client(this.field_name);
}
this._sync_theme_classes();
var theme_mode = document.documentElement.getAttribute(
'data-theme-mode') || '';
var render_key = String(value || '').startsWith('d3:') ?
value + '|theme:' + theme_mode : value;
// Si la valeur n'a pas changé, ne rien faire
if (this._last_render_key === render_key) {
return;
}
this._last_value = value;
this._last_render_key = render_key;
this.container.empty();
// if (!value) {
// return;
// }
let height = 300
let width = 400
if (this.attributes.height) {
height = parseInt(this.attributes.height);
}
if (this.attributes.width) {
width = parseInt(this.attributes.width);
}
Sao.renderHTMLViewerContent(this.container, value, height, width, this.record);
},
focus: function() {
// No input element to focus
return false;
},
get_value: function() {
// Read-only, no value
return null;
},
set_value: function() {
// No-op — viewer only
},
set_readonly: function(readonly) {
// Viewer is always read-only — nothing to do
},
get modified() {
return false;
},
});
Sao.View.Form.PurchaseSummary = Sao.class_(Sao.View.Form.HTMLViewer, {
class_: 'form-purchase-summary',
_summary_config: function(value) {
if (!value) {
return {};
}
if (typeof value == 'object') {
return value;
}
var text = String(value || '');
if (text.startsWith('purchase_summary:')) {
text = text.slice(17);
}
try {
return JSON.parse(text || '{}');
} catch (error) {
return {};
}
},
_approval_info: function(config) {
config = config || {};
var approval = config.approval || {};
var approval_raw = approval.status || config.approval_status || '';
var approval_key = String(approval_raw || 'need_approval')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
var approval_labels = {
need_approval: 'Need approval',
first_approval: 'First Approval',
final_approval: 'Final approval',
};
if (!approval_labels[approval_key]) {
approval_key = 'need_approval';
}
return {
key: approval_key,
label: approval.label || approval_labels[approval_key],
};
},
_update_summary_legend: function(config) {
var panel = this.el.closest('fieldset.form-panel');
var legend = panel.children('legend').first();
if (!legend.length) {
return;
}
legend.addClass('purchase-summary-legend');
legend.find('.purchase-summary-title-strip').remove();
legend.children('span').not('.purchase-summary-title-strip')
.first().text('Summary');
var escape = function(raw) {
return jQuery('
').text(raw === undefined || raw === null ?
'' : String(raw)).html();
};
var activity = (config.activity || config.activities || []).slice(0, 3);
var info = this._approval_info(config);
var activity_html = activity.map(function(item) {
var text = item.text || item.label || item.field || '';
var old_value = item.old_value !== undefined ? item.old_value :
item.old !== undefined ? item.old :
item.previous !== undefined ? item.previous : '';
var new_value = item.new_value !== undefined ? item.new_value :
item.new !== undefined ? item.new :
item.current !== undefined ? item.current : '';
if (old_value !== '' || new_value !== '') {
text = [text, [old_value, new_value].filter(function(part) {
return part !== '';
}).join(' => ')].filter(Boolean).join(' ');
}
text = [text, item.user, item.date || item.time]
.filter(Boolean).join(' - ');
return '
' + escape(text) + '';
}).join('');
var strip = jQuery(
'
' +
'' +
activity_html + '' +
'' + escape(info.label) + '' +
'');
if (this._collapse_button && this._collapse_button.parent()[0] ==
legend[0]) {
strip.insertBefore(this._collapse_button);
} else {
legend.append(strip);
}
},
display: function() {
Sao.View.Form.Widget.prototype.display.call(this);
window.setTimeout(() => this._ensure_collapse_control());
window.setTimeout(() => this._ensure_collapse_control(), 50);
var value = '';
var record = this.record;
if (record) {
value = record.field_get_client(this.field_name);
}
this._sync_theme_classes();
var render_key = 'purchase_summary|' + String(value || '');
if (this._last_render_key === render_key) {
return;
}
this._last_value = value;
this._last_render_key = render_key;
var height = parseInt(this.attributes.height || 168, 10);
var width = parseInt(this.attributes.width || 1200, 10);
this.el[0].style.setProperty('min-height', '0', 'important');
this.container[0].style.setProperty('min-height', '0', 'important');
if (value && !String(value).startsWith('purchase_summary:')) {
value = 'purchase_summary:' + String(value);
}
Sao.renderHTMLViewerContent(
this.container, value, height, width, this.record);
this._update_summary_legend(this._summary_config(value));
},
});
Sao.View.VisGraph = Sao.class_(Sao.View, {
editable: false,
view_type: 'graph',
xml_parser: Sao.View.GraphXMLViewParser,
init: function(view_id, screen, xml, children_field) {
this.el = jQuery('
', {
'class': 'graph'
});
this.container = this.el;
Sao.View.VisGraph._super.init.call(this, view_id, screen, xml);
},
display: function() {
const loads = [];
for (const record of this.screen.group) {
loads.push(record.load('vis_data'));
}
jQuery.when.apply(jQuery, loads).done(() => {
for (const record of this.screen.group) {
const visData = record.field_get_client('vis_data');
try {
let height = 300
let width = 400
if (this.attributes.height) {
height = parseInt(this.attributes.height);
}
if (this.attributes.width) {
width = parseInt(this.attributes.width);
}
Sao.renderHTMLViewerContent(this.container, visData,height,width, record);
} catch (e) {
console.warn('Erreur de parsing JSON pour vis_data', e);
}
}
});
},
reload: function() {
this.display();
},
get deletable() {
return false;
},
set_value: function() {
// No-op — viewer only
},
});
window.addEventListener("message", function (event) {
if (event.data?.type === "open_tab") {
const { model, res_id, view_mode, domain, context } = event.data.payload || {};
Sao.Tab.create({
model: model,
res_id: res_id,
mode: view_mode,
domain: domain,
target: "new",
context: context
});
}
if (event.data?.type === "exec_window"){
const { model, res_id, view_mode, domain, context } = event.data.payload || {};
var action = {
type: 'ir.action.act_window',
res_model: model,
pyson_domain: domain,
pyson_context: JSON.stringify(context),
pyson_order: 'null',
pyson_search_value: '[]',
views: [],
domains: []
};
Sao.Action.exec_action(action, { ids: [] }, {});
}
});
// window.addEventListener("message", function (event) {
// // ⚠️ Sécurité : vérifier l'origine
// // if (event.origin !== "https://ton-react.example.com") return;
// if (event.data?.type === "open_tab") {
// const { model, res_id, view_mode, domain } = event.data.payload || {};
// Sao.Tab.create({
// model: model,
// res_id: res_id,
// mode: [view_mode],
// domain: domain,
// target: "new",
// });
// }
// });
Sao.traceHTMLViewerTheme = function(stage, payload) {
if (!window.console) {
return;
}
var storage_theme = null;
try {
storage_theme = window.localStorage &&
localStorage.getItem('sao_theme_mode');
} catch (error) {
storage_theme = 'unavailable:' + (error && error.name || 'error');
}
try {
console.info('[HTMLVIEWER][THEME_DEBUG]', JSON.stringify(
jQuery.extend({
stage: stage,
href: window.location.href,
parentTheme: document.documentElement.getAttribute(
'data-theme-mode') || null,
storageTheme: storage_theme,
}, payload || {})));
} catch (error) {
console.info('[HTMLVIEWER][THEME_DEBUG]', stage, payload);
}
};
Sao.htmlViewerThemeMode = function() {
return document.documentElement.getAttribute('data-theme-mode') || 'light';
};
Sao.isHTMLViewerDashboardURL = function(value) {
return /\/dashboard\/index\.html(?:[?#]|$)/i.test(String(value || ''));
};
Sao.shouldRewriteHTMLViewerDashboardOrigin = function(url) {
if (!url || url.origin == window.location.origin) {
return false;
}
var current_host = window.location.hostname || '';
var dashboard_host = url.hostname || '';
return /(^|\.)open-squared\.tech$/i.test(current_host) &&
/(^|\.)open-squared\.tech$/i.test(dashboard_host);
};
Sao.withHTMLViewerThemeParam = function(value) {
var is_dashboard = Sao.isHTMLViewerDashboardURL(value);
if (!is_dashboard) {
Sao.traceHTMLViewerTheme('with-param-skip', {
value: value,
reason: 'not-dashboard-url',
});
return value;
}
try {
var url = new URL(value, window.location.href);
var input_origin = url.origin;
if (Sao.shouldRewriteHTMLViewerDashboardOrigin(url)) {
url.protocol = window.location.protocol;
url.host = window.location.host;
Sao.traceHTMLViewerTheme('origin-rewrite', {
input: value,
fromOrigin: input_origin,
toOrigin: url.origin,
currentOrigin: window.location.origin,
});
}
var mode = Sao.htmlViewerThemeMode();
url.searchParams.set('theme', mode);
url.searchParams.set('theme_mode', mode);
Sao.traceHTMLViewerTheme('with-param', {
input: value,
output: url.toString(),
mode: mode,
urlOrigin: url.origin,
urlPathname: url.pathname,
sameOrigin: url.origin == window.location.origin,
themeParam: url.searchParams.get('theme'),
themeModeParam: url.searchParams.get('theme_mode'),
});
return url.toString();
} catch (e) {
var separator = value.indexOf('?') == -1 ? '?' : '&';
var mode = encodeURIComponent(Sao.htmlViewerThemeMode());
var output = value + separator + 'theme=' + mode +
'&theme_mode=' + mode;
Sao.traceHTMLViewerTheme('with-param-fallback', {
input: value,
output: output,
mode: mode,
error: e && (e.name + ': ' + e.message),
});
return output;
}
};
Sao.applyHTMLViewerThemeToIFrame = function(iframe) {
var mode = Sao.htmlViewerThemeMode();
var dashboard = iframe.getAttribute('data-htmlviewer-dashboard') == '1';
Sao.traceHTMLViewerTheme('apply-start', {
dashboard: dashboard,
mode: mode,
src: iframe.getAttribute('src') || '',
originalSrc: iframe.getAttribute('data-original-src') || '',
});
if (!dashboard) {
return;
}
var next_src = Sao.withHTMLViewerThemeParam(
iframe.getAttribute('data-original-src') || iframe.getAttribute('src') || '');
if (next_src && iframe.getAttribute('src') != next_src) {
Sao.traceHTMLViewerTheme('apply-src-update', {
previousSrc: iframe.getAttribute('src') || '',
nextSrc: next_src,
});
iframe.setAttribute('src', next_src);
}
try {
var doc = iframe.contentDocument || iframe.contentWindow.document;
if (!doc || !doc.documentElement) {
Sao.traceHTMLViewerTheme('apply-no-document', {
src: iframe.getAttribute('src') || '',
});
return;
}
var existing_style = doc.getElementById(
'tradon-htmlviewer-theme-bridge');
Sao.traceHTMLViewerTheme('apply-document-access', {
src: iframe.getAttribute('src') || '',
readyState: doc.readyState,
docUrl: doc.location && doc.location.href || null,
docThemeBefore: doc.documentElement.getAttribute(
'data-theme-mode') || null,
bodyThemeBefore: doc.body &&
doc.body.getAttribute('data-theme-mode') || null,
hasHead: Boolean(doc.head),
hasBody: Boolean(doc.body),
bridgeBefore: Boolean(existing_style),
});
doc.documentElement.setAttribute('data-theme-mode', mode);
doc.body && doc.body.setAttribute('data-theme-mode', mode);
var style = doc.getElementById('tradon-htmlviewer-theme-bridge');
if (!style) {
style = doc.createElement('style');
style.id = 'tradon-htmlviewer-theme-bridge';
doc.head.appendChild(style);
}
var dashboard_logo_url = new URL(
'dist/tradon_wbg.png', window.location.href).toString();
var compact_dashboard_css = `
:root {
--tradon-dashboard-card-radius: 6px;
font-size: 14px !important;
}
body {
font-size: 0.9rem !important;
}
#root > [class*="min-h-screen"],
#app > [class*="min-h-screen"],
body > [class*="min-h-screen"] {
padding: 0.75rem !important;
}
[class*="container"], [class*="max-w-"] {
padding-left: 0.6rem !important;
padding-right: 0.6rem !important;
}
[class*="grid"][class*="gap-6"],
[class*="grid"][class*="gap-5"],
[class*="grid"][class*="gap-4"] {
gap: 0.7rem !important;
}
[class*="gap-6"], [class*="gap-5"], [class*="gap-4"] {
gap: 0.7rem !important;
}
[class*="gap-3"], [class*="gap-2"] {
gap: 0.4rem !important;
}
[class*="p-8"], [class*="p-7"], [class*="p-6"] {
padding: 0.75rem !important;
}
[class*="p-5"], [class*="p-4"] {
padding: 0.65rem !important;
}
[class*="px-6"], [class*="px-5"], [class*="px-4"] {
padding-left: 0.7rem !important;
padding-right: 0.7rem !important;
}
[class*="py-6"], [class*="py-5"], [class*="py-4"] {
padding-top: 0.55rem !important;
padding-bottom: 0.55rem !important;
}
[class*="mb-8"], [class*="mb-7"], [class*="mb-6"] {
margin-bottom: 0.75rem !important;
}
[class*="mt-8"], [class*="mt-7"], [class*="mt-6"] {
margin-top: 0.75rem !important;
}
[class*="space-y-6"] > :not([hidden]) ~ :not([hidden]),
[class*="space-y-5"] > :not([hidden]) ~ :not([hidden]),
[class*="space-y-4"] > :not([hidden]) ~ :not([hidden]) {
margin-top: 0.7rem !important;
}
[class*="bg-white"], [class*="card"], [class*="Card"],
section, article {
border-radius: var(--tradon-dashboard-card-radius) !important;
min-height: 0 !important;
}
[class*="rounded-2xl"], [class*="rounded-xl"], [class*="rounded-lg"] {
border-radius: var(--tradon-dashboard-card-radius) !important;
}
[class*="shadow-lg"], [class*="shadow-xl"], [class*="shadow-md"] {
box-shadow: 0 3px 8px rgba(15, 23, 42, 0.12) !important;
}
[class*="text-6xl"], [class*="text-5xl"] {
font-size: 1.75rem !important;
line-height: 2rem !important;
}
[class*="text-4xl"] {
font-size: 1.55rem !important;
line-height: 1.85rem !important;
}
[class*="text-3xl"] {
font-size: 1.3rem !important;
line-height: 1.6rem !important;
}
[class*="text-2xl"] {
font-size: 1.1rem !important;
line-height: 1.35rem !important;
}
[class*="text-xl"] {
font-size: 1rem !important;
line-height: 1.25rem !important;
}
[class*="text-lg"] {
font-size: 0.95rem !important;
line-height: 1.2rem !important;
}
[class*="w-12"][class*="h-12"],
[class*="w-10"][class*="h-10"],
[class*="w-8"][class*="h-8"] {
width: 1.35rem !important;
height: 1.35rem !important;
}
[class*="w-6"][class*="h-6"],
[class*="w-5"][class*="h-5"] {
width: 1rem !important;
height: 1rem !important;
}
svg {
max-width: 1.35rem !important;
max-height: 1.35rem !important;
}
[class*="h-3"], [class*="h-4"] {
min-height: 0 !important;
}
canvas, [class*="chart"], [class*="Chart"], [class*="recharts"] {
max-height: 150px !important;
}
`;
var dark_dashboard_css = `
:root { color-scheme: dark; }
html {
font-size: 14px !important;
}
body {
font-size: 0.9rem !important;
}
#root > [class*="min-h-screen"],
#app > [class*="min-h-screen"],
body > [class*="min-h-screen"] {
padding: 0.75rem !important;
}
[class*="grid"][class*="gap-4"] {
gap: 0.7rem !important;
}
[class*="p-6"] {
padding: 0.75rem !important;
}
[class*="p-4"] {
padding: 0.65rem !important;
}
[class*="mb-6"] {
margin-bottom: 0.75rem !important;
}
[class*="gap-2"] {
gap: 0.4rem !important;
}
[class*="text-4xl"] {
font-size: 1.55rem !important;
line-height: 1.85rem !important;
}
html, body, #root, #app, [id*="root"], [class*="app"] {
background: #000000 !important;
color: #eaf7f4 !important;
}
body {
scrollbar-color: #31534e #000000;
}
body > div, #root, #app,
#root > div, #app > div,
#root > div > div, #app > div > div,
main,
[class*="min-h-screen"], [class*="dashboard"], [class*="Dashboard"] {
background-color: #000000 !important;
}
[class*="bg-gray-50"], [class*="bg-slate-50"],
[class*="bg-neutral-50"], [class*="bg-zinc-50"],
[class*="bg-gray-100"], [class*="bg-slate-100"],
[class*="bg-neutral-100"], [class*="bg-zinc-100"] {
background: #000000 !important;
}
[class*="bg-white"], [class*="card"],
[class*="Card"], section, article {
background: linear-gradient(180deg, #142326 0%, #101d20 100%) !important;
border-color: rgba(118, 173, 165, 0.22) !important;
color: #eaf7f4 !important;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
}
[class*="text-gray"], [class*="text-slate"], [class*="text-neutral"],
[class*="text-zinc"] {
color: #c3d4cf !important;
}
[class*="text-gray-400"], [class*="text-gray-500"],
[class*="text-slate-400"], [class*="text-slate-500"],
[class*="text-neutral-400"], [class*="text-neutral-500"] {
color: #a9c1bb !important;
}
h1, h2, h3, h4, h5, h6,
[class*="font-semibold"], [class*="font-bold"] {
color: #f4fbf8 !important;
}
svg, svg * {
color: #a9e3dd !important;
stroke: currentColor !important;
}
svg {
opacity: 0.94 !important;
}
[class*="bg-gray-200"], [class*="bg-slate-200"],
[class*="bg-neutral-200"], [class*="bg-zinc-200"] {
background: #1b2c2e !important;
}
[class*="bg-green"], [class*="bg-emerald"], [class*="bg-teal"],
[class*="bg-cyan"] {
background: #11625d !important;
color: #a2fff7 !important;
}
[class*="text-green"], [class*="text-emerald"], [class*="text-teal"] {
color: #5ee6dc !important;
}
[class*="bg-blue"], [class*="bg-sky"], [class*="bg-indigo"] {
background: #0f5e83 !important;
color: #9be0ff !important;
}
[class*="bg-red"], [class*="bg-rose"], [class*="bg-pink"] {
background: #4a2228 !important;
color: #ff9ba6 !important;
}
[class*="bg-yellow"], [class*="bg-amber"], [class*="bg-orange"] {
background: #4a3818 !important;
color: #ffd071 !important;
}
[class*="rounded-full"], [class*="badge"], [class*="Badge"] {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10) !important;
font-weight: 700 !important;
}
[class*="bg-green"][class*="rounded"],
[class*="bg-emerald"][class*="rounded"],
[class*="bg-teal"][class*="rounded"],
[class*="bg-cyan"][class*="rounded"],
[class*="bg-blue"][class*="rounded"],
[class*="bg-sky"][class*="rounded"],
[class*="bg-indigo"][class*="rounded"] {
box-shadow: inset 0 0 0 1px rgba(126, 228, 220, 0.20) !important;
}
[class*="bg-green"][class*="rounded-lg"],
[class*="bg-green"][class*="rounded-xl"],
[class*="bg-emerald"][class*="rounded-lg"],
[class*="bg-emerald"][class*="rounded-xl"],
[class*="bg-teal"][class*="rounded-lg"],
[class*="bg-teal"][class*="rounded-xl"],
[class*="bg-cyan"][class*="rounded-lg"],
[class*="bg-cyan"][class*="rounded-xl"] {
background: rgba(20, 88, 82, 0.18) !important;
border: 1px solid rgba(46, 196, 182, 0.52) !important;
color: #dffdfa !important;
box-shadow: inset 0 0 0 1px rgba(126, 228, 220, 0.08) !important;
}
[class*="bg-blue"][class*="rounded-lg"],
[class*="bg-blue"][class*="rounded-xl"],
[class*="bg-sky"][class*="rounded-lg"],
[class*="bg-sky"][class*="rounded-xl"],
[class*="bg-indigo"][class*="rounded-lg"],
[class*="bg-indigo"][class*="rounded-xl"] {
background: rgba(35, 82, 118, 0.18) !important;
border: 1px solid rgba(82, 153, 214, 0.50) !important;
color: #e6f5ff !important;
box-shadow: inset 0 0 0 1px rgba(155, 224, 255, 0.08) !important;
}
[class*="flex"][class*="items-center"][class*="justify-between"],
[class*="flex"][class*="justify-between"][class*="items-center"] {
background: transparent !important;
box-shadow: none !important;
border-color: transparent !important;
}
[class*="w-3"][class*="h-3"], [class*="w-4"][class*="h-4"] {
box-shadow: none !important;
}
button, a[role="button"], [role="button"] {
color: #edf8f5 !important;
border-color: rgba(118, 173, 165, 0.20) !important;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
button:hover, button:focus,
a[role="button"]:hover, a[role="button"]:focus,
[role="button"]:hover, [role="button"]:focus {
background: #1b3436 !important;
border-color: rgba(126, 228, 220, 0.34) !important;
color: #ffffff !important;
}
canvas, [class*="chart"], [class*="Chart"], [class*="recharts"] {
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
[class*="chart"] svg, [class*="Chart"] svg,
[class*="recharts"] svg {
background: transparent !important;
overflow: visible !important;
}
[class*="chart"] svg text,
[class*="Chart"] svg text,
[class*="chart"] svg line,
[class*="Chart"] svg line,
[class*="chart"] svg rect,
[class*="Chart"] svg rect {
opacity: 0 !important;
}
[class*="recharts-cartesian-axis"],
[class*="recharts-cartesian-grid"],
[class*="recharts-tooltip-cursor"],
[class*="axis"], [class*="Axis"],
[class*="tick"], [class*="Tick"] {
opacity: 0 !important;
}
[class*="recharts-line-curve"],
[class*="recharts-area-curve"],
[class*="chart"] svg path,
[class*="Chart"] svg path,
[class*="chart"] svg polyline,
[class*="Chart"] svg polyline {
stroke: #15d6a4 !important;
stroke-width: 3 !important;
stroke-linecap: round !important;
stroke-linejoin: round !important;
filter: drop-shadow(0 0 7px rgba(21, 214, 164, 0.34)) !important;
}
[class*="chart"] svg path[fill="none"],
[class*="Chart"] svg path[fill="none"],
[class*="chart"] svg polyline,
[class*="Chart"] svg polyline {
fill: none !important;
}
[class*="chart"] svg polygon,
[class*="Chart"] svg polygon,
[class*="chart"] svg path:not([fill="none"]),
[class*="Chart"] svg path:not([fill="none"]) {
fill: rgba(21, 214, 164, 0.13) !important;
stroke: transparent !important;
}
[class*="recharts-area-area"] {
fill: rgba(21, 214, 164, 0.14) !important;
}
svg [stroke="#000"], svg [stroke="black"],
svg [stroke="#111"], svg [stroke="#222"] {
stroke: #7ed6cf !important;
}
[class*="text-4xl"], [class*="text-5xl"], [class*="text-6xl"] {
background: transparent !important;
box-shadow: none !important;
border-color: transparent !important;
}
img[src*="logo"], img[src*="Logo"], img[src*="tradon"], img[src*="Tradon"] {
content: url("${dashboard_logo_url}") !important;
object-fit: contain !important;
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
[class*="logo"], [class*="Logo"] {
background-image: url("${dashboard_logo_url}") !important;
background-size: contain !important;
background-repeat: no-repeat !important;
background-position: center !important;
background-color: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
[class*="splash"], [class*="Splash"],
[class*="loading"], [class*="Loading"],
[class*="loader"], [class*="Loader"] {
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
[class*="border"], hr {
border-color: rgba(118, 173, 165, 0.22) !important;
}
input, select, textarea {
background: #1a3033 !important;
border-color: rgba(118, 173, 165, 0.26) !important;
color: #f4fbf8 !important;
}
`;
if (mode != 'dark') {
style.textContent = compact_dashboard_css;
Sao.traceHTMLViewerTheme('apply-light-compact-injected', {
src: iframe.getAttribute('src') || '',
bridgePresent: Boolean(doc.getElementById(
'tradon-htmlviewer-theme-bridge')),
styleLength: style && style.textContent ?
style.textContent.length : 0,
});
return;
}
style.textContent = compact_dashboard_css + dark_dashboard_css;
doc.querySelectorAll('img').forEach(function(img) {
var marker = [
img.getAttribute('src') || '',
img.getAttribute('alt') || '',
img.className || '',
img.id || '',
].join(' ');
if (!/logo|tradon/i.test(marker)) {
return;
}
if (!img.getAttribute('data-tradon-light-src')) {
img.setAttribute('data-tradon-light-src', img.getAttribute('src') || '');
}
img.setAttribute('src', dashboard_logo_url);
var parent = img.parentElement;
var depth = 0;
while (parent && depth < 3) {
var parent_marker = [
parent.className || '',
parent.id || '',
].join(' ');
if (/logo|tradon|splash|loading|loader/i.test(parent_marker) ||
parent.children.length == 1) {
parent.style.setProperty('background', 'transparent', 'important');
parent.style.setProperty('background-color', 'transparent', 'important');
parent.style.setProperty('border-color', 'transparent', 'important');
parent.style.setProperty('box-shadow', 'none', 'important');
}
parent = parent.parentElement;
depth += 1;
}
});
Sao.traceHTMLViewerTheme('apply-dark-injected', {
src: iframe.getAttribute('src') || '',
docThemeAfter: doc.documentElement.getAttribute(
'data-theme-mode') || null,
bodyThemeAfter: doc.body &&
doc.body.getAttribute('data-theme-mode') || null,
bridgePresent: Boolean(doc.getElementById(
'tradon-htmlviewer-theme-bridge')),
styleLength: style && style.textContent ?
style.textContent.length : 0,
bodyClass: doc.body && doc.body.className || '',
rootFound: Boolean(doc.getElementById('root') ||
doc.getElementById('app')),
});
} catch (e) {
Sao.traceHTMLViewerTheme('apply-error', {
src: iframe.getAttribute('src') || '',
errorName: e && e.name || '',
errorMessage: e && e.message || String(e),
});
// Cross-origin dashboards can still read the theme query parameter.
}
};
Sao.renderPurchaseSummaryMap = function(node, map, height) {
var ensure_scripts = function(callback) {
var remaining = 0;
var done = function() {
remaining -= 1;
if (remaining <= 0) {
callback();
}
};
if (typeof d3 == 'undefined') {
remaining += 1;
var d3_script = document.createElement('script');
d3_script.src = 'https://d3js.org/d3.v7.min.js';
d3_script.onload = done;
document.head.appendChild(d3_script);
}
if (typeof topojson == 'undefined') {
remaining += 1;
var topo_script = document.createElement('script');
topo_script.src = 'https://d3js.org/topojson.v3.min.js';
topo_script.onload = done;
document.head.appendChild(topo_script);
}
if (!remaining) {
callback();
}
};
var label_boxes = [];
var as_point = function(point) {
if (!point || !isFinite(Number(point.lat)) ||
!isFinite(Number(point.lon))) {
return null;
}
return {
lat: Number(point.lat),
lon: Number(point.lon),
};
};
var auto_sea_waypoints = function(from_point, to_point) {
if (!from_point || !to_point) {
return [];
}
var east_asia = from_point.lon >= 95 && from_point.lat > 10;
var east_africa = to_point.lon >= 25 && to_point.lon <= 55 &&
to_point.lat <= 5;
if (east_asia && east_africa) {
return [
{lon: 122, lat: 22},
{lon: 104, lat: 1},
{lon: 79, lat: -8},
{lon: 55, lat: -20},
{lon: 43, lat: -21},
];
}
if (east_africa && east_asia) {
return [
{lon: 43, lat: -21},
{lon: 55, lat: -20},
{lon: 79, lat: -8},
{lon: 104, lat: 1},
{lon: 122, lat: 22},
];
}
return [];
};
var route_points = function(map, from_point, to_point) {
var configured = map.route_points || map.sea_route ||
map.waypoints || [];
var middle = configured.map(as_point).filter(Boolean);
if (!middle.length) {
middle = auto_sea_waypoints(from_point, to_point);
}
return [from_point].concat(middle, [to_point]).filter(Boolean);
};
var place_label = function(projection, point, text, base_dy) {
var projected = projection([point.lon, point.lat]);
if (!projected) {
return null;
}
var value = String(text || '');
var width = Math.max(34, value.length * 6.8);
var candidates = [
{dx: 0, dy: base_dy || -18, anchor: 'middle'},
{dx: 17, dy: -3, anchor: 'start'},
{dx: -17, dy: -3, anchor: 'end'},
{dx: 0, dy: 18, anchor: 'middle'},
];
for (const candidate of candidates) {
var x = projected[0] + candidate.dx;
var y = projected[1] + candidate.dy;
var x1 = candidate.anchor == 'end' ? x - width :
candidate.anchor == 'middle' ? x - width / 2 : x;
var box = {x1: x1, x2: x1 + width, y1: y - 11, y2: y + 3};
var overlaps = label_boxes.some(existing =>
!(box.x2 < existing.x1 || box.x1 > existing.x2 ||
box.y2 < existing.y1 || box.y1 > existing.y2));
if (!overlaps) {
label_boxes.push(box);
return {x: x, y: y, anchor: candidate.anchor, text: value};
}
}
return {
x: projected[0],
y: projected[1] + (base_dy || -18),
anchor: 'middle',
text: value,
};
};
ensure_scripts(function() {
var el = jQuery(node);
el.empty();
var max_width = Number(map.width || map.max_width || 214);
var width = Math.min(
Math.max(170, node.clientWidth || max_width),
Math.max(170, max_width));
var h = Math.max(88, height || node.clientHeight || 104);
node.style.width = width + 'px';
node.style.maxWidth = width + 'px';
node.style.flex = '0 0 ' + width + 'px';
var departures = map.departures || [];
var arrivals = map.arrivals || [];
var from = map.from || (departures[0] && departures[0].name) || '';
var to = map.to || (arrivals[0] && arrivals[0].name) || '';
var points = departures.concat(arrivals).filter(point =>
isFinite(Number(point.lat)) && isFinite(Number(point.lon)));
var center_lon = points.length ?
d3.mean(points, point => Number(point.lon)) : 15;
var center_lat = points.length ?
d3.mean(points, point => Number(point.lat)) : 15;
var projection = d3.geoNaturalEarth1()
.scale(Math.min(width, h) * 0.43)
.center([center_lon, center_lat])
.translate([width / 2, h / 2]);
var path = d3.geoPath().projection(projection);
var svg = d3.select(node).append('svg')
.attr('class', 'purchase-summary-world-svg')
.attr('width', width)
.attr('height', h)
.attr('viewBox', '0 0 ' + width + ' ' + h)
.style('width', width + 'px')
.style('max-width', width + 'px');
d3.json('https://unpkg.com/world-atlas@2.0.2/countries-110m.json')
.then(function(world) {
var countries = topojson.feature(
world, world.objects.countries).features;
svg.selectAll('path.purchase-summary-country')
.data(countries)
.join('path')
.attr('class', 'purchase-summary-country')
.attr('d', path);
var draw_point = function(point, letter, extra_class) {
var projected = projection([point.lon, point.lat]);
if (!projected) {
return;
}
var group = svg.append('g')
.attr('class', 'purchase-summary-map-marker ' +
(extra_class || ''))
.attr('transform', 'translate(' + projected[0] +
' ' + projected[1] + ')');
group.append('circle').attr('r', 7);
group.append('text').attr('y', 3).text(letter);
};
if (departures[0]) {
draw_point(departures[0], 'A', '');
}
if (arrivals[0]) {
draw_point(arrivals[0], 'D',
'purchase-summary-map-marker-to');
}
if (departures[0] && arrivals[0]) {
var route = route_points(
map, as_point(departures[0]), as_point(arrivals[0]))
.map(function(point) {
return projection([point.lon, point.lat]);
})
.filter(Boolean);
if (route.length >= 2) {
var route_line = d3.line()
.x(point => point[0])
.y(point => point[1])
.curve(d3.curveCatmullRom.alpha(0.5));
svg.insert('path', '.purchase-summary-map-marker')
.attr('class', 'purchase-summary-route-svg')
.attr('d', route_line(route));
}
}
[
{point: departures[0], text: from, dy: -17},
{point: arrivals[0], text: to, dy: 20},
].forEach(function(item) {
if (!item.point) {
return;
}
var placed = place_label(
projection, item.point, item.text, item.dy);
if (!placed) {
return;
}
svg.append('text')
.attr('class', 'purchase-summary-map-label')
.attr('x', placed.x)
.attr('y', placed.y)
.attr('text-anchor', placed.anchor)
.text(placed.text);
});
});
});
};
Sao.renderPurchaseSummaryContent = function(container, value, height) {
var decode = function(raw) {
if (!raw) {
return {};
}
if (typeof raw == 'object') {
return raw;
}
var text = String(raw || '');
if (text.startsWith('purchase_summary:')) {
text = text.slice(17);
}
return JSON.parse(text || '{}');
};
var escape = function(raw) {
return jQuery('
').text(raw === undefined || raw === null ?
'' : String(raw)).html();
};
var doc_link = function(kind, link, label) {
link = link || {};
var count = Number(link.count || link.total || (link.res_id ? 1 : 0));
var model = link.model || '';
var res_id = link.res_id || link.id || '';
var view_mode = link.view_mode || link.view || 'form';
var disabled = !model || !res_id;
var title = link.title || (kind == 'shipment' ? 'Open shipment' :
kind == 'sale' ? 'Open sale' : 'Open invoice');
return '
';
};
var number = function(value, digits) {
var numeric = Number(value || 0);
return numeric.toLocaleString(undefined, {
maximumFractionDigits: digits === undefined ? 0 : digits,
minimumFractionDigits: digits === undefined ? 0 : digits,
});
};
var clean_label = function(value) {
var label = value === undefined || value === null ? '' :
String(value);
if (/^\d+$/.test(label)) {
label = '';
}
return label;
};
var money = function(value, currency, digits) {
var label = currency === undefined || currency === null ? '' :
String(currency);
if (/^\d+$/.test(label)) {
label = '';
}
return [escape(label), number(value, digits)].filter(Boolean)
.join(' ');
};
var signed = function(value, suffix) {
var numeric = Number(value || 0);
var sign = numeric >= 0 ? '+' : '';
return sign + number(numeric, 2) + (suffix || '');
};
var tone = function(value) {
var numeric = Number(value || 0);
if (numeric > 0) return 'positive';
if (numeric < 0) return 'negative';
return 'neutral';
};
var config = decode(value);
var map = config.map || {};
var execution = config.execution || {};
var totals = execution.totals || {};
var rows = execution.rows || [];
var links = execution.links || config.links || {};
var sale_link = links.sale || links.sales || execution.sale ||
execution.sales || config.sale || config.sales || {};
var shipment_link = links.shipment || links.shipments ||
execution.shipment || execution.shipments || config.shipment ||
config.shipments || {};
var pnl = config.pnl || {};
var mtm = config.mtm || {};
var amount = config.amount || config.financial_amount || totals.amount ||
pnl.amount || {};
var amount_value = typeof amount == 'object' ? amount.value : amount;
var amount_currency = clean_label(config.total_currency ||
config.currency_badge || (typeof amount == 'object' ?
amount.currency : '') || config.currency);
var normalize_chips = function(raw) {
if (!raw) {
return [];
}
if (raw instanceof Array) {
return raw;
}
if (typeof raw == 'object') {
if (raw.dimension || raw.label || raw.name || raw.value ||
raw.text) {
return [raw];
}
return Object.keys(raw).map(function(key) {
return {
dimension: key,
value: raw[key],
};
});
}
return [];
};
var total_chips = normalize_chips(config.total_chips ||
config.total_badges || config.dimension_chips ||
config.total_dimensions || config.dimensions ||
(typeof amount == 'object' ? amount.dimensions : []));
var total_chips_html = total_chips.slice(0, 2).map(function(item) {
if (item instanceof Array) {
item = {
dimension: item[0],
value: item[1],
};
}
var dimension = item.dimension || item.label || item.name ||
item.key || '';
var chip_value = item.value !== undefined ? item.value :
item.text !== undefined ? item.text : '';
if (!dimension || chip_value === undefined || chip_value === null ||
chip_value === '') {
return '';
}
return '
' +
escape(dimension) + ': ' + escape(chip_value) + '';
}).filter(Boolean).join('');
var departures = map.departures || [];
var arrivals = map.arrivals || [];
var from = map.from || (departures[0] && departures[0].name) || '';
var to = map.to || (arrivals[0] && arrivals[0].name) || '';
var h = Math.max(162, Number(config.height || config.banner_height ||
Math.min(Number(height || 162), 162)));
var map_id = 'purchase-summary-map-' + Math.random().toString(36).slice(2);
var row_quantity = function(row) {
var physical = row.physical !== undefined ? row.physical :
row.physic !== undefined ? row.physic :
row.open !== undefined ? row.open :
row.shipped || 0;
var open = row.open !== undefined && row.physical !== undefined ?
row.open : row.quantity || totals.quantity || 0;
return number(physical) + '/' + number(open) + ' ' +
(row.unit || totals.unit || '');
};
var quantity_value = function(value, row) {
return number(value || 0) + (row.unit || totals.unit ?
' ' + (row.unit || totals.unit) : '');
};
var percent_value = function(row, keys) {
for (var i = 0; i < keys.length; i++) {
if (row[keys[i]] !== undefined && row[keys[i]] !== null &&
row[keys[i]] !== '') {
if (typeof row[keys[i]] == 'string' &&
row[keys[i]].indexOf('%') >= 0) {
return row[keys[i]];
}
return number(row[keys[i]], 0) + '%';
}
}
return '';
};
var money_value = function(value, row) {
if (value && typeof value == 'object') {
return money(value.value, value.currency || row.currency ||
config.currency, value.digits);
}
return money(value || 0, row.currency || config.currency, 0);
};
var raw_metric_value = function(value) {
if (value && typeof value == 'object') {
return Number(value.value || 0);
}
return Number(value || 0);
};
var line_metric = function(label, value, row, extra_class) {
return '';
};
var exec_icon = function(name) {
return '
';
};
var exec_metric = function(row_class, label, icon, value) {
return '
' +
exec_icon(icon) +
'' +
escape(label) + '' + escape(value) +
'';
};
var period_value = function(row) {
return row.period || row.shipment_period || row.period_at ||
row.date_period || totals.period || totals.shipment_period || '';
};
var visible_rows = rows.length ? rows : [{
index: 1,
product: totals.product || 'Line',
quantity: totals.quantity,
period: totals.period || totals.shipment_period,
matched: totals.matched,
shipped: totals.shipped,
unit: totals.unit,
pnl: totals.pnl,
mtm: totals.mtm,
amount: totals.amount,
}];
var exec_count = visible_rows.length;
var exec_mode = exec_count <= 1 ? 'one' :
exec_count == 2 ? 'two' :
'paged';
var rows_per_page = exec_mode == 'paged' ? 2 : visible_rows.length;
var page_count = Math.max(1,
Math.ceil(visible_rows.length / rows_per_page));
var page = 0;
var rows_html = function(current_page) {
current_page = Math.max(0, Math.min(page_count - 1,
current_page || 0));
var start = current_page * rows_per_page;
var page_rows = visible_rows.slice(start, start + rows_per_page);
return page_rows.map(function(row, index) {
var row_sale = row.sale || row.sales || sale_link;
var row_shipment = row.shipment || row.shipments || shipment_link;
return '
' +
'
' +
escape(row.index || row.sequence || start + index + 1) +
'' +
'
Product' +
'
Contract & Status' +
'
' + escape(row.product || row.label || 'Line') + '' +
'
Qty' +
escape(row_quantity(row)) + '' +
exec_metric('purchase-summary-exec-period', 'Period',
'calendar', period_value(row)) +
exec_metric('purchase-summary-exec-priced', 'Priced',
'tag', percent_value(row, ['priced', 'priced_pct',
'priced_percent'])) +
exec_metric('purchase-summary-exec-hedged', 'Hedged',
'ok-circle', percent_value(row, ['hedged', 'hedged_pct',
'hedged_percent'])) +
exec_metric('purchase-summary-exec-invoiced', 'Invoiced',
'list-alt', percent_value(row, ['invoiced', 'invoiced_pct',
'invoiced_percent'])) +
exec_metric('purchase-summary-exec-paid', 'Paid',
'usd', percent_value(row, ['paid', 'paid_pct',
'paid_percent'])) +
exec_metric('purchase-summary-exec-matched', 'Matched',
'th-large', quantity_value(row.matched, row)) +
exec_metric('purchase-summary-exec-shipped', 'Shipped',
'transfer', quantity_value(row.shipped, row)) +
'
' +
doc_link('sale', row_sale, 'Sales') + '' +
'
' +
doc_link('shipment', row_shipment, 'Shipments') +
'' +
'
' +
line_metric('PnL', row.pnl, row, 'purchase-summary-exec-pnl') +
line_metric('MTM', row.mtm, row, 'purchase-summary-exec-mtm') +
line_metric('Amount', row.amount, row,
'purchase-summary-exec-amount') +
'
' +
'
';
}).join('');
};
var pager_html = page_count > 1 ?
'' : '';
container.empty().append(
'
' +
'' +
'
' +
'Route' +
'' + escape([from, to].filter(Boolean)
.join(' \u2192 ')) + '' +
'
' +
'
' +
'
' +
'' +
'
' + rows_html(page) + '
' +
pager_html +
'
' +
'' +
(amount_currency ? '
' +
escape(amount_currency) + '' : '') +
'
' +
'PnL' +
money(pnl.value, pnl.currency || config.currency, 0) +
'' + signed(
pnl.change_d1, pnl.change_suffix || '') +
'' +
'MTM' +
money(mtm.value, mtm.currency || pnl.currency ||
config.currency, 0) + '' + signed(
mtm.change, mtm.change_suffix || '') +
'' +
'Amount' +
number(amount_value, typeof amount == 'object' ?
amount.digits : 0) + '' +
'
' +
(total_chips_html ? '
' +
total_chips_html + '
' : '') +
'
' +
'');
var bind_doc_links = function(scope) {
scope.find('.purchase-summary-doc-link').off('click').on(
'click', function(evt) {
evt.preventDefault();
evt.stopPropagation();
var button = jQuery(this);
if (button.is('[disabled], .disabled')) {
return;
}
var model = button.attr('data-model');
var res_id = parseInt(button.attr('data-res-id'), 10);
var view_mode = button.attr('data-view-mode') || 'form';
if (!model || isNaN(res_id)) {
return;
}
Sao.Tab.create({
model: model,
res_id: res_id,
mode: [view_mode],
});
});
};
var update_page = function(next_page) {
page = Math.max(0, Math.min(page_count - 1, next_page));
var start = page * rows_per_page + 1;
var end = Math.min(visible_rows.length, (page + 1) * rows_per_page);
var rows_node = container.find('.purchase-summary-exec-rows');
rows_node.html(rows_html(page));
bind_doc_links(rows_node);
container.find('.purchase-summary-exec-page-label')
.text(start + '-' + end + ' / ' + visible_rows.length);
container.find('.purchase-summary-exec-page-prev')
.prop('disabled', page <= 0);
container.find('.purchase-summary-exec-page-next')
.prop('disabled', page >= page_count - 1);
};
bind_doc_links(container);
container.find('.purchase-summary-exec-page-prev').on('click',
function(evt) {
evt.preventDefault();
update_page(page - 1);
});
container.find('.purchase-summary-exec-page-next').on('click',
function(evt) {
evt.preventDefault();
update_page(page + 1);
});
update_page(page);
Sao.renderPurchaseSummaryMap(
container.find('#' + map_id)[0], map, Math.max(88, h - 34));
};
Sao.renderHTMLViewerContent = function(container, value, height, width, record) {
function decodeHtmlEntities(str) {
const txt = document.createElement('textarea');
txt.innerHTML = str;
return txt.value;
}
container.empty();
container.removeClass('htmlviewer-react-dashboard');
if (!value) return;
if (String(value || '').startsWith('purchase_summary:')) {
Sao.renderPurchaseSummaryContent(container, value, height);
return;
} else if (/^imo:\d+$/.test(value)) {
// Case 1: VesselFinder ship by IMO
var imo = value.split(':')[1];
var iframe = document.createElement('iframe');
iframe.style.width = '100%';
iframe.style.height = height.toString()+'px';
iframe.style.border = '0';
iframe.src = `https://www.vesselfinder.com/aismap?width=100%25&height=${height-10}&names=true&imo=${imo}&track=true`;
container[0].appendChild(iframe);
} else if (/^https?:\/\//i.test(value)) {
// Case 2: Generic URL (not VesselFinder)
var is_dashboard = Sao.isHTMLViewerDashboardURL(value);
Sao.traceHTMLViewerTheme('render-url', {
value: value,
isDashboard: is_dashboard,
height: height,
width: width,
});
var iframe = jQuery('
', {
src: Sao.withHTMLViewerThemeParam(value),
width: '100%',
height: height.toString()+'px',
frameborder: 0,
scrolling: 'auto',
// sandbox: 'allow-same-origin allow-scripts allow-popups allow-forms', // optional: adjust based on your security needs
});
if (is_dashboard) {
iframe.attr({
'data-htmlviewer-dashboard': '1',
'data-original-src': value,
});
container.addClass('htmlviewer-react-dashboard');
iframe.on('load', function() {
Sao.traceHTMLViewerTheme('iframe-load', {
src: this.getAttribute('src') || '',
originalSrc: this.getAttribute('data-original-src') || '',
});
Sao.applyHTMLViewerThemeToIFrame(this);
});
} else {
container.removeClass('htmlviewer-react-dashboard');
}
container.append(iframe);
if (is_dashboard) {
Sao.applyHTMLViewerThemeToIFrame(iframe[0]);
}
} else if (value.startsWith("metabase:")) {
const url = value.replace(/^metabase:/, '');
const iframe = jQuery('
', {
src: url,
width: '100%',
height: height.toString()+'px',
frameborder: 0,
allowtransparency: 'true',
sandbox: 'allow-same-origin allow-scripts allow-popups allow-forms',
});
container.append(iframe);
} else if (value.startsWith("chatbot:")) {
let chatData = JSON.parse(value.replace(/^chatbot:/, '')) || [];
let lastMessage = chatData[chatData.length - 1];
container.empty();
const chatHeight = height - 60;
// 1. Ajouter le conteneur HTML
$('
', {
id: 'chat-wrapper',
html: `
`,
css: {
width: '100%',
height: height.toString()+'px',
boxSizing: 'border-box'
}
}).appendTo(container);
$('#chat-container').css({
height: chatHeight.toString() + 'px',
overflowY: 'auto',
borderRadius: '16px',
border: '1px solid #ccc',
padding: '10px',
boxSizing: 'border-box',
background: '#fff'
});
// 2. Ajout des messages de l'historique
const chatContainer = document.getElementById('chat-container');
const addMessage = (type, content) => {
if ((type === 'bot') || (type === 'user')){
const bubble = document.createElement('div');
bubble.className = `chat-bubble ${type}`;
bubble.innerHTML = decodeHtmlEntities(content);
chatContainer.appendChild(bubble);
bubble.querySelectorAll('a.tryton-link').forEach(link => {
link.addEventListener('click', (event) => {
event.preventDefault();
const target = event.currentTarget;
const model = target.getAttribute('data-model');
const res_id = parseInt(target.getAttribute('data-id'), 10);
const view_mode = target.getAttribute('data-view') || 'form';
if (!model || isNaN(res_id)) return;
Sao.Tab.create({
model: model,
res_id: res_id,
mode: [view_mode],
target: "new",
});
});
});
chatContainer.scrollTop = chatContainer.scrollHeight;
}
};
for (let msg of chatData) {
addMessage(msg.type, msg.content);
}
// 3. Gérer la saisie utilisateur
const input = document.getElementById('user-input');
const button = document.getElementById('send-btn');
button.addEventListener('click', () => {
const question = input.value.trim();
if (question) {
addMessage('user', question);
let currentUserId = Sao.Session.current_session.user_id;
// charger les inputs via RPC
let fields = ['id','user_id']; // les champs dont on a besoin
let domain = [['user_id', '=', currentUserId]]; // filtrer sur ce dashboard
Sao.rpc({
'method': 'model.purchase.dashboard.search_read',
'params': [domain, 0, null, null, fields, Sao.Session.current_session.context]
}, Sao.Session.current_session, true, false).then(inputs => {
console.log("RPC results",inputs);
let userInput = inputs.find(r => r.user_id === currentUserId);
Sao.Action.execute(479, {
model: 'purchase.dashboard',
id: userInput.id,
ids: [userInput.id]
}, {user_request: question}, Sao.Session.current_session.context, true)
.then(() => {
chatData.push({ type: 'user', content: question });
input.value = '';
record.field_set_client('input', JSON.stringify(chatData));
})
});
}
});
// 4. Permettre la touche "Entrée"
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
button.click();
}
});
} else {
if (value.startsWith('d3:')) {
const json = value.slice(3);
const config = JSON.parse(json); // doit contenir { highlightedCountry: '250' }
if (config.type === 'execution-summary') {
let d3Div = container.find('.d3-execution-summary-container');
if (d3Div.length === 0) {
d3Div = $('
', {
class: 'd3-execution-summary-container',
css: {
width: '100%',
height: height.toString() + 'px',
overflow: 'hidden',
background: 'transparent'
}
}).appendTo(container);
} else {
d3Div.empty();
}
const loadD3ExecutionSummary = () => {
const node = d3Div[0];
const w = Math.max(300, node.clientWidth || width || 360);
const h = Math.max(125, height || 160);
const totals = config.totals || {};
const rows = config.rows || [];
const totalQty = Math.max(1, Number(totals.quantity || 0));
const matched = Number(totals.matched || 0);
const shipped = Number(totals.shipped || 0);
const matchedPct = Math.min(100, Math.round((matched / totalQty) * 100));
const shippedPct = Math.min(100, Math.round((shipped / totalQty) * 100));
const fmt = d3.format(',.0f');
const hasTitle = Boolean(config.title);
const statsY = hasTitle ? 30 : 8;
const barY = hasTitle ? 58 : 36;
const rowY = hasTitle ? 84 : 62;
const html = document.documentElement;
const themeMode = html.getAttribute('data-theme-mode');
const isDarkMode = themeMode === 'dark';
const isLightMode = !isDarkMode && (
html.getAttribute('theme') === 'default' ||
themeMode === 'light');
const palette = !isLightMode ? {
matched: '#65d7d0',
shipped: '#4fb8b2',
open: '#405b56',
text: '#eef7f4',
muted: '#b7cac5',
line: '#66817b'
} : {
matched: '#2b8c87',
shipped: '#4ba9a4',
open: '#dce9e5',
text: '#173b40',
muted: '#526967',
line: '#bfd4ce'
};
const svg = d3.select(node).append('svg')
.attr('width', w)
.attr('height', h)
.attr('viewBox', `0 0 ${w} ${h}`);
if (hasTitle) {
svg.append('text')
.attr('x', 4)
.attr('y', 15)
.attr('font-family', 'Inter, sans-serif')
.attr('font-size', 12)
.attr('font-weight', 700)
.attr('fill', palette.text)
.text(config.title);
}
const stats = [
{label: 'Lines', value: fmt(totals.lines || rows.length || 0)},
{label: 'Matched', value: matchedPct + '%'},
{label: 'Shipped', value: shippedPct + '%'}
];
const stat = svg.selectAll('g.stat')
.data(stats)
.join('g')
.attr('class', 'stat')
.attr('transform', (d, i) => `translate(${4 + i * Math.min(95, (w - 16) / 3)}, ${statsY})`);
stat.append('text')
.attr('font-family', 'Inter, sans-serif')
.attr('font-size', 10)
.attr('fill', palette.muted)
.text(d => d.label);
stat.append('text')
.attr('y', 16)
.attr('font-family', 'Inter, sans-serif')
.attr('font-size', 14)
.attr('font-weight', 700)
.attr('fill', palette.text)
.text(d => d.value);
const barX = 4;
const barW = Math.max(160, w - 8);
const barH = 8;
svg.append('rect')
.attr('x', barX)
.attr('y', barY)
.attr('width', barW)
.attr('height', barH)
.attr('rx', 4)
.attr('fill', palette.open);
svg.append('rect')
.attr('x', barX)
.attr('y', barY)
.attr('width', barW * Math.min(1, matched / totalQty))
.attr('height', barH)
.attr('rx', 4)
.attr('fill', palette.matched);
svg.append('rect')
.attr('x', barX)
.attr('y', barY)
.attr('width', barW * Math.min(1, shipped / totalQty))
.attr('height', barH)
.attr('rx', 4)
.attr('fill', palette.shipped);
const row = svg.selectAll('g.exec-row')
.data(rows.slice(0, 3))
.join('g')
.attr('class', 'exec-row')
.attr('transform', (d, i) => `translate(4, ${rowY + i * 24})`);
row.append('line')
.attr('x1', 0)
.attr('x2', w - 8)
.attr('y1', -8)
.attr('y2', -8)
.attr('stroke', palette.line);
row.append('text')
.attr('font-family', 'Inter, sans-serif')
.attr('font-size', 10)
.attr('font-weight', 700)
.attr('fill', palette.text)
.text(d => (d.product || 'Line').slice(0, 18));
row.append('text')
.attr('x', 170)
.attr('font-family', 'Inter, sans-serif')
.attr('font-size', 10)
.attr('fill', palette.muted)
.text(d => `${fmt(d.shipped || 0)}/${fmt(d.quantity || 0)} ${d.unit || ''}`);
row.append('text')
.attr('x', 250)
.attr('font-family', 'Inter, sans-serif')
.attr('font-size', 10)
.attr('fill', palette.muted)
.text(d => (d.route || '').slice(0, 32));
if (!rows.length) {
svg.append('text')
.attr('x', 4)
.attr('y', 90)
.attr('font-family', 'Inter, sans-serif')
.attr('font-size', 11)
.attr('fill', palette.muted)
.text('No purchase lines to summarize');
}
};
const ensureD3 = (cb) => {
if (typeof d3 !== 'undefined') {
cb();
return;
}
const script = document.createElement('script');
script.src = 'https://d3js.org/d3.v7.min.js';
script.onload = cb;
document.head.appendChild(script);
};
ensureD3(loadD3ExecutionSummary);
return;
}
// Réutilise le conteneur si déjà présent
let d3Div = container.find('.d3-map-container');
if (d3Div.length === 0) {
d3Div = $('
', {
class: 'd3-map-container',
css: {
width: '100%',
minWidth: '800px',
height: (height - 25*height/1000).toString() + 'px',
overflow: 'visible',
background: 'transparent'
}
}).appendTo(container);
} else {
d3Div.empty(); // vide sans supprimer le conteneur
}
const loadD3Map = () => {
const svg = d3.select(d3Div[0])
.append("svg")
.attr("width", width)
.attr("height", (height - 25*height/1000));
const scale = Math.min(width, height) * 0.3;
const projection = d3.geoNaturalEarth1()
.scale(scale)
.translate([width / 2, (height - 10) / 2]);
const path = d3.geoPath().projection(projection);
function showTooltip(data, type) {
// Supprime les anciens tooltips
svg.selectAll(".boat-tooltip-container").remove();
const svgHeight = +svg.attr("height");
const tooltipHeight = 400;
const tooltipGroup = svg.append("g")
.attr("class", "boat-tooltip-container")
.attr("transform", `translate(10, ${svgHeight - tooltipHeight - 100})`);
let html = `
${data.name || data.properties?.name || 'Objet'}
`;
if (type === 'boat') {
html += `
IMO: ${data.imo || 'N/A'}
Position: ${data.lat.toFixed(2)}, ${data.lon.toFixed(2)}
Statut: ${data.status || 'En route'}
${data.links ? `
` : ''}
${data.actions ? `
${data.actions.map(action => `
`).join('')}
` : ''}
`;
} else if (type === 'country') {
html += `
Country: ${data.properties.name}
`;
}
html += `
`;
tooltipGroup.append("foreignObject")
.attr("width", 180)
.attr("height", 400)
.attr("x", 10)
.attr("y", 100)
.html(html);
// Ajout du bouton × (sélecteur plus sûr)
tooltipGroup.select("button").on("click", () => {
svg.selectAll(".boat-tooltip-container").remove();
});
// Clic extérieur pour fermer
function outsideClickHandler(event) {
if (!event.target.closest(".boat-tooltip-container")) {
svg.selectAll(".boat-tooltip-container").remove();
document.body.removeEventListener('click', outsideClickHandler, true);
}
}
document.body.addEventListener('click', outsideClickHandler, true);
}
d3.json("https://unpkg.com/world-atlas@2.0.2/countries-110m.json").then(worldData => {
const countries = topojson.feature(worldData, worldData.objects.countries).features;
const highlightedNames = (config.highlightedCountryNames || []).map(d => d.name);
const labelBoxes = [];
const labelPlacement = (d, baseY, text, fontSize) => {
const point = projection([d.lon, d.lat]);
if (!point) {
return {show: false, y: baseY};
}
const value = String(text || '');
const width = Math.max(36, value.length * fontSize * 0.56);
const candidates = [baseY, baseY - 14, baseY + 14, baseY - 26];
for (const y of candidates) {
const box = {
x1: point[0] - width / 2,
x2: point[0] + width / 2,
y1: point[1] + y - fontSize,
y2: point[1] + y + 4
};
const overlaps = labelBoxes.some(existing =>
!(box.x2 < existing.x1 || box.x1 > existing.x2 ||
box.y2 < existing.y1 || box.y1 > existing.y2));
if (!overlaps) {
labelBoxes.push(box);
return {show: true, y: y};
}
}
return {show: false, y: baseY};
};
const html = document.documentElement;
const themeMode = html.getAttribute('data-theme-mode');
const isDarkMode = themeMode === 'dark';
const isLightMode = !isDarkMode && (
html.getAttribute('theme') === 'default' ||
themeMode === 'light');
const mapPalette = isLightMode ? {
country: '#9aa8aa',
countryHighlight: '#cf8240',
countryStroke: '#f2f6f4',
label: '#1f3336',
labelStroke: 'rgba(255, 255, 255, 0.92)',
route: '#2b8c87'
} : {
country: '#4e5d60',
countryHighlight: '#8a5526',
countryStroke: '#243538',
label: '#f4fbf8',
labelStroke: 'rgba(8, 17, 19, 0.88)',
route: '#0077cc'
};
const styleMapLabel = selection => selection
.attr("font-family", "Inter")
.attr("fill", mapPalette.label)
.attr("paint-order", "stroke")
.attr("stroke", mapPalette.labelStroke)
.attr("stroke-width", 3)
.attr("stroke-linejoin", "round")
.attr("pointer-events", "none");
svg.selectAll("path.country")
.data(countries, d => d.id)
.join("path")
.attr("class", "country")
.attr("d", path)
.attr("fill", d => highlightedNames.includes(d.properties.name) ? mapPalette.countryHighlight : mapPalette.country)
.attr("stroke", mapPalette.countryStroke)
.attr("stroke-width", 0.5)
.on("click", function(event, d) {
event.stopPropagation();
console.log("countries",d)
showTooltip(d, "country");
});
const departureGroup = svg.selectAll("g.departure-group")
.data(config.departures || [])
.join("g")
.attr("class", "departure-group")
.attr("transform", d => {
const [x, y] = projection([d.lon, d.lat]);
return `translate(${x}, ${y})`;
});
departureGroup.append("image")
.attr("xlink:href", d => 'dist/departure.png')
.attr("width", 45)
.attr("height", 45)
.attr("x", -30)
.attr("y", -37)
.style("cursor", "pointer")
.on("click", function(event, d) {
event.stopPropagation();
showTooltip(d, "port");
});
departureGroup.append("text")
.text(d => `${d.name}`)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.each(function(d) {
const placement = labelPlacement(d, -35, d.name, 12);
d3.select(this)
.attr("y", placement.y)
.attr("opacity", placement.show ? 1 : 0);
})
.call(styleMapLabel);
const arrivalGroup = svg.selectAll("g.arrival-group")
.data(config.arrivals || [])
.join("g")
.attr("class", "arrival-group")
.attr("transform", d => {
const [x, y] = projection([d.lon, d.lat]);
return `translate(${x}, ${y})`;
});
arrivalGroup.append("image")
.attr("xlink:href", d => 'dist/arrival.png')
.attr("width", 45)
.attr("height", 45)
.attr("x", -30)
.attr("y", -37)
.style("cursor", "pointer")
.on("click", function(event, d) {
event.stopPropagation();
showTooltip(d, "port");
});
arrivalGroup.append("text")
.text(d => `${d.name}`)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.each(function(d) {
const placement = labelPlacement(d, -35, d.name, 12);
d3.select(this)
.attr("y", placement.y)
.attr("opacity", placement.show ? 1 : 0);
})
.call(styleMapLabel);
(config.routes || []).forEach((routePoints, idx) => {
const departure = (config.departures || [])[idx];
const arrival = (config.arrivals || [])[idx];
const fullRoute = [
...(departure ? [departure] : []),
...routePoints,
...(arrival ? [arrival] : [])
];
if (fullRoute.length < 2) return;
const projectedRoute = fullRoute.map(d => projection([d.lon, d.lat]));
const lineGenerator = d3.line()
.x(d => d[0])
.y(d => d[1])
.curve(d3.curveCatmullRom.alpha(0.5));
const pathData = lineGenerator(projectedRoute);
const routePath = svg.append("path")
.attr("class", "route-line")
.attr("d", pathData)
.attr("fill", "none")
.attr("stroke", mapPalette.route)
.attr("stroke-width", 2)
.attr("stroke-dasharray", "6,4");
const totalLength = routePath.node().getTotalLength();
routePath
.attr("stroke-dashoffset", totalLength)
.transition()
.duration(4000)
.ease(d3.easeLinear)
.attr("stroke-dashoffset", 0);
});
const cottonGroup = svg.selectAll("g.cotton-group")
.data(config.cottonStocks || [])
.join("g")
.attr("class", "cotton-group")
.attr("transform", d => {
const [x, y] = projection([d.lon, d.lat]);
return `translate(${x}, ${y})`;
});
cottonGroup.append("image")
.attr("xlink:href", d => 'dist/bale.png')
.attr("width", 80)
.attr("height", 80)
.attr("x", -15)
.attr("y", -25)
.style("cursor", "pointer")
.on("click", function(event, d) {
event.stopPropagation();
showTooltip(d, "stock");
});
cottonGroup.append("text")
.text(d => `Stock ${d.name}`)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.each(function(d) {
const text = `Stock ${d.name}`;
const placement = labelPlacement(d, -20, text, 12);
d3.select(this)
.attr("y", placement.y)
.attr("opacity", placement.show ? 1 : 0);
})
.call(styleMapLabel);
const boatGroup = svg.selectAll("g.boat-group")
.data(config.boats || [])
.join("g")
.attr("class", "boat-group")
.attr("transform", d => {
const [x, y] = projection([d.lon, d.lat]);
return `translate(${x}, ${y})`;
});
// Ajoute l'image du bateau
boatGroup.append("image")
.attr("xlink:href", d => `dist/boat_${d.direction || "right"}.png`)
.attr("width", 30)
.attr("height", 30)
.attr("x", -15)
.attr("y", -25)
.style("cursor", "pointer")
.on("click", function(event, d) {
event.stopPropagation();
showTooltip(d, "boat");
});
// Ajoute le tooltip permanent au-dessus
boatGroup.append("text")
.text(d => d.name || "")
.attr("text-anchor", "middle")
.attr("font-size", "10px")
.each(function(d) {
const text = d.name || "";
const placement = labelPlacement(d, -20, text, 10);
d3.select(this)
.attr("y", placement.y)
.attr("opacity", placement.show ? 1 : 0);
})
.call(styleMapLabel);
}).catch(err => {
console.error("Erreur D3:", err);
d3Div.html('