Guía 82 – PivotGrid y edición de sus datos.

Como sabéis, los usuarios habituales de este blog, ya había publicado un artículo sobre la solución de PivotGrid utilizando la biblioteca de DevExtreme.

En esta ocasión, este ejemplo, tiene las siguientes características que le diferencia del anterior:

  • El modelo de datos en el que se basa es mucho más simple, para que la compresión sea más fácil.
  • Utiliza un Api Rest para acceder a los datos. Que es la forma estándar que se utiliza con todos los interfaces de JavaScript (React, Angular, Vue.js, etc.)
  • Nos permite, desde la propia visualización Pivot, actualizar los registros base.

La petición me ha hecho un desarrollador para poder implementar un sistema de control de tesorería o “CashFlow” y puede emplearse para muchos otros detalles como para la evaluación de presupuestos de empresa, etc.

Objetivo

Definir un PivotGrid, muy potente en su definición y representación y poder actualizar la información base desde el mismo interface gráfico.

DEMO:  https://fhumanes.com/devextreme_php/

Solución Técnica

Como he indicado, la solución de representación de la información se ha utilizado la biblioteca de DevExtreme, la versión de JQuery.

Para la definición del ApiRest para servir los datos y para las actualizaciones he utilizado el framework Slim 4, que para muchos desarrolladores de PHP es la mejor solución para el desarrollo de este tipo de Api. Partiendo de estos ejemplos, creo que desarrollar otros es bastante sencillo, siempre que se cuente con un mínimo de conocimiento de PHP. Es un framework muy poco “pesado” y está incluido en la parte de “Custom Files” del proyecto de PHPRunner.

En este artículo expliqué el funcionamiento de este framework y las características de este Api.

Veréis que la aplicación he puesto un ejemplo de DataGrid y otro de PivotGrid, he hecho esto porque cuando en el PivotGrid cliqueamos sobre una celda se abre una ventana popup con un DataGrid del conjunto de registros que forman la agrupación sobre la que hemos hecho el click.

Disponiendo de las 2 soluciones podremos ver todas las características que podemos aplicar a la ventana popup.

A modo de ejemplo, explico los ficheros más relevantes del aplicativo.

RESTAPI

Fichero “index.php”

<?php
/* Los headers permiten acceso desde otro dominio (CORS) a nuestro REST API o desde un cliente remoto via HTTP
 * Removiendo las lineas header() limitamos el acceso a nuestro RESTfull API a el mismo dominio
 * Nótese los métodos permitidos en Access-Control-Allow-Methods. Esto nos permite limitar los métodos de consulta a nuestro RESTfull API
 * Mas información: https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS
 **/

$dominioPermitido = "http://localhost:3000";

// header("Access-Control-Allow-Origin: $dominioPermitido"); // Para restringir desde dónde se pueden hacer peticines
// header("Access-Control-Allow-Headers: content-type");

header("Access-Control-Allow-Origin: *");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: PUT, GET, POST, DELETE, OPTIONS');
header("Access-Control-Allow-Headers: X-Requested-With");
header('Content-Type: text/html; charset=utf-8');
header('P3P: CP="IDC DSP COR CURa ADMa OUR IND PHY ONL COM STA"');

include_once '../include/config.php';

require_once("../../include/dbcommon.php"); // DataBase PHPRunner

// DEBUG batch
$debugCode = true;
custom_error(1,"URL ejecutada: ".$_SERVER["REQUEST_URI"]); // To debug

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
// use Selective\BasePath\BasePathMiddleware;
use Slim\Factory\AppFactory;

require_once __DIR__ . '/../libs/autoload.php';  // Biblioteca de SLIM

$app = AppFactory::create();
$app->addRoutingMiddleware();
// $app->add(new BasePathMiddleware($app)); // No usar si se ejecuta en subdirectorio
$app->addErrorMiddleware(true, true, true);
$app->addBodyParsingMiddleware();

$app->setBasePath(SCRIPTS_DIR); // Indica el directorio desde dónde está trabajando

// --------------------------------------------------------------------------------------

/* Usando GET para consultar los orders */

