', {
'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.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'],
});
}
},
_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);
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.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.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 (/^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('