// Javascript libraries for Dolibarr ERP CRM (https://www.dolibarr.org)
// To start/stop Block UI
function dolBlockUI(message = 'Loading...', indicatorUrl = '/theme/eldy/img/working.gif') {
const block = document.getElementById('dol-block-ui');
if (block != null) {
const msgDiv = block.querySelector('.message');
if (msgDiv != null) {
msgDiv.innerText = message;
msgDiv.style.backgroundImage = `url('${indicatorUrl}')`;
block.style.display = 'flex';
}
}
}
function dolUnblockUI() {
document.getElementById('dol-block-ui').style.display = 'none';
}
// For jQuery date picker
var tradMonths = ["January","February","March","April","May","June","July","August","September","October","November","December"];
var tradMonthsShort = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
var tradDays = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
var tradDaysShort = ["S","M","T","W","T","F","S"];
var tradDaysMin = ["Su","Mo","Tu","We","Th","Fr","Sa"];
var currencyCache = [];
// For JQuery date picker
$(document).ready(function() {
$.datepicker.setDefaults({
autoSize: true,
changeMonth: true,
changeYear: true,
altField: '#timestamp',
altFormat: '@' // Gives a timestamp dateformat
});
});
jQuery(function($){
$.datepicker.regional['en_GB'] = {
closeText: 'Close2',
prevText: 'Previous',
nextText: 'Next',
currentText: 'Now',
monthNames: tradMonths,
monthNamesShort: tradMonthsShort,
dayNames: tradDays,
dayNamesShort: tradDaysShort,
dayNamesMin: tradDaysMin,
weekHeader: 'Week',
dateFormat: 'dd/mm/yy', /* Note dd/mm/yy means year on 4 digit in jquery format */
firstDay: 1,
isRTL: false,
showMonthAfterYear: false, /* TODO add specific to country */
yearSuffix: '' /* TODO add specific to country */
};
$.datepicker.setDefaults($.datepicker.regional['en_GB']);
});
/**
* Set array used for select2 translations
*/
var select2arrayoflanguage = {
matches: function (matches) { return matches + " Some results found. Use arrows to select."; },
noResults: function () { return "No result found"; },
inputTooShort: function (input) {
var n = input.minimum;
/*console.log(input); console.log(input.minimum);*/
if (n > 1) return "Enter " + n + " or more characters";
else return "Enter " + n + " or more character"
},
loadMore: function (pageNumber) { return "Loading more results..."; },
searching: function () { return "Search in progress..."; }
};
/**
* For calendar input
*/
// Returns an object given an id
function getObjectFromID(id){
var theObject;
if(document.getElementById)
theObject=document.getElementById(id);
else
theObject=document.all[id];
return theObject;
}
// Called after the selection or typing of a date to save details into detailed fields
function dpChangeDay(dateFieldID, format)
{
console.log("Call dpChangeDay, we save date from field "+dateFieldID+" into detailed fields from format = "+format);
var thefield = getObjectFromID(dateFieldID);
var thefieldday = getObjectFromID(dateFieldID+"day");
var thefieldmonth = getObjectFromID(dateFieldID+"month");
var thefieldyear = getObjectFromID(dateFieldID+"year");
console.log("string date value is " + thefield.value);
var date = getDateFromFormat(thefield.value, format);
if (date)
{
thefieldday.value = date.getDate();
if (thefieldday.onchange) thefieldday.onchange.call(thefieldday);
thefieldmonth.value = date.getMonth()+1;
if (thefieldmonth.onchange) thefieldmonth.onchange.call(thefieldmonth);
thefieldyear.value = date.getFullYear();
if (thefieldyear.onchange) thefieldyear.onchange.call(thefieldyear);
return 1;
}
// Replace yyyy into yy
newformat = format.replace(/yyyy/g, 'yy');
if (newformat != format) {
console.log("dpChangeDay, we try now from format = "+newformat);
var date = getDateFromFormat(thefield.value, newformat);
//console.log(date);
if (date)
{
thefieldday.value = date.getDate();
if (thefieldday.onchange) thefieldday.onchange.call(thefieldday);
thefieldmonth.value = date.getMonth()+1;
if (thefieldmonth.onchange) thefieldmonth.onchange.call(thefieldmonth);
thefieldyear.value = date.getFullYear();
if (thefieldyear.onchange) thefieldyear.onchange.call(thefieldyear);
return 2;
}
}
thefieldday.value = '';
if (thefieldday.onchange) thefieldday.onchange.call(thefieldday);
thefieldmonth.value = '';
if (thefieldmonth.onchange) thefieldmonth.onchange.call(thefieldmonth);
thefieldyear.value = '';
if (thefieldyear.onchange) thefieldyear.onchange.call(thefieldyear);
return 0;
}
/*
* =================================================================
* Function: formatDate(javascript object Date(), format)
* Purpose: Returns a date in the output format specified. The format string can use the following tags:
* Year | yyyy (4 digits), yy (2 digits)
* Month | MM (2 digits)
* Day of Month | dd (2 digits)
* Hour (1-12) | hh (2 digits) Hour (0-23) | HH (2 digits)
* Minute | mm (2 digits)
* Second | ss (2 digits)
* Author: Laurent Destailleur Author: Matelli (see http://matelli.fr/showcases/patchs-dolibarr/update-date-input-in-action-form.html)
* Licence: GPL
* ==================================================================
*/
function formatDate(date,format)
{
// alert('formatDate date='+date+' format='+format);
// Force parameters en chaine
format=format+"";
var result="";
var year=date.getFullYear();
var month=date.getMonth()+1;
var day=date.getDate();
var hour=date.getHours();
var minute=date.getMinutes();
var seconde=date.getSeconds();
var i=0;
while (i < format.length)
{
c=format.charAt(i); // Recupere char du format
var substr = '';
j=i;
while ((format.charAt(j)==c) && (j < format.length)) // Recupere char successif identiques
{
substr += format.charAt(j++);
}
// alert('substr='+substr);
if (substr == 'yyyy') { result=result+year; }
else if (substr == 'yy') { result=result+year.substring(2,4); }
else if (substr == 'M') { result=result+month; }
else if (substr == 'MM') { result=result+(month<1||month>9?"":"0")+month; }
else if (substr == 'd') { result=result+day; }
else if (substr == 'dd') { result=result+(day<1||day>9?"":"0")+day; }
else if (substr == 'hh') { if (hour > 12) hour-=12; result=result+(hour<0||hour>9?"":"0")+hour; }
else if (substr == 'HH') { result=result+(hour<0||hour>9?"":"0")+hour; }
else if (substr == 'mm') { result=result+(minute<0||minute>9?"":"0")+minute; }
else if (substr == 'ss') { result=result+(seconde<0||seconde>9?"":"0")+seconde; }
else { result=result+substr; }
i+=substr.length;
}
// alert(result);
return result;
}
/*
* =================================================================
* Function: getDateFromFormat(date_string, format_string)
* Purpose: This function takes a date string and a format string.
* It parses the date string with format and it
* returns the date as a javascript Date() object. If date does not match
* format, it returns 0. The format string can use the following tags:
* Field | Tags
* -------------+-----------------------------------
* Year | yyyy (4 digits), yy (2 digits)
* Month | MM (2 digits)
* Day of Month | dd (2 digits)
* Hour (1-12) | hh (2 digits)
* Hour (0-23) | HH (2 digits)
* Minute | mm (2 digits)
* Second | ss (2 digits)
* Author: Laurent Destailleur
* Licence: GPL
* ==================================================================
*/
function getDateFromFormat(val, format)
{
// alert('getDateFromFormat val='+val+' format='+format);
// Force parameters en chaine
val = val+"";
format = format+"";
if (val == '') return 0;
var now=new Date();
var year=now.getFullYear();
var month=now.getMonth()+1;
var day=now.getDate();
var hour=now.getHours();
var minute=now.getMinutes();
var seconde=now.getSeconds();
var i=0;
var d=0; // -d- follows the date string while -i- follows the format string
while (i < format.length)
{
c=format.charAt(i); // Recupere char du format
substr="";
j=i;
while ((format.charAt(j)==c) && (j < format.length)) // Get successive similar characters
{
substr += format.charAt(j++);
}
// alert('substr='+substr);
if (substr == "yyyy") year=getIntegerInString(val,d,4,4);
if (substr == "yy") year=""+(getIntegerInString(val,d,2,2)-0+2000); /* #28334 */
if (substr == "MM" ||substr == "M")
{
month=getIntegerInString(val,d,1,2);
if (month) d -= 2- month.length;
}
if (substr == "dd")
{
day=getIntegerInString(val,d,1,2);
if (day) d -= 2- day.length;
}
if (substr == "HH" ||substr == "hh" )
{
hour=getIntegerInString(val,d,1,2);
if (dhouray) d -= 2- hour.length;
}
if (substr == "mm"){
minute=getIntegerInString(val,d,1,2);
if (minute) d -= 2- minute.length;
}
if (substr == "ss")
{
seconde=getIntegerInString(val,d,1,2);
if (seconde) d -= 2- seconde.length;
}
i+=substr.length;
d+=substr.length;
}
// Check if format param are ok
if (year==null||year<1) { return 0; }
if (month==null||(month<1)||(month>12)) { return 0; }
if (day==null||(day<1)||(day>31)) { return 0; }
if (hour==null||(hour<0)||(hour>24)) { return 0; }
if (minute==null||(minute<0)||(minute>60)) { return 0; }
if (seconde==null||(seconde<0)||(seconde>60)) { return 0; }
// alert(year+' '+month+' '+day+' '+hour+' '+minute+' '+seconde);
return new Date(year,month-1,day,hour,minute,seconde);
}
/*
* =================================================================
* Function: stringIsInteger(string)
* Purpose: Return true if string is an integer
* ==================================================================
*/
function stringIsInteger(str)
{
var digits="1234567890";
for (var i=0; i < str.length; i++)
{
if (digits.indexOf(str.charAt(i))==-1)
{
return false;
}
}
return true;
}
/*
* =================================================================
* Function: getIntegerInString(string,pos,minlength,maxlength)
* Purpose: Return part of string from position i that is integer
* ==================================================================
*/
function getIntegerInString(str,i,minlength,maxlength)
{
for (var x=maxlength; x>=minlength; x--)
{
var substr=str.substring(i,i+x);
if (substr.length < minlength) { return null; }
if (stringIsInteger(substr)) { return substr; }
}
return null;
}
/*
* =================================================================
* Purpose: Clean string to have it url encoded
* Input: s
* Author: Laurent Destailleur
* Licence: GPL
* ==================================================================
*/
function urlencode(s) {
var news = s;
news = news.replace(/\+/gi,'%2B');
news = news.replace(/&/gi,'%26');
return news;
}
/*
* =================================================================
* Purpose: Clean string to get a HTML coded string.
* Input: s
* Author: Laurent Destailleur
* Licence: GPL
* ==================================================================
*/
function htmlEntityDecodeJs(inp){
var replacements = {'<':'<','>':'>','/':'/','"':'"',''':'\'','&':'&',' ':' '};
if (inp) {
for(var r in replacements){
inp = inp.replace(new RegExp(r,'g'),replacements[r]);
}
return inp.replace(/(\d+);/g, function(match, dec) {
return String.fromCharCode(dec);
});
} else {
return '';
}
}
/*
* =================================================================
* Purpose: Applique un delai avant execution. Used for autocompletion of companies.
* Input: funct, delay
* Author: Regis Houssin
* Licence: GPL
* ==================================================================
*/
function ac_delay(funct,delay) {
// delay before start of action
setTimeout(funct,delay);
}
/*
* =================================================================
* Purpose:
* Clean values of a "Sortable.serialize". Used by drag and drop.
* Input: expr
* Author: Regis Houssin
* Licence: GPL
* ==================================================================
*/
function cleanSerialize(expr) {
if (typeof(expr) != 'string') {
return '';
}
var reg = new RegExp("(&)", "g");
var reg2 = new RegExp("[^A-Z0-9,]", "g");
var liste1 = expr.replace(reg, ",");
return liste1.replace(reg2, "");
}
/*
* Purpose: Fonction to open a confirm popup on a click of a link
* Input: msg
* Input: id
* Input: popupWidth
* Input: popupHeight
* Input: disableCancelButton
* Licence: GPL
* See also document_preview() that also maje a dialogforpopup.dialog().
* See also newpopup that use window.open.
*/
function confirmDolibarr(msg, id, popupWidth = 400, popupHeight = 300, disableCancelButton = 0) {
let alink = document.getElementById(id);
let title = 'Note';
if (alink.getAttribute("data-alreadyclicked") === "1") {
return true;
}
console.log("Call confirmDolibarr disableCancelButton="+disableCancelButton);
let buttons = {};
if (disableCancelButton === 0) {
buttons['Cancel'] = function () {
$(this).dialog("close");
};
}
buttons['Confirm'] = function () {
console.log("We click OK"); $(this).dialog("close"); alink.setAttribute("data-alreadyclicked", "1"); alink.click(); return false;
};
new Promise(res => {
$("#dialogforpopup").text(msg).dialog({
closeOnEscape: true,
resizable: true,
modal: true,
width: popupWidth,
height: popupHeight,
title: title,
buttons: buttons
});
});
return false;
}
/*
* Used by button to set on/off.
* Call url then make complementary action (like show/hide, enable/disable or set another option).
*
* @param string url Url (warning: as any url called in ajax mode, the url called here must not renew the token)
* @param string code Code
* @param string input Array of complementary actions to do if success
* @param int entity Entity
* @param int strict Strict (0=?, 1=?)
* @param int forcereload Force reload
* @param int userid User id
* @param string token Token
* @param int value Value to set
* @param int userconst 1=On/Off of user constant instead of global const
* @return boolean
*/
function setConstant(url, code, input, entity, strict, forcereload, userid, token, value, userconst) {
var saved_url = url; /* avoid undefined url */
$.post( url, {
action: "set",
name: code,
entity: entity,
token: token,
value: value,
userconst: userconst
},
function() { /* handler for success of post */
console.log("Ajax url request to set constant is a success. Make complementary actions and then forcereload="+forcereload+" value="+value);
if (value == 0) {
$("#set_" + code).show();
$("#del_" + code).hide();
} else {
$("#set_" + code).hide();
$("#del_" + code).show();
}
$.each(input, function(type, data) {
// Enable another element
if (type == "disabled" && strict != 1) {
$.each(data, function(key, value) {
var newvalue=((value.search("^#") < 0 && value.search("^\.") < 0) ? "#" : "") + value;
$(newvalue).removeAttr("disabled");
if ($(newvalue).hasClass("butActionRefused") == true) {
$(newvalue).removeClass("butActionRefused");
$(newvalue).addClass("butAction");
}
});
} else if (type == "enabled") {
$.each(data, function(key, value) {
var newvalue=((value.search("^#") < 0 && value.search("^\.") < 0) ? "#" : "") + value;
if (strict == 1)
$(newvalue).removeAttr("disabled");
else
$(newvalue).attr("disabled", true);
if ($(newvalue).hasClass("butAction") == true) {
$(newvalue).removeClass("butAction");
$(newvalue).addClass("butActionRefused");
}
});
// Show another element
} else if (type == "showhide" || type == "show") {
$.each(data, function(key, value) {
var newvalue=((value.search("^#") < 0 && value.search("^\.") < 0) ? "#" : "") + value;
$(newvalue).show();
});
// Set another constant
} else if (type == "set") {
$.each(data, function(key, value) {
$("#set_" + key).hide();
$("#del_" + key).show();
$.post( saved_url, {
action: "set",
name: key,
value: value,
entity: entity,
token: token
});
});
}
});
if (forcereload) {
var url = window.location.href;
/* reset action param */
url = url.replace(/action=\w+/g, '');
/* reset dol_resetcache param */
if (url.indexOf('dol_resetcache') < 0) {
if (url.indexOf('?') > -1) {
url = url + "&dol_resetcache=1";
} else {
url = url + "?dol_resetcache=1";
}
}
/* reset page_y param */
var page_y = $(document).scrollTop();
url = url.replace(/page_y=\d+/g, '');
if (page_y > 0) {
if (url.indexOf('?') > -1) {
url = url + "&page_y="+page_y;
} else {
url = url + "?page_y="+page_y;
}
}
url = url.replace(/&&+/, '&');
console.log("url ro redirect = "+url);
window.location.href = url;
//location.reload();
return false;
}
}).fail(function(error) { console.log("Error, we force reload"); location.reload(); }); /* When it fails, we always force reload to have setEventErrorMessages in session visible */
return true;
}
/*
* Used by button to set on/off
* Call url then make complementary action (like show/hide, enable/disable or set another option).
*
* @param string url Url (warning: as any url called in ajax mode, the url called here must not renew the token)
* @param string code Code
* @param string input Array of complementary actions to do if success
* @param int entity Entity
* @param int strict Strict
* @param int forcereload Force reload
* @param int userid User id
* @param string token Token
* @param int userconst 1=On/Off of user constant instead of global const
* @return boolean
*/
function delConstant(url, code, input, entity, strict, forcereload, userid, token, userconst) {
var saved_url = url; /* avoid undefined url */
$.post( url, {
action: "del",
name: code,
entity: entity,
token: token,
userconst: userconst
},
function() {
console.log("Ajax url request to delete constant is success. Make complementary actions and then forcereload="+forcereload);
$("#del_" + code).hide();
$("#set_" + code).show();
$.each(input, function(type, data) {
// Disable another element
if (type == "disabled") {
$.each(data, function(key, value) {
var newvalue=((value.search("^#") < 0 && value.search("^\.") < 0) ? "#" : "") + value;
$(newvalue).attr("disabled", true);
if ($(newvalue).hasClass("butAction") == true) {
$(newvalue).removeClass("butAction");
$(newvalue).addClass("butActionRefused");
}
});
} else if (type == "enabled" && strict != 1) {
$.each(data, function(key, value) {
var newvalue=((value.search("^#") < 0 && value.search("^\.") < 0) ? "#" : "") + value;
$(newvalue).removeAttr("disabled");
if ($(newvalue).hasClass("butActionRefused") == true) {
$(newvalue).removeClass("butActionRefused");
$(newvalue).addClass("butAction");
}
});
// Hide another element
} else if (type == "showhide" || type == "hide") {
$.each(data, function(key, value) {
var newvalue=((value.search("^#") < 0 && value.search("^\.") < 0) ? "#" : "") + value;
$(newvalue).hide();
});
// Delete another constant
} else if (type == "del") {
$.each(data, function(key, value) {
$("#del_" + value).hide();
$("#set_" + value).show();
$.post( saved_url, {
action: "del",
name: value,
entity: entity,
token: token
});
});
}
});
if (forcereload) {
var url = window.location.href;
if (url.indexOf('dol_resetcache') < 0) {
if (url.indexOf('?') > -1) {
url = url + "&dol_resetcache=1";
} else {
url = url + "?dol_resetcache=1";
}
}
var page_y = $(document).scrollTop();
url = url.replace(/page_y=\d+/g, '');
if (page_y > 0) {
if (url.indexOf('?') > -1) {
url = url + "&page_y="+page_y;
} else {
url = url + "?page_y="+page_y;
}
}
url = url.replace(/&&+/, '&');
console.log("url ro redirect = "+url);
window.location.href = url;
//location.reload();
return false;
}
}).fail(function(error) { console.log("Error, we force reload"); location.reload(); }); /* When it fails, we always force reload to have setEventErrorMessages in session visible */
return true;
}
/*
* Call the setConstant or delConstant but with a confirmation before.
* Used by button to set on/off.
*
* @param string action Action
* @param string url Url
* @param string code Code
* @param string input Array of complementary actions to do if success
* @param string box Box
* @param int entity Entity
* @param int yesButton yesButton
* @param int noButton noButton
* @param int strict Strict
* @param int userid User id
* @param string token Token
* @return boolean
*/
function confirmConstantAction(action, url, code, input, box, entity, yesButton, noButton, strict, userid, token) {
var boxConfirm = box;
$("#confirm_" + code)
.attr("title", boxConfirm.title)
.html(boxConfirm.content)
.dialog({
resizable: false,
height: 170,
width: 500,
modal: true,
buttons: [
{
id : 'yesButton_' + code,
text : yesButton,
click : function() {
if (action == "set") {
setConstant(url, code, input, entity, strict, 0, userid, token, 1);
} else if (action == "del") {
delConstant(url, code, input, entity, strict, 0, userid, token);
}
// Close dialog
$(this).dialog("close");
// Execute another method
if (boxConfirm.method) {
var fnName = boxConfirm.method;
if (window.hasOwnProperty(fnName)) {
window[fnName]();
}
}
}
},
{
id : 'noButton_' + code,
text : noButton,
click : function() {
$(this).dialog("close");
}
}
]
});
// For information dialog box only, hide the noButton
if (boxConfirm.info) {
$("#noButton_" + code).button().hide();
}
return true;
}
/*
* =================================================================
* This is to allow to transform all select box into ajax autocomplete box
* with just one line:
* $(function() { $( "#idofmylist" ).combobox(); });
* Do not use it on large combo boxes
* =================================================================
*/
(function( $ ) {
$.widget( "ui.combobox", {
options: {
minLengthToAutocomplete: 0
},
_create: function() {
var savMinLengthToAutocomplete = this.options.minLengthToAutocomplete;
var self = this,
select = this.element.hide(),
selected = select.children( ":selected" ),
value = selected.val() ? selected.text() : "";
var input = this.input = $( "" )
.insertAfter( select )
.val( value )
.attr('id', 'inputautocomplete'+select.attr('id'))
.autocomplete({
delay: 0,
minLength: this.options.minLengthToAutocomplete,
source: function( request, response ) {
var matcher = new RegExp( $.ui.autocomplete.escapeRegex(request.term), "i" );
response( select.children( "option:enabled" ).map(function() {
var text = $( this ).text();
if ( this.value && ( !request.term || matcher.test(text) ) )
return {
label: text.replace(
new RegExp(
"(?![^&;]+;)(?!<[^<>]*)(" +
$.ui.autocomplete.escapeRegex(request.term) +
")(?![^<>]*>)(?![^&;]+;)", "gi"
), "$1" ),
value: text,
option: this
};
}) );
},
select: function( event, ui ) {
ui.item.option.selected = true;
self._trigger( "selected", event, {
item: ui.item.option
});
},
change: function( event, ui ) {
if ( !ui.item ) {
var matcher = new RegExp( "^" + $.ui.autocomplete.escapeRegex( $(this).val() ) + "$", "i" ),
valid = false;
select.children( "option" ).each(function() {
if ( $( this ).text().match( matcher ) ) {
this.selected = valid = true;
return false;
}
});
if ( !valid ) {
// remove invalid value, as it didn't match anything
$( this ).val( "" );
select.val( "" );
input.data("ui-autocomplete").term = "";
return false;
}
}
}
})
.addClass( "ui-widget ui-widget-content ui-corner-left dolibarrcombobox" );
input.data("ui-autocomplete")._renderItem = function( ul, item ) {
return $("
")
.data( "ui-autocomplete-item", item ) // jQuery UI > 1.10.0
.append( "" + item.label + "" )
.appendTo( ul );
};
this.button = $( "" )
.attr( "tabIndex", -1 )
.attr( "title", "Show All Items" )
.insertAfter( input )
.button({
icons: {
primary: "ui-icon-triangle-1-s"
},
text: false
})
.removeClass( "ui-corner-all" )
.addClass( "ui-corner-right ui-button-icon" )
.click(function() {
// close if already visible
if ( input.autocomplete( "widget" ).is( ":visible" ) ) {
input.autocomplete( "close" );
return;
}
// pass empty string as value to search for, displaying all results
input.autocomplete({ minLength: 0 });
input.autocomplete( "search", "" );
input.autocomplete({ minLength: savMinLengthToAutocomplete });
input.focus();
});
},
destroy: function() {
this.input.remove();
this.button.remove();
this.element.show();
$.Widget.prototype.destroy.call( this );
}
});
})( jQuery );
/**
* Function to output a dialog box for copy/paste
*
* @param text Text to put into copy/paste area
* @param text2 Text to put under the copy/paste area
*/
function copyToClipboard(text,text2)
{
text = text.replace(/ /g,"\n");
var newElem = '
'+text2;
/* alert(newElem); */
$("#dialogforpopup").html(newElem);
$("#dialogforpopup").dialog();
$("#coordsforpopup").select();
return false;
}
/**
* Show a popup HTML page. Use the "window.open" function.
*
* @param url Url
* @param title Title of popup
* @return boolean False
* @see document_preview() and confirmDolibarr()
*/
function newpopup(url, title) {
var argv = newpopup.arguments;
var argc = newpopup.arguments.length;
var tmp = url;
console.log("newpopup "+argv[2]+" "+argv[3]);
var l = (argc > 2) ? argv[2] : 600;
var h = (argc > 3) ? argv[3] : 400;
var left = (screen.width - l)/2;
var top = (screen.height - h)/2;
var wfeatures = "directories=0,menubar=0,status=0,resizable=0,scrollbars=1,toolbar=0,location=0,width=" + l +",height=" + h + ",left=" + left + ",top=" + top;
fen = window.open(tmp, title, wfeatures);
return false;
}
/**
* Function to show a document preview popup. It uses the "dialog" function.
* The "a" tag around the "img" must have the src='', class='documentpreview', mime='image/xxx', target='_blank' from getAdvancedPreviewUrl().
*
* @param file Url
* @param type Mime file type ("image/jpeg", "application/pdf", "text/html")
* @param title Title of popup
* @return void
* @see also confirmDolibarr() that also make a dialogforpopup.dialog()
* @see also newpopup()that use window.open
*/
function document_preview(file, type, title)
{
var ValidImageTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"];
var showOriginalSizeButton = false;
console.log("document_preview A click was done: file="+file+", type="+type+", title="+title);
if ($.inArray(type, ValidImageTypes) < 0) {
/* Not an image */
var object_width = '100%';
var object_height = '98%';
var popupWidth = '85%';
var popupHeight = $( window ).height() * 0.90 - 100;
show_preview('notimage');
} else {
/* This is an image */
var object_width = 0;
var object_height = 0;
var img = new Image();
img.src = file;
img.onload = function() {
object_width = this.width; /* the real width of image */
object_height = this.height; /* the real height of image */
/* Complete title with size of image */
title = title + ' (' + object_width + ' x ' + object_height + ')';
popupWidth = $( window ).width() * 0.85 - 50;
console.log("object_width="+object_width+" popup window width="+popupWidth);
if (object_width < popupWidth) {
console.log("Object width is small, we set width of popup according to image width.");
popupWidth = object_width + 50
}
if (popupWidth < 250) { /* Set a minimal width because we need to have neough space for the buttons */
popupWidth = 250;
}
popupHeight = $( window ).height() * 0.90 - 160;
console.log("object_height="+object_height+" popup window height="+popupHeight);
if (object_height < (popupHeight - 160)) {
console.log("Object height is small, we set height of popup according to image height.");
popupHeight = object_height + 160
} else {
showOriginalSizeButton = true;
}
console.log("popupWidth="+popupWidth+" popupHeight="+popupHeight);
show_preview('image');
};
}
/* This function is local to document_preview. Variables like file, type, title, object_width and object_height are global inside this function */
function show_preview(mode) {
/* console.log("mode="+mode+" file="+file+" type="+type+" title=title+" width="+popupWidth+" height="+popupHeight); */
var newElem = '';
optionsbuttons = {}
if (mode == 'image') {
var curRot = 0;
var savMaxHeight = 0;
optionsbuttons = {
'Rotate 90°': function() { curRot += 90; jQuery(".ui-dialog-content.ui-widget-content > object").css("transform","rotate(" + curRot + "deg)"); },
'Close': function() { $( this ).dialog( "close" ); }
};
if (showOriginalSizeButton) {
optionsbuttons = {
'Original size': function() { console.log("Click on original size button"); savMaxHeight = jQuery(".ui-dialog-content.ui-widget-content > object").css("max-height"); console.log("savMaxHeight="+savMaxHeight); jQuery(".ui-dialog-content.ui-widget-content > object").css({ "max-height": (savMaxHeight == "none" ? "100%" : "none") }); },
...optionsbuttons
};
}
}
$("#dialogforpopup").addClass("center");
$("#dialogforpopup").html(newElem);
$("#dialogforpopup").dialog({
closeOnEscape: true,
resizable: true,
width: popupWidth,
height: popupHeight,
modal: true,
title: title,
buttons: optionsbuttons
});
if (showOriginalSizeButton) {
jQuery(".ui-dialog-content.ui-widget-content > object").css({ "max-height": "100%", "width": "auto", "margin-left": "auto", "margin-right": "auto", "display": "block" });
}
}
}
/*
* Provide a function to get an URL GET parameter in javascript
*
* @param name Name of parameter
* @param valueifnotfound Value if not found
* @return string Value
*/
function getParameterByName(name, valueifnotfound)
{
name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
var regex = new RegExp("[\\?&]" + name + "=([^]*)"),
results = regex.exec(location.search);
return results === null ? valueifnotfound : decodeURIComponent(results[1].replace(/\+/g, " "));
}
/**
* Get the list of possible operators for a given field type that we can use in the generic filter.
*/
function getOperatorsForFieldType(type, maybenull = 0) {
console.log('Get list of operators for type='+type);
// Define the list of operators for each general field category
const operatorList = {
selectlink: {
Is: 'Is',
IsNot: 'Is not',
},
text: {
Contains: 'Contains',
DoesNotContain: 'Does not contain',
Is: 'Is',
IsNot: 'Is not',
StartsWith: 'Starts with',
EndsWith: 'Ends with'
},
number: {
'=': 'Is',
'!=': 'Is not',
'<': 'Is lower than',
'>': 'Is higher than',
'<=': 'Is lower than or equal',
'>=': 'Is higher than or equal',
},
date: {
Is: 'Is',
IsNot: 'Is not',
IsBefore: 'Is before',
IsAfter: 'Is after',
IsOnOrBefore: 'Is before or equal',
IsOnOrAfter: 'Is after or equal'
},
html: {
Contains: 'Contains'
}
};
// Determine the general category for the given type using regex
let generalType = "";
if (/^select$/i.test(type) || /^link$/i.test(type)) {
generalType = "selectlink";
} else if (/^(varchar|char|text|blob|nchar|mediumtext|longtext)\(\d+\)$/i.test(type) || /^(varchar|mail|phone|ip)$/i.test(type)) {
generalType = "text";
} else if (/^(int|integer|float|double|decimal|numeric)(\(\d+,\d+\))?$/i.test(type)) {
generalType = "number";
} else if (/^(date|datetime|timestamp)$/i.test(type)) {
generalType = "date";
} else if (/^(tinyint|smallint)\(\d+\)$/i.test(type)) {
generalType = "number";
} else if (/^html$/i.test(type)) {
generalType = "html";
} else {
// Handle unknown or unsupported types
console.log("The type of field "+type+" is not supported");
return [];
}
// If maybenull is true, then append the "IsDefined" and "IsNotDefined" operators
if (maybenull === 1) {
operatorList[generalType]["IsDefined"] = 'Is defined';
operatorList[generalType]["IsNotDefined"] = 'Is not defined';
}
// Return the operators for the general type, or an empty array if not found
return operatorList[generalType] || [];
}
/**
* Generate a filter string based on the given column, operator, context and field type
*/
function generateFilterString(column, operator, context, fieldType) {
let filter = "";
console.log("generateFilterString column="+column+" operator="+operator+" context="+context+" fieldType="+fieldType);
switch (operator) {
case "Contains":
filter = column + " like \'%" + context + "%\'";
break;
case "DoesNotContain":
filter = column + " notlike \'%" + context + "%\'";
break;
case "Is":
filter = column + " = \'" + context + "\'";
break;
case "IsNot":
filter = column + " != \'" + context + "\'";
break;
case "StartsWith":
filter = column + " like \'" + context + "%\'";
break;
case "EndsWith":
filter = column + " like \'%" + context + "\'";
break;
case "IsDefined":
filter = column + ":isnot:null";
break;
case "IsNotDefined":
filter = column + ":is:null";
break;
case "=":
filter = column + " = \'" + context + "\'";
break;
case "!=":
filter = column + " != \'" + context + "\'";
break;
case "<":
filter = column + " < \'" + context + "\'";
break;
case ">":
filter = column + " > \'" + context + "\'";
break;
case "<=":
filter = column + " <= \'" + context + "\'";
break;
case ">=":
filter = column + " >= \'" + context + "\'";
break;
case "IsBefore":
filter = column + " < \'" + context + "\'";
break;
case "IsAfter":
filter = column + " > \'" + context + "\'";
break;
case "IsOnOrBefore":
filter = column + " <= \'" + context + "\'";
break;
case "IsOnOrAfter":
filter = column + " >= \'" + context + "\'";
break;
default:
filter = "";
}
return filter;
}
// Code in the public domain from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round
(function() {
/**
* Decimal adjustment of a number.
*
* @param {String} type The type of adjustment.
* @param {Number} value The number.
* @param {Integer} exp The exponent (the 10 logarithm of the adjustment base).
* @returns {Number} The adjusted value.
*/
function decimalAdjust(type, value, exp) {
// If the exp is undefined or zero...
if (typeof exp === 'undefined' || +exp === 0) {
return Math[type](value);
}
value = +value;
exp = +exp;
// If the value is not a number or the exp is not an integer...
if (isNaN(value) || !(typeof exp === 'number' && exp % 1 === 0)) {
return NaN;
}
// Shift
value = value.toString().split('e');
value = Math[type](+(value[0] + 'e' + (value[1] ? (+value[1] - exp) : -exp)));
// Shift back
value = value.toString().split('e');
return +(value[0] + 'e' + (value[1] ? (+value[1] + exp) : exp));
}
// Decimal round
if (!Math.round10) {
Math.round10 = function(value, exp) {
return decimalAdjust('round', value, exp);
};
}
// Decimal floor
if (!Math.floor10) {
Math.floor10 = function(value, exp) {
return decimalAdjust('floor', value, exp);
};
}
// Decimal ceil
if (!Math.ceil10) {
Math.ceil10 = function(value, exp) {
return decimalAdjust('ceil', value, exp);
};
}
})();
// Another solution, easier, to build a javascript rounding function
function dolroundjs(number, decimals) { return +(Math.round(number + "e+" + decimals) + "e-" + decimals); }
/**
* Function similar to PHP price()
*
* Example use:
* pricejs(13312.448, 'MT', 'EUR', 'fr_FR')
* // (depending on conf for 'MT'): '13 312.45 €'
*
* pricejs(343000.121, 'MT')
* // assuming conf for 'MT' is 2 and $langs->defaultlang is 'en_US': '343,000.12'
*
* @param {number|string} amount The amount to show
* @param {string} mode 'MT' or 'MU'
* @param {string} currency_code ISO code of currency (empty by default)
* @param {string} force_locale ISO code locale to use (if empty, will use Dolibarr's current locale code)
* @return {string} The amount with digits
*
*/
function pricejs(amount, mode = 'MT', currency_code = '', force_locale = '') {
var main_max_dec_shown = 8;
var main_rounding_unit = 5;
var main_rounding_tot = 2;
var main_decimal_separator = ".";
var main_thousand_separator = ",";
var locale_code = force_locale || "en_GB";
var amountAsLocalizedString;
var useIntl = Boolean(Intl && Intl.NumberFormat);
var nDigits;
if (currency_code === 'auto') currency_code = "GBP";
if (mode === 'MU') nDigits = main_rounding_unit;
else if (mode === 'MT') nDigits = main_rounding_tot;
else return 'Bad value for parameter mode';
if (useIntl) {
// simple version: let the browser decide how to format the number using the provided language / currency
// parameters
var formattingOptions = {
minimumFractionDigits: nDigits,
maximumFractionDigits: nDigits
};
if (currency_code) {
formattingOptions['style'] = 'currency';
formattingOptions['currency'] = currency_code;
}
return Intl.NumberFormat(locale_code.replace('_', '-'), formattingOptions).format(amount);
}
// No Intl -> attempt to format the number in a way similar to Dolibarr PHP's `price()` function
amountAsLocalizedString = amount.toFixed(nDigits).replace(
/((?!^)(?:\d{3})*)(?:\.(\d+))?$/,
(fullMatch, digitsByThree, decimals) =>
digitsByThree.replace(
/\d{3}/g,
(groupOfThree) => main_thousand_separator + groupOfThree
) + (decimals !== undefined ? main_decimal_separator + decimals : '')
).replace(/ /, ' ');
if (!currency_code) return amountAsLocalizedString;
// print with currency
var currency_symbol = currency_code;
// codes of languages / currencies where the symbol must be placed before the amount
var currencyBeforeAmountCodes = {
currency: ['AUD', 'CAD', 'CNY', 'COP', 'CLP', 'GBP', 'HKD', 'MXN', 'PEN', 'USD'],
language: ['nl_NL']
};
if (currencyCache[currency_code]
&& currencyCache[currency_code]['unicode']
&& currencyCache[currency_code]['unicode'].length) {
currency_symbol = currencyCache[currency_code]['unicode'].reduce(function (res, cur) {return res + cur}, '');
}
if (currencyBeforeAmountCodes.currency.indexOf(currency_code) >= 0
|| currencyBeforeAmountCodes.language.indexOf(locale_code)) {
// if we use a language or a currency where the symbol is placed before the amount
return currency_symbol + amountAsLocalizedString;
}
// by default: currency symbol after the amount
return amountAsLocalizedString + ' ' + currency_symbol;
}
/**
* Function similar to PHP price2num()
*
* @param {number|string} amount The amount to convert/clean
* @return {number} The amount in universal numeric format (Example: '99.99999')
* @todo Implement rounding parameter
*/
function price2numjs(amount) {
if (amount == '') return '';
if (amount == null) return ''; /* null or undefined */
var dec = ".";
var thousand = ",";
var main_max_dec_shown = 8;
var main_rounding_unit = 5;
var main_rounding_tot = 2;
var amount = amount.toString();
// rounding for unit price
var rounding = main_rounding_unit;
var pos = amount.indexOf(dec);
var decpart = '';
if (pos >= 0) {
decpart = amount.substring(pos + 1).replace('/0+$/i', ''); // Remove 0 for decimal part
}
var nbdec = decpart.length;
if (nbdec > rounding) {
rounding = nbdec;
}
// If rounding higher than max shown
if (rounding > main_max_dec_shown) rounding = main_max_dec_shown;
if (thousand != ',' && thousand != '.') amount = amount.replace(',', '.');
amount = amount.replace(' ', ''); // To avoid spaces
amount = amount.replace(thousand, ''); // Replace of thousand before replace of dec to avoid pb if thousand is .
amount = amount.replace(dec, '.');
//console.log("amount before="+amount+" rounding="+rounding)
var res = Math.round10(amount, - rounding);
// Other solution is
// var res = dolroundjs(amount, rounding)
console.log("price2numjs text="+amount+" return="+res);
return res;
}
// Defined properties for JNotify
$(document).ready(function() {
if (typeof $.jnotify == 'function') {
$.jnotify.setup({
delay: 3000 // the default time to show each notification (in milliseconds)
, sticky: false // determines if the message should be considered "sticky" (user must manually close notification)
, closeLabel: "×" // the HTML to use for the "Close" link
, showClose: true // determines if the "Close" link should be shown if notification is also sticky
, fadeSpeed: 1000 // the speed to fade messages out (in milliseconds)
, slideSpeed: 250 // the speed used to slide messages out (in milliseconds)
, classContainer: "jnotify-container"
, classNotification: "jnotify-notification"
, classBackground: "jnotify-background"
, classClose: "jnotify-close"
, classMessage: "jnotify-message"
, init: null // callback that occurs when the main jnotify container is created
, create: null // callback that occurs when when the note is created (occurs just before appearing in DOM)
, beforeRemove: null // callback that occurs when before the notification starts to fade away
});
}
});
jQuery(document).ready(function() {
// Force to hide menus when page is inside an iFrame so we can show any page into a dialog popup
if (window.location && window.location.pathname.indexOf("core/frames.php") == -1 && window.location.pathname.indexOf("externalsite/frametop.php") == -1 && window.location !== window.parent.location ) {
console.log("Page is detected to be into an iframe, we hide by CSS the menus");
// The page is in an iframe
jQuery(".side-nav-vert, .side-nav, .websitebar").hide();
jQuery(".id-container").css('width', '100%');
}
// Code to set tooltip on search field
jQuery('table.liste tr.liste_titre_filter td.liste_titre input[name^="search"][type=text]:not(".maxwidthdate")').attr('title', 'For searching inside text fields, you can use the characters ^ or $ to make a \'start or end with\' search or use the ! to make a \'does not contain\' test. You can use the | between two strings instead of a space for a \'OR\' condition instead of \'AND\'. For numeric values, you can use the operator <, >, <=, >= or != before the value, to filter using a mathematical comparison');
// Code to toggle dropdown components
jQuery(document).on("click", ".butAction.dropdown-toggle", function(event) {
console.log("Click on .butAction.dropdown-toggle");
let parentHolder = jQuery(event.target).parent();
let dropDownContent = parentHolder.children(".dropdown-content");
let offset = parentHolder.offset();
let widthDocument = $(document).width();
let heightDocument = $(document).height();
let right = widthDocument - offset.left - parentHolder.width();
let widthPopup = parentHolder.children(".dropdown-content").width();
if (widthPopup + right >= widthDocument) {
//right = 10;
}
parentHolder.toggleClass("open"); /* If open, it closes, if closed, it opens */
// Check tooltip is in viewport
let dropDownContentTop = dropDownContent.offset().top;
let dropDownContentLeft = dropDownContent.offset().left;
let dropDownContentHeight = dropDownContent.outerHeight();
let dropDownContentBottom = dropDownContentTop + dropDownContentHeight;
let viewportBottom = $(window).scrollTop() + $(window).height();
// Change dropdown Up/Down orientation if dropdown is close to bottom viewport
if (parentHolder.hasClass('open')
&& dropDownContentBottom > viewportBottom // Check bottom of dropdown is behind viewport
&& dropDownContentTop - dropDownContentHeight > 0 // check if set dropdown to --up will not go over the top of document
) {
parentHolder.addClass("--up");
} else {
parentHolder.removeClass("--up");
}
// Change dropdown left/right offset if dropdown is close to left viewport
if (parentHolder.hasClass('open') && dropDownContentLeft < 0) {
parentHolder.addClass("--left");
} else {
parentHolder.removeClass("--left");
}
});
// Close drop down
jQuery(document).on("click", function(event) {
// search if click was outside drop down
if (!$(event.target).closest('.butAction.dropdown-toggle').length) {
/* console.log("click close butAction - we click outside"); */
let parentholder = jQuery(".butAction.dropdown-toggle").closest(".dropdown.open");
if (parentholder){
// Hide the menus.
parentholder.removeClass("open --up --left");
}
}
});
});
// Code to manage the js for combo list with dependencies (called by extrafields_view.tpl.php)
function showOptions(child_list, parent_list) {
var parentInput = $("select[name="+parent_list+"]");
if (parentInput.length === 0) { // when parent extra-field is in view mode and the child is edited directly on card (on line edit)
parentInput = $("input[name="+parent_list+"]");
}
if (parentInput.length > 0) {
var val = parentInput.val();
var parentVal = parent_list + ":" + val;
if (val > 0) {
$("select[name=\""+child_list+"\"] option[parent]").prop("disabled", true).hide(); // hide not work with select2 element so disabled it
$("select[name=\""+child_list+"\"] option[parent=\""+parentVal+"\"]").prop('disabled', false).show(); // show not work with select2 element so enabled it
} else {
$("select[name=\""+child_list+"\"] option").prop("disabled", false).show(); // show not work with select2 element so enabled it
}
}
}
function setListDependencies() {
console.log("setListDependencies");
jQuery("select option[parent]").parent().each(function() {
var child_list = $(this).attr("name");
var parent = $(this).find("option[parent]:first").attr("parent");
var infos = parent.split(":");
var parent_list = infos[0];
showOptions(child_list, parent_list);
/* Activate the handler to call showOptions on each future change */
$("select[name=\""+parent_list+"\"]").change(function() {
showOptions(child_list, parent_list);
});
});
}
/**
* Hacky fix for a bug in select2 with jQuery 3.6.4's new nested-focus "protection"
* This fix the need to click a second time when clicking into a combo with ajax (see Test4d and Test5a in test_forms.php
* see: https://github.com/select2/select2/issues/5993
* see: https://github.com/jquery/jquery/issues/4382
*
* TODO: Recheck with the select2 GH issue and remove once this is fixed on their side
*/
$(document).on('select2:open', (e) => {
console.log("Execute the focus (click on combo or use space when on component)");
const target = $(e.target);
if (target && target.length) {
let id = target[0].id || target[0].name;
if (id.substr(-2) == '[]') {
id = id.substr(0,id.length-2);
}
document.querySelector('input[aria-controls*='+id+']').focus();
}
});
/**
* Code to manage drag and drop inside kanban group by view - handles sortable columns and item movement between status columns
*/
$(document).ready(function() {
if ($('.kanban .column').length > 0) {
$('.kanban .column').sortable({
items: '.kanban-draggable',
connectWith: '.kanban .column',
cursor: 'move',
opacity: 0.8,
tolerance: 'pointer',
start: function(_, ui) {
ui.item.data('original-column', ui.item.parent());
ui.placeholder.height(ui.item.outerHeight());
},
receive: function(_, ui) {
var originalColumn = ui.item.data('original-column');
var newColumn = $(this);
if (!originalColumn.is(newColumn)) {
onKanbanColumnChange(ui.item, newColumn);
}
}
});
}
});
/**
* Function called when an item is moved to a different column
*
* @param {jQuery} item - The dragged item
* @param {jQuery} newColumn - The new column
*/
function onKanbanColumnChange(item, newColumn) {
console.log("Call onKanbanColumnChange");
jQuery.ajax({
method: 'POST',
url: '/core/ajax/saveinplace.php',
data: {
field: 'editval_'+newColumn.data('groupbyfield'),
element: item.data('element'),
table_element: item.data('tableelement'),
fk_element: item.data('itemid'),
value: newColumn.data('groupbyid'),
token: '83685229606aedd3a8ddd252ded2bbf7'
},
context: document.body,
success: function() {
if (newColumn.hasClass('kanbancollapsed')) {
item.hide();
}
}
});
item.data('original-column', newColumn);
}
if (typeof jQuery.fn.on === 'function') {
/*
* Intuitive table selection (with keyboard selection)
*/
$(function() {
/**
* @param {jQuery} el
* @param {Integer} status
*/
let setLastClickedRowStatus = function (el, status = 1){
$('.row-with-select').attr('data-is-last-changed', 0);
el.attr('data-is-last-changed', status === 0 ? 0 : 1);
}
/**
* Remove data-is-last-changed on double click
* Because if data-is-last-changed is present the user can't select text
*/
$(document).on("dblclick", ".row-with-select", function(e) {
$('.row-with-select[data-is-last-changed]').removeAttr( 'data-is-last-changed' );
});
/**
* DISABLE on click a and button
* Because Ctrl + Click on link is also used for open ion a new tab
* we need to block select tool
*/
$(document).on("click", ".row-with-select a, .row-with-select button", function (e) {
// we need to block select tool
if (e.ctrlKey) {
e.stopPropagation();
}
});
$(document).on("mousedown click", ".row-with-select input.checkforselect", function (e) {
// Prevents automatic change of “checked”
e.preventDefault();
e.stopPropagation(); // parent click trigger will be done below
let parentRow = $(this).closest(".row-with-select");
// this part of code prevent weird behavior when user (ctrl or maj) + click directly on checkbox
// We simulate a click on the parent line
console.log("Emulate click on parent line");
parentRow.trigger({
type: "click",
ctrlKey: !e.shiftKey, // simulate ctrlKey click will automatically prop activate the checkbox with parent event but not if shift key is pressed.
metaKey: !e.shiftKey, // simulate metaKey click will automatically prop activate the checkbox with parent event but not if shift key is pressed.
shiftKey: e.shiftKey,
originalEvent: e
});
});
$(document).on("click", ".row-with-select", function (e) {
console.log("A click on line was done");
let checkBox = $(this).find('.checkforselect');
let nextCheckStatus = !checkBox.is(':checked')
if (e.ctrlKey || e.metaKey) {
// Add line to selection
if (checkBox) {
checkBox.prop('checked', nextCheckStatus).trigger('change');
}
setLastClickedRowStatus($(this), 1);
}
if (e.shiftKey) {
let lastLastChanged = $(this).closest('table').find('.row-with-select[data-is-last-changed="1"]');
if (lastLastChanged.length>0) {
// Add all lines to selection beetwin last selected line
if ($(this).index() === lastLastChanged.index()) {
return null;
}
if ($(this).index() < lastLastChanged.index()) {
$(this).nextUntil(lastLastChanged, ".row-with-select" ).find('.checkforselect').prop('checked', nextCheckStatus).trigger('change');
}else{
lastLastChanged.nextUntil($(this), ".row-with-select" ).find('.checkforselect').prop('checked', nextCheckStatus).trigger('change');
}
lastLastChanged.find('.checkforselect').prop('checked', nextCheckStatus).trigger('change');
checkBox.prop('checked', nextCheckStatus).trigger('change');
setLastClickedRowStatus($(this), 1);
}
}
});
});
}
// End of lib_head.js.php