$app->get('/orders/list', function(Request $request, Response $response, array $args) {
    
    $responseBody = array();
    global $errorMessages;
  
    require_once '../include/DBorders.php';
    $orders = new DBorders();

    $respuesta = $orders->list();

    if ($respuesta[0] == true ) {  
        $responseBody["error"] = false;
        $responseBody["message"] = "Videojuegos cargados: " . count($respuesta[2]); 
        $responseBody["orders"] = $respuesta[2];
        // $response->getBody()->write(json_encode($responseBody));
        
        $response->getBody()->write('{"data":'.json_encode($respuesta[2], JSON_NUMERIC_CHECK).'}');
        return $response
            ->withHeader('content-type', 'application/json')
            ->withStatus(200);
    } else {
        $responseBody["error"] = true;
        $responseBody["message_num"] = '999';
        $responseBody["message"] = $respuesta[1];
        $response->getBody()->write(json_encode($responseBody));
        return $response
            ->withHeader('content-type', 'application/json')
            ->withStatus(500);
    }

});
// --------------------------------------------------------------------------------------

/* Usando put para View, Update, Delete de un videojuego */

$app->post('/orders/{action}/{id}', function(Request $request, Response $response, array $args) {
    
    $responseBody = array();
    global $errorMessages;
    
    include_once '../include/function.php';
    
        // check for required params
    $param = array();
    $param['action'] = $args['action'];
    $param['id'] = $args['id'];
    
    custom_error(2,"Action: ". $param['action']." ID: ".$param['id']); // To debug
    
    $verify = verifyRequiredParams(array('action','id'), $param, $request, $response);
    if (is_array($verify)) { // Si es una array, es que hay error
        $response->getBody()->write(json_encode($verify));
        return $response
            ->withHeader('content-type', 'application/json')
            ->withStatus(400);
    }
    // Verificación de los valores de los parámetros || attributes
    if( !in_array($param['action'],array('view','update','delete','add')) || !is_numeric($param['id']))  { // Si la acción es correcta y el id es un número
        $response->getBody()->write(json_encode($verify));
        return $response
        ->withHeader('content-type', 'application/json')
        ->withStatus(400);
    }
    $action = $param['action'];
    $id = $param['id'];

    require_once '../include/DBorders.php';
    $orders = new DBorders();
    
    if ($action == 'view' || $action == 'delete') {
        
        $respuesta = $orders->$action($id); // Ejecutar la operación
    }
    if ($action == 'update' || $action == 'add') {
        
  // check for required params by Forms
        // $param2 = $request->getParsedBody();
        $param2 = file_get_contents('php://input');  // Only JSON
        $param_array = explode("&", $param2);
        foreach ($param_array as &$var) {
            $var_array = explode("=",$var);
            if ($var_array[0] == 'values') {
                $param2 = urldecode($var_array[1]); // El JSON está en el campo "values"
            }
        }
        $param2 = json_decode($param2, true);  // Only JSON
        
        custom_error(4,"Fields: ". print_r($param2,true)); // To debug
        
        // Field SELECT `OrderID`, `CustomerID`, `OrderDate`, `ShippedDate`, `Freight`, 
        //               `ShipCity`, `ShipCountry`, `TotalOrder` FROM devExtre_tabla
        $verify = verifyParams(array('CustomerID','OrderDate','ShippedDate','Freight','ShipCity','ShipCountry','TotalOrder'), $param2, $request, $response);
        if (is_array($verify)) { // Si es una array, es que hay error
            $response->getBody()->write(json_encode($verify));
            return $response
                ->withHeader('content-type', 'application/json')
                ->withStatus(400);
            }
        $param2 = adaptDates(array('OrderDate','ShippedDate'), $param2, $request, $response); // Adapta los campos de fecha
        $respuesta = $orders->$action($id,$param2); // Ejecutar la operación
        }
    
    if ($respuesta[0] == false ) { // Ha existido error
        $responseBody["error"] = true;
        $responseBody["message_num"] = '999';
        $responseBody["message"] = $errorMessages['999'];
        $response->getBody()->write(json_encode($responseBody));
        return $response
            ->withHeader('content-type', 'application/json')
            ->withStatus(500);
    }
    // Todo Ok
    $responseBody["error"] = false;
    $responseBody["message_num"] = '004';
    $responseBody["message"] = $errorMessages['004']; 
    $responseBody["orders"] = $respuesta[2];
    // $response->getBody()->write(json_encode($responseBody));
    
    $response->getBody()->write(json_encode($respuesta[2], JSON_NUMERIC_CHECK));
    return $response
        ->withHeader('content-type', 'application/json')
        ->withStatus(200);

});

/* corremos la aplicación */
$app->run();

En él vemos todos los métodos de acceso:

  • GET – “/orders/list“, para acceder a todos los registros de órdenes de la base de datos.
  • POST – “/orders/{action}/{id}” , Para poder acceder y actualizar un registro de la tabla.
    action = view, update, add, delete

Fichero “DBorders.php”, son los accesos a la base de datos para cada una de las acciones.

<?php
class DBorders
{
    // private $connDB;
    // 
    // Field SELECT `OrderID`, `CustomerID`, `OrderDate`, `ShippedDate`, `Freight`, 
    //               `ShipCity`, `ShipCountry`, `TotalOrder` FROM devExtre_tabla

    function __construct()
    {
        // global $conn; // Connection data base of PHPRunner
        // $this->connDB = $conn;
    }

    public function list()
    {   
        $param = array();
        try {
            $sql = "select * from devExtre_tabla";
            $rs = DB::Query($sql);
            while( $data = $rs->fetchAssoc() )
            {   //`OrderID`, `CustomerID`, `OrderDate`, `ShippedDate`, `Freight`, 
                //`ShipCity`, `ShipCountry`, `TotalOrder`
                $param[] = $data;
                /*
                $param[] = array('OrderID'=>$data['OrderID'], 'CustomerID'=>$data['CustomerID'], 'OrderDate'=>$data['OrderDate'], 'ShippedDate'=>$data['ShippedDate'], 
                           'Freight'=>$data['Freight'],'ShipCity'=>$data['ShipCity'],'ShipCountry'=>$data['ShipCountry'],'TotalOrder'=>$data['TotalOrder']);
                 */
            }
            return array(true,'Ok',$param);  // respuesta: true/false, Mensaje, array resultado
            
        } catch (mysqli_sql_exception $e) {
            $error = $e->getMessage();
            return array(false,$error,$param);  // respuesta: true/false, Mensaje, array resultado
        }
    }
    // Action VIEW
    public function view($id){
        $param = array();
        try {
            $sql = "select * from devExtre_tabla WHERE OrderID = $id ";
            $rs = DB::Query($sql);
            while( $data = $rs->fetchAssoc() )
            {   //`OrderID`, `CustomerID`, `OrderDate`, `ShippedDate`, `Freight`, 
                //`ShipCity`, `ShipCountry`, `TotalOrder`
                $param = $data;
                // $param = array('OrderID'=>$data['OrderID'], 'CustomerID'=>$data['CustomerID'], 'OrderDate'=>$data['OrderDate'], 'ShippedDate'=>$data['ShippedDate'], 
                //           'Freight'=>$data['Freight'],'ShipCity'=>$data['ShipCity'],'ShipCountry'=>$data['ShipCountry'],'TotalOrder'=>$data['TotalOrder']);
            }
            return array(true,'Ok',$param);  // respuesta: true/false, Mensaje, array resultado
            
        } catch (mysqli_sql_exception $e) {
            $error = $e->getMessage();
            return array(false,$error,$param);  // respuesta: true/false, Mensaje, array resultado
        }
    }
    // Action DELETE
    public function delete($id){
        $param = array();
        try {
            $data = array();
            $data["OrderID"] = $id;
            DB::Delete("devExtre_tabla", $data );
            return array(true,'Ok',$param);  // respuesta: true/false, Mensaje, array resultado

        } catch (mysqli_sql_exception $e) {
            $error = $e->getMessage();
            return array(false,$error,$param);  // respuesta: true/false, Mensaje, array resultado
        }
    }
    // Action ADD
    public function add($id,$fields){
            $param = array();
        try {
            DB::Insert("devExtre_tabla", $fields );
            $id = DB::LastId();
            $respuesta = DBorders::view($id);  // Recuperamos "last record"
            return $respuesta;

        } catch (mysqli_sql_exception $e) {
            $error = $e->getMessage();
            return array(false,$error,$param);  // respuesta: true/false, Mensaje, array resultado
        }
    }
    // Action UPDATE
    public function update($id,$fields){
            $param = array();
        try {
            $data = array();
            $keyvalues = array();
            $data= $fields;
            $keyvalues["OrderID"] = $id;
            DB::Update("devExtre_tabla", $data, $keyvalues );
            $respuesta = DBorders::view($id);  // Recuperamos "last record"
            return $respuesta;

        } catch (mysqli_sql_exception $e) {
            $error = $e->getMessage();
            return array(false,$error,$param);  // respuesta: true/false, Mensaje, array resultado
        }
    }       
}

Como veis, aprovecha la conexión a la base de datos de PHPRunner y los métodos de acceso a la base de datos utilizamos los de PHPRunner. Esto nos hace que el aplicativo es independiente al estor de base de datos.

DevExtreme DataGrid

Veréis que en el proyecto existe 2 directorios “caso01” = PivotGrid y “caso03” = DataGrid. En estos directorios hay 3 ficheros “index.html”, “index.js” y “styles.css”. Con estos ficheros se puede ver el funcionamiento externamente a PHPRunner. Lo que hago en PHPRunner es definir una página LIST que sólo tiene el menú  un “Snippet”. En el Snippet lo que hago es leer el fichero “index.html” y así se utiliza para la ejecución en esa página.

Muestro los ficheros del directorio “caso01” que corresponde al PivotGrid.

Fichero “index.html”.

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>DevExtreme Demo</title>
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
    <script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
    <!--  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>  -->

    <script src=" https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js"></script>

    <link rel="stylesheet" type="text/css" href="https://cdn3.devexpress.com/jslib/21.2.5/css/dx.common.css" />
    <!-- DevExtreme theme -->
    <!-- <link rel="stylesheet" type="text/css" href="https://cdn3.devexpress.com/jslib/21.2.5/css/dx.light.compact.css" />  -->
    <link rel="stylesheet" type="text/css" href="https://cdn3.devexpress.com/jslib/21.2.5/css/dx.material.blue.light.css" /> 

    <script src="https://cdn3.devexpress.com/jslib/21.2.5/js/dx.all.js"></script>
    <script src="https://unpkg.com/[email protected]/js/dx.aspnet.data.js"></script>

     <!--Versiones de DevExtreme    21.2..5 y 23.2..6 -->

    <!-- Dictionary files for English and Spanish languages -->   
    <!-- <script src="https://cdn3.devexpress.com/jslib/21.2.5/js/localization/dx.messages.en.js"></script> -->
    <script src="https://cdn3.devexpress.com/jslib/21.2.5/js/localization/dx.messages.es.js"></script>

    <link rel="stylesheet" type="text/css" href="caso01/styles.css" />
    <script src="caso01/index.js"></script>
  </head>
  <body class="dx-viewport">
    <div id="loadPanel"></div>  
    <div class="pivot-container">
        <center><h3>Ordenes de Pedidos</h3></center>
      <div id="pivotgrid-group">
        <div id="pivotgrid-chart"></div>
        <div id="pivotgrid"></div>
        <div id="pivotgrid-popup"></div>
      </div>
    </div>
  </body>
</html>

Fichero “index.js”.

$(() => {
  const URL = 'http://localhost/devextreme_php/restapi/v1';

  /*
   ---------     Para formatear los valores numéricos  -----------------
  */
  function number_format(number, decimals, dec_point, thousands_sep) {
        number = (number + '')
        .replace(/[^0-9+\-Ee.]/g, '');
      var n = !isFinite(+number) ? 0 : +number,
        prec = !isFinite(+decimals) ? 0 : Math.abs(decimals),
        sep = (typeof thousands_sep === 'undefined') ? ',' : thousands_sep,
        dec = (typeof dec_point === 'undefined') ? '.' : dec_point,
        s = '',
        toFixedFix = function(n, prec) {
          var k = Math.pow(10, prec);
          return '' + (Math.round(n * k) / k)
            .toFixed(prec);
        };
      // Fix for IE parseFloat(0.55).toFixed(0) = 0;
      s = (prec ? toFixedFix(n, prec) : '' + Math.round(n))
        .split('.');
      if (s[0].length > 3) {
        s[0] = s[0].replace(/\B(?=(?:\d{3})+(?!\d))/g, sep);
      }
      if ((s[1] || '')
        .length < prec) {
        s[1] = s[1] || '';
        s[1] += new Array(prec - s[1].length + 1)
          .join('0');
      }
      return s.join(dec);
    }

  /*
   ---------     Para las comunicaciones con el servidor PHP  -----------------
  */

    function sendRequest(url, method = 'GET', data) {
      const d = $.Deferred();
  
      $.ajax(url, {
        method,
        data,
        cache: false,
        xhrFields: { withCredentials: true },
      }).then((result) => {
        d.resolve(method === 'GET' ? result.data : result);
      }, (xhr) => {
        d.reject(xhr.responseJSON ? xhr.responseJSON.Message : xhr.statusText);
      });
  
      return d.promise();
    }
  /*
   ---------     Para las controlar las acciones y comunicarlas al server PHP  -----------------
  */

    function saveChange(url, change) {
      const msg = JSON.stringify(change.data);
      const key = change.key;

      switch (change.type) {
        case 'insert':
          return sendRequest(`${url}/orders/add/0`, 'POST', { values: JSON.stringify(change.data) });
        case 'update':
          return sendRequest(`${url}/orders/update/${change.key}`, 'POST', {  key: change.key, values: JSON.stringify(change.data) });
        case 'remove':
          return sendRequest(`${url}/orders/delete/${change.key}`, 'POST', { key: change.key });
        default:
          return null;
      }
    }
  /*
   ---------     Para las volver a cargar los datos de la PivotGrid  -----------------
  */
    function onDataGridSaved(e) {
      let pivotGridDS = pivotGrid.getDataSource();
      pivotGridDS.reload();
  }
   
    const loadPanel = $('#loadPanel').dxLoadPanel({
      position: {
        of: '#pivotgrid',
      },
      visible: false,
    }).dxLoadPanel('instance');
  
    const pivotGrid = $('#pivotgrid').dxPivotGrid({
      allowSorting: true,
      allowSortingBySummary: true,
      allowFiltering: true,
      // height: 620,
      showBorders: true,
      rowHeaderLayout: 'tree',
      scrolling: {
        mode: 'virtual',
      },
      allowExpandAll: true,
      fieldPanel: {visible: true},
      height: '550px',
      width: '100%' ,
      encodeHtml: false,
      
      fieldChooser: {
        enabled: true,
        // allowSearch: true,
        height: 600,
        title: "Selector campos"
      },
      onCellClick(e) {
        if (e.area === 'data') {
          const pivotGridDataSource = e.component.getDataSource();
          const rowPathLength = e.cell.rowPath.length;
          const rowPathName = e.cell.rowPath[rowPathLength - 1];
          const popupTitle = `${rowPathName || 'Total'} Drill Down Data`;
  
          drillDownDataSource = pivotGridDataSource.createDrillDownDataSource(e.cell);
          ordersPopup.option('title', popupTitle);
          ordersPopup.show();
        }
      },

      //`OrderID`, `CustomerID`, `OrderDate`, `ShippedDate`, `Freight`, 
      //`ShipCity`, `ShipCountry`, `TotalOrder`
   
      dataSource: {   
          remoteOperations: false,
          store: DevExpress.data.AspNet.createStore({
            key: 'OrderID',
            loadUrl: `${URL}/orders/list`,
         }),
             
        fields: [{
        dataField: 'OrderID',
        caption: 'ID',
        width: 8,     
      }, {
        dataField: 'CustomerID',
        caption: 'Customer',
        width: 16,
        area: 'row',
      }, {
        dataField: 'ShipCountry',
        caption: 'Country',
        width: 15,
      }, {
        dataField: 'ShipCity',
        caption: 'City',
        width: 15,
      }, {
        dataField: 'ShippedDate',
        caption: 'Ship Date',
        dataType: 'date',       
      },
      { groupName: "Date", groupInterval: "year", groupIndex: 0 },   
      { groupName: "Date", groupInterval: "day", groupIndex: 1, format: "dd/MM/yyyy", 
        selector: function(data) {
          // var month = new Date(data.date).getMonth();
          return new Date(data.OrderDate);
      }} ,
      {
        dataField: 'OrderDate',
        caption: 'Order Date',
        dataType: 'date',
        groupName: "Date",
        area: 'row',
      }, {
        dataField: 'Freight',
        caption: 'Freight',
        datatype: 'number',
        summaryType: 'sum',
        area: 'data',
        // alignment: 'right',
        format: function (value) {   
          formattedValue="<div  style=\" color: blue \">"
          +number_format(value,2,',','.') +"</div>";
          return formattedValue;
        },
       
      }, {
        dataField: 'TotalOrder',
        caption: 'Total Orden',
        datatype: 'nnumber',
        area: 'data',
        summaryType: 'sum',
        format: function (value) {
          value<0?formattedValue="<div  style=\" color: red \">"+number_format(value,2,',','.') +"</div>":
          formattedValue=value>0?number_format(value,2,',','.'):null;
          return formattedValue;
        },
      }
      ],
    },    
      export: {
        enabled: true,
        fileName: "orders"
        },
        texts: {
            collapseAll: "Collapse All",
            dataNotAvailable: "N/A",
            expandAll: "Expand All",
            exportToExcel: "Exportar a fichero Excel",
            grandTotal: "Total General",
            noData: "No data",
            removeAllSorting: "Remove All Sorting",
            showFieldChooser: "Mostrar el selector de campos" ,
            sortColumnBySummary: "Sort {0} by This Column",
            sortRowBySummary: "Sort {0} by This Row",
            total: "{0} Total"
        },
  
    }).dxPivotGrid('instance');

    const ordersPopup = $('#pivotgrid-popup').dxPopup({
        width: 1000,
        height: 400,
        showCloseButton: true,
        showTitle: true,
        contentTemplate(contentElement) {
          $('<div />')
            .addClass('drill-down')
            .dxDataGrid({
              editing: {
                allowUpdating: true,
                // allowAdding: true,
                // allowDeleting: true,
               
              },
              width:950,
              height: 350,
             
              onSaving(e) {
                // console.log(e);

                const change = e.changes[0];
          
                if (change) {
                  e.cancel = true;
                  loadPanel.show();
                  e.promise = saveChange(URL, change)
                    .always(() => { loadPanel.hide(); })
                    .then((data) => {
                      
                      let orders = e.component.option('dataSource');
          
                      if (change.type === 'insert') {
                        change.data = data;
                      }                   
                      //  data = DevExpress.data.applyChanges(data, [change], { keyExpr: 'OrderID' });                     
                      e.component.option({
                        dataSource: data,
                        editing: {
                          editRowKey: null,
                          changes: [],
                        },
                      });
                      onDataGridSaved(e);
                      ordersPopup.hide();
                    }
                  );
                }
              },

              columns: [{
                dataField: 'OrderID',
                caption: 'ID',
                width: 8,
                allowEditing: false,
              }, {
                dataField: 'CustomerID',
                caption: 'Customer',
                allowEditing: false,
              }, {
                dataField: 'ShipCountry',
                allowEditing: false,
              }, {
                dataField: 'ShipCity',
                allowEditing: false,
              }, {
                dataField: 'ShippedDate',
                dataType: 'date',
                format: "shortDate",
                allowEditing: false,
              }, {
                dataField: 'OrderDate',
                dataType: 'date',
                format: "shortDate",
              }, {
                dataField: 'Freight',
                datatype: 'number',
                encodeHtml: false,
                alignment: 'right',
                format: function (value) {                  
                  formattedValue="<div  style=\" color: blue \">"
                  +number_format(value,2,',','.') +"</div>";
                  return formattedValue;
                },
              }, {
                dataField: 'TotalOrder',
                caption: 'Total Orden',
                encodeHtml: false,
                alignment: 'right',
                datatype: 'number',
                format: function (value) {
                  value<0?formattedValue="<div  style=\" color: red \">"+number_format(value,2,',','.') +"</div>":
                  formattedValue=value>0?number_format(value,2,',','.'):null;
                  return formattedValue;
                }
              }
              ],
              // columns: ['banco','Fecha', 'Categoría', 'Subcategoria','Tipo', 'Importe','Descripcion'],
            })
            .appendTo(contentElement);
        },
      
        onShowing() {
          $('.drill-down')
            .dxDataGrid('instance')
            .option('dataSource', drillDownDataSource);
        },
        onShown() {
          $('.drill-down')
            .dxDataGrid('instance')
            .updateDimensions();
        },
        onRowUpdating: function(e) {
          console.log(e);
          data.update(e.key, e.newData);         
        },
        onRowInserting: function(e){
          data.insert(e.data);          
        },      
       onRowRemoving: function(e){
           data.remove(e.key);          
        },
        onSaving(e) {
          const change = e.changes[0];    
          if (change) {
            e.cancel = true;
            loadPanel.show();
            e.promise = saveChange(URL, change)
              .always(() => { loadPanel.hide(); })
              .then((data) => {
                let orders = e.component.option('dataSource');    
                if (change.type === 'insert') {
                  change.data = data;
                }
                orders = DevExpress.data.applyChanges(orders, [change], { keyExpr: 'OrderID' });    
                e.component.option({
                  dataSource: orders,
                  editing: {
                    editRowKey: null,
                    changes: [],
                  },
                });
              });
          }
        },
      }).dxPopup('instance');

    DevExpress.localization.locale(navigator.language);
  });

Como es habitual, os dejo para vuestra descarga el ejemplo completo y así podéis probarlo y modificarlo en vuestro PC.

Podéis preguntarme cualquier cosa del ejemplo a través de mi email.

Adjuntos

Archivo Tamaño de archivo Descargas
zip PHPRunner 10.7 y backup 345 KB 82

Blog personal para facilitar soporte gratuito a usuarios de PHPRunner