S-026 – Crear DashBoard con ApexChart en Svelte

Aquel que me haya seguido desde 2017, sabrá que me gusta mucho la solución de AnyChart, pero que ésta es de pago, aunque los que trabajamos con PHPRunner, la licencia de PHPRunner incluye una licencia de AnyChart para integrar gráficos en sus desarrollos.

Este producto ApexCharts, es también un producto de muchas posibilidades y de excelente calidad, que dispone de una licencia Community, por lo que podremos utilizarlo en casi todas las posibilidades.

Aparte de analizar los tipos de gráficos, sus posibilidades, etc., se quiere probar su funcionamiento en Svelte 5 con Runas y su integración con TailWind CSS y Daisy UI.

Objetivo

Construir un ejemplo de aplicativo donde integremos ApexCharts, TailWind CSS y Daisy UI, y analizar las posibilidades de construcción de DashBoard.

DEMO: https://fhumanes.com/apexchart-svelte/

Solución Técnica

Prácticamente, ya os he indicado los productos que he utilizado para construir el ejemplo:

    • Front-End
      • Svelte 5 (javaScript)
      • TailWind CSS
      • Daisy UI
      • ApexCharts
    • Back-End
      • PHP
      • Slim 4
      • Mysql

Son los productos que utilizo en casi todos los ejemplos, por lo que si sois seguidores, no tendréis problemas en su uso. Recordar que para instalar el ejemplo del Front-End se ejecuta el comando «npm install» y ya lo tendríais funcionando en vuestro PC.

En este caso, la parte del Back-End la he dejado para que la instaléis con composer install, en el kit que os dejo. Esto lo hago para que los kits sean más pequeños y para que tengáis instalado las últimas versiones de los productos requeridos.

La versión de Back-End es una evolución/mejora del código que estaba utilizando y si trabajáis con este tipo de solución, os aconsejo que la reviséis.

Cuando haces un ejemplo de un producto de gráficos, nunca puede ser completo, pues las posibilidades de tipos de gráficos son casi infinitas, El que siempre intento probar es el de semi-donuts (el hemiciclo de unas cortes), porque no suele estar dentro del catálogo de soluciones, pero si el producto es maduro, a través de las soluciones de los gráficos de Donuts, se puede hacer. En este caso, así ha sido.

Aplicación de Front-End

He estructurado el proyecto en estos directorios:

  • «src/lib» .- Es donde defino las funciones exclusiva de JavaScript. En este caso, está el API para el acceso al servidor.
  • «src/components/ApexChart» .- Es donde está el código de los gráficos que tiene el ejemplo.
  • «src/components/DataGrid» .- en el ejemplo no se usa. Está ahí, porque quería incluir un DataGrid con gráficos, pero pensé que iba a complicaros demasiado el ejemplo y he dejado esa parte para otro ejemplo.
  • «src/pages» .- Son las páginas que se llaman desde el menú que está en «App.svelte»

Os muestro algunos de los ficheros más representativos:

(1) App.svelte(2) src/pages/DashboardPage.svelte(3) src/component/ApexChart/BarEscanos.svelte(4) src/component/ApexChart/SemiDonutEscanos.svelte(5) src/component/ApexChart/EvolutivoEscanos.svelte
<script>
  import Router from 'svelte-spa-router';
  // import active from 'svelte-spa-router/active';

  import Donuts from './pages/DonutsPage.svelte';
  import BarEscanos from './pages/BarEscanosPage.svelte';
  import Evolutivos from './pages/EvolutivosPage.svelte';
  import Dashboard from './pages/DashboardPage.svelte';
  // import Test from './pages/testPage.svelte';

  const routes = {
    '/': Dashboard,
    '/dashboard': Dashboard,
    '/donuts': Donuts,
    '/barescanos': BarEscanos,
    '/evolutivos': Evolutivos,
    // '/test': Test,
  
  };

  let current = location.hash;  // location.hash;  ejemplo: '#/donuts'
  // actualizar cuando cambie el hash
  window.addEventListener("hashchange", () => {
    current = location.hash;
  });
  console.log("Current route:", current);

</script>

<!-- NAVBAR DataGrid -->
<div class="navbar bg-neutral text-neutral-content px-2 lg:px-4">
  <div class="flex-1">
    <a href="#/" class="text-xl font-bold">Elecciones Comunidad de Madrid</a>
  </div>

  <div class="flex gap-4">
    <a
      href="#/dashboard"
      class="btn  btn-sm text-neutral-content hover:text-neutral {current === '#/dashboard' || current === '#/' || current === '' ? 'btn-primary' : 'btn-ghost'}"
    >
      DashBoard
    </a>

    <a
      href="#/donuts"
      class="btn  btn-sm text-neutral-content hover:text-neutral {current === '#/donuts' ? 'btn-primary' : 'btn-ghost'}"
    >
      Donuts
    </a>
    <a
      href="#/barescanos"
      class="btn btn-sm text-neutral-content hover:text-neutral {current === '#/barescanos' ? 'btn-primary' : 'btn-ghost'}"
    >
      Escaños
    </a>
    <a
      href="#/evolutivos"
      class="btn btn-sm text-neutral-content hover:text-neutral {current === '#/evolutivos' ? 'btn-primary' : 'btn-ghost'}"
    >
      Evolutivos
    </a>
    <!--
    <a
      href="#/test"
      class="btn btn-sm text-neutral-content hover:text-neutral {current === '#/test' ? 'btn-primary' : 'btn-ghost'}"
    >
      Test
    </a>
    -->

    </div>
</div>

<!-- CONTENIDO PRINCIPAL -->
<div class="p-1  bg-gray-50 min-h-screen">
  <Router {routes} />
</div>
<script>
  import { onMount } from "svelte";
  import api from "../lib/api.js";

  // Gráficos de escaños
  import BarEscanos from "../components/ApexChart/BarEscanos.svelte";
  import SemiDonutEscanos from "../components/ApexChart/SemiDonutEscanos.svelte";

  // Gráficos evolutivos
  import EvolutivoVotos from "../components/ApexChart/EvolutivoVotos.svelte";
  import EvolutivoPorcentaje from "../components/ApexChart/EvolutivoPorcentaje.svelte";
  import EvolutivoEscanos from "../components/ApexChart/EvolutivoEscanos.svelte";

  // Estado general del Dashboard
  let convocatorias = $state([]);
  let selectedConvocatoria = $state(null);

  // Datos para los gráficos de escaños
  let labels = $state([]);
  let colors = $state([]);
  let escanos = $state([]);
  let votos = $state([]);
  let porcentajes = $state([]);

  // Datos para los evolutivos
  let datosEvo = $state([]);
  let evoConvocatorias = $state([]);
  let candidaturas = $state([]);
  let matriz = $state({});
  let coloresCandidaturas = $state({});

  // Tab de evolutivos
  let tab = $state("votos");

  // ---------------------------------------------------------
  // 🔵 CARGA DE ESCANOS POR CONVOCATORIA (como BarEscanosPage)
  // ---------------------------------------------------------
  async function loadConvocatorias() {
    const res = await api.get("/convocatorias");

    convocatorias = res.data.filter(c => Number(c.idConvocatoria) > 0);

    if (convocatorias.length > 0) {
      selectedConvocatoria = convocatorias[0].idConvocatoria;
      await loadEscanos();
    }
  }

  async function loadEscanos() {
    const id = Number(selectedConvocatoria);
    if (!id || id <= 0) return;

    const res = await api.get(`/escanos/${id}`);
    const data = res.data;

    const ordenado = data
      .filter(d => d.Escanos > 0)
      .sort((a, b) => b.Escanos - a.Escanos);

    labels      = ordenado.map(d => d.Codigo);
    colors      = ordenado.map(d => d.Color);
    escanos     = ordenado.map(d => d.Escanos);
    votos       = ordenado.map(d => d.Votos);
    porcentajes = ordenado.map(d => d.PorcVotos);
  }

  // ---------------------------------------------------------
  // 🔵 CARGA DE EVOLUTIVOS (como EvolutivosPage)
  // ---------------------------------------------------------
  async function loadEvolutivos() {
    const res = await api.get("/escanos_evolucion");
    datosEvo = res.data;

    // Convocatorias únicas
    evoConvocatorias = [...new Set(datosEvo.map(d => d.Convocatoria))]
      .sort((a, b) => a.localeCompare(b));

    // Candidaturas únicas
    candidaturas = [...new Set(datosEvo.map(d => d.Candidatura))]
      .sort((a, b) => a.localeCompare(b));

    // Matriz completa
    const m = {};

    candidaturas.forEach(cand => {
      m[cand] = {};
      evoConvocatorias.forEach(conv => {
        m[cand][conv] = null;
      });
    });

    datosEvo.forEach(d => {
      m[d.Candidatura][d.Convocatoria] = {
        votos: d.Votos,
        porc: d.PorcVotos,
        escanos: d.Escanos === 0 ? null : d.Escanos
      };
    });

    matriz = m;
  }

  async function loadColoresCandidaturas() {
    const res = await api.get("/candidaturas");
    coloresCandidaturas = Object.fromEntries(
      res.data.map(d => [d.Codigo, d.Color])
    );
  }

  // ---------------------------------------------------------
  // 🔵 INICIALIZACIÓN DEL DASHBOARD
  // ---------------------------------------------------------
  onMount(async () => {
    await loadConvocatorias();
    await loadColoresCandidaturas();
    await loadEvolutivos();
  });
</script>

<div class="p-1 lg:p-4 space-y-6 bg-base-200 min-h-screen">

  <!-- Selector de convocatoria -->
  <h2 class="text-xl font-bold mb-4">Dashboard Electoral</h2>

  <fieldset class="fieldset space-y-0 mb-2">
    <legend class="fieldset-legend mb-0">Convocatoria</legend>

    <select
      class="select select-bordered max-w-[40ch]"
      bind:value={selectedConvocatoria}
      onchange={() => loadEscanos()}
    >
      {#each convocatorias as c}
        <option value={c.idConvocatoria}>{c.Titulo}</option>
      {/each}
    </select>
  </fieldset>


  <!-- Fila 1: Escaños -->
  <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">

    <div class="card bg-base-100 shadow-md p-4">
      <h3 class="text-lg font-bold mb-2">Escaños por Candidatura</h3>
      <BarEscanos
        {labels}
        {colors}
        {escanos}
        {votos}
        {porcentajes}
      />
    </div>

    <div class="card bg-base-100 shadow-md p-4">
      <h3 class="text-lg font-bold mb-2">Distribución de Escaños</h3>
      <SemiDonutEscanos
        {labels}
        {colors}
        {escanos}
      />
    </div>

  </div>

  <!-- Fila 2: Evolutivos -->
  <div class="card bg-base-100 shadow-md p-4">

    <h3 class="text-lg font-bold mb-4">Evolutivos Históricos</h3>

    <!-- Pestañas -->
    <div class="flex gap-2 mb-4">

        <button
            type="button"
            class="px-4 py-2 rounded-lg border shadow-sm font-semibold
                transition-all duration-200
                {tab === 'votos'
                    ? 'bg-blue-600 text-white border-blue-700 shadow-md'
                    : 'bg-base-100 text-base-content hover:bg-base-200'}"
            onclick={() => tab = 'votos'}
        >
            Votos
        </button>

        <button
            type="button"
            class="px-4 py-2 rounded-lg border shadow-sm font-semibold
                transition-all duration-200
                {tab === 'porc'
                    ? 'bg-green-600 text-white border-green-700 shadow-md'
                    : 'bg-base-100 text-base-content hover:bg-base-200'}"
            onclick={() => tab = 'porc'}
        >
            Porcentajes
        </button>

        <button
            type="button"
            class="px-4 py-2 rounded-lg border shadow-sm font-semibold
                transition-all duration-200
                {tab === 'esc'
                    ? 'bg-red-600 text-white border-red-700 shadow-md'
                    : 'bg-base-100 text-base-content hover:bg-base-200'}"
            onclick={() => tab = 'esc'}
        >
            Escaños
        </button>

    </div>

    <!-- Contenido -->
    {#if tab === "votos"}
      <EvolutivoVotos
        matriz={matriz}
        convocatorias={evoConvocatorias}
        candidaturas={candidaturas}
        colores={coloresCandidaturas}
      />
    {:else if tab === "porc"}
      <EvolutivoPorcentaje
        matriz={matriz}
        convocatorias={evoConvocatorias}
        candidaturas={candidaturas}
        colores={coloresCandidaturas}
      />
    {:else}
      <EvolutivoEscanos
        matriz={matriz}
        convocatorias={evoConvocatorias}
        candidaturas={candidaturas}
        colores={coloresCandidaturas}
      />
    {/if}

  </div>

</div>
<script>
  import ApexCharts from "apexcharts";

  const { labels, colors, escanos, votos, porcentajes } = $props();

  let chart = $state(null);
  let chartEl = $state(null);

  $effect(() => {
    if (!labels.length) return;

    const options = {
      chart: { type: "bar", height: 400, toolbar: { show: false } },
      series: [{ name: "Escaños", data: escanos }],
      colors,
      plotOptions: {
        bar: { distributed: true, borderRadius: 4, columnWidth: "55%" }
      },
      states: { // Para que no actue el click en los elementos del gráfico
        active: {
          filter: {
            type: 'none'
          }
        },
        hover: {
          filter: {
            type: 'none'
          }
        }
      },
      xaxis: {
        categories: labels,
        labels: {
          style: {
            colors,
            fontSize: "14px",
            fontWeight: 600
          }
        }
      },
      legend: { show: false },
      dataLabels: { enabled: true, formatter: val => val },
      tooltip: {
        custom: ({ dataPointIndex }) => {
          const codigo     = labels[dataPointIndex];
          const color      = colors[dataPointIndex];
          const esc        = escanos[dataPointIndex];
          const totalVotos = votos[dataPointIndex].toLocaleString("es-ES");
          const porcentaje = porcentajes[dataPointIndex]
            .toFixed(2)
            .replace(".", ",") + "%";

          return `
            <div style="background:${color};color:white;padding:8px;border-radius:6px;">
              <strong>${codigo}</strong><br/>
              Escaños: ${esc}<br/>
              Votos: ${totalVotos}<br/>
              ${porcentaje}
            </div>
          `;
        }
      }
    };

    if (!chart) {
      chart = new ApexCharts(chartEl, options);
      chart.render();
    } else {
      chart.updateOptions(options);
    }
  });
</script>

<div class="max-h-112.5" bind:this={chartEl}></div>
<script>
  import ApexCharts from "apexcharts";

  const { labels, colors, escanos } = $props();

  let chart = $state(null);
  let chartEl = $state(null);
  let totalEscanos = $state(0);

  $effect(() => {
    if (!escanos.length) return;

    // 🔥 Total de escaños de la convocatoria
    totalEscanos = escanos.reduce((a, b) => a + b, 0);

    const options = {
      chart: {
        type: "donut",
        width: 450,
        height: 400, // "auto",

        offsetY: +150,    // 🔥 mueve el donut hacia arriba o para abajo
      },
      /*
      grid: {
        padding: {
            top: +160,    // sube un poco más el gráfico
            // bottom: +20  // reduce espacio bajo el gráfico
        }
    },
    */

      series: escanos,
      labels: labels,   // ← siglas (PSOE, PP, VOX…)
      colors: colors,

      plotOptions: {
        pie: {
            startAngle: -90,
            endAngle: 90,

            expandOnClick: true,   // mantiene la expansión en Selected
            customScale: 1,        // evita reescalado interno
            // 🔥 Esta línea es la clave
            offsetX: 0, // Sets the left offset of the whole pie area, default = 0
            offsetY: 16, // Sets the top offset of the whole pie area, default = 16

            donut: {
                size: "50%",
                labels: {
                    show: true,
                   
                    total: {
                        show: true,
                        showAlways: true,  // 🔥 solo aparece al hacer hover

                        // 🔥 Solo una línea, sin formatter
                        label: "Total escaños: " + totalEscanos,

                        // 🔥 Desactivamos formatter para evitar la segunda línea
                        formatter: () => ""
                    }
                    
                }
            },

            stroke: { // 🔥 borde blanco entre segmentos
                width: 2,
                colors: ['#ffffff']   // ✔ array → funciona
            },
            // Para eliminar sombreado oscuro en la selección de un segmento, usamos selected: { filter: { type: 'none' } }
            states: { 
                active: {
                    filter: {
                    type: 'lighten'   // 🔥 evita el sombreado oscuro
                    // type: 'lighten',
                    // value: 0.5
                    }
                },
                  hover: {
                    filter: {
                    type: 'lighten',
                    // value: 0.05
                    }
                },
            },
        }
      },

      // 🔥 Texto visible en cada porción = número de escaños
      dataLabels: {
        enabled: true,
        formatter: (_, { seriesIndex }) => escanos[seriesIndex],
        style: {
          fontSize: "12px",
          fontWeight: "bold",
          colors: ["#fff"]
        }
      },

      tooltip: {
        custom: ({ seriesIndex }) => {
          const sigla = labels[seriesIndex];
          const esc = escanos[seriesIndex];
          const color = colors[seriesIndex];

          return `
            <div style="background:${color};padding:8px;color:white;border-radius:6px;">
              <strong>${sigla}</strong><br/>
              Escaños: ${esc}
            </div>
          `;
        }
      },
      legend: {
        show: true,
        position: "bottom",
        horizontalAlign: "center",
        offsetY: 10 ,   // si quieres, puedes subirla con un valor negativo
    },

      // 🟦 Responsive para pantallas pequeñas
      responsive: [
        {
            breakpoint: 1024,
            options: {
                chart: {
                width: 350,
                height: 400,
                },
                plotOptions: {
                pie: {
                    donut: {
                    size: "55%",
                    }
                }
                },
                dataLabels: {
                    style: {
                        fontSize: "12px"
                    }
                },
                legend: {
                    show: true,
                    position: "bottom",
                    horizontalAlign: "center",
                    offsetY: 20, // si quieres, puedes subirla con un valor negativo
                }
            },

        },
        
      ],


    };

    if (!chart) {
      chart = new ApexCharts(chartEl, options);
      chart.render();
    } else {
      chart.updateOptions(options);
    }
  });
</script>

<div class="w-full flex items-center justify-center">

  <!-- Semidonut recortado y centrado -->

  <!-- Esta es la línea origina, la dejo para que se entienda el cambio de tamaño y posición del gráfico  
  <div class="relative w-[450px] h-[300px] overflow-hidden flex items-center justify-center">
    <div class="absolute inset-0 -translate-y-[30%]" bind:this={chartEl}></div>
  </div>
  -->
  <div class="relative w-112.5 h-75 overflow-hidden flex items-center justify-center">
    <div class="absolute inset-0 translate-y-[-30%]" bind:this={chartEl}></div>
  </div>

</div>
<script>
  import ApexCharts from "apexcharts";

  const { matriz, convocatorias, candidaturas, colores } = $props();

  let chart = $state(null);
  let chartEl = $state(null);

  function buildSeries() {
    return candidaturas.map(cand => ({
      name: cand,
      data: convocatorias.map(conv => {
        const cell = matriz[cand]?.[conv];
        return cell ? cell.escanos : null;   // 🔥 NULO si no hay datos
      })
    }));
  }

  $effect(() => {
    if (!candidaturas.length || !convocatorias.length) return;

    const options = {
      chart: { type: "line", height: 350 },
      series: buildSeries(),
      colors: candidaturas.map(c => colores[c] ?? "#CCCCCC"),
      xaxis: { categories: convocatorias },
      stroke: { width: 3 },
      markers: { size: 4 }
    };

    if (!chart) {
      chart = new ApexCharts(chartEl, options);
      chart.render();
    } else {
      chart.updateOptions(options);
    }
  });
</script>

<div bind:this={chartEl}></div>
Aplicación de Back-End

Como he indicado, tiene la estructura de los directorios, habituales, con su fichero de configuración de «include/Config.php». La diferencia de este ejemplo con otros es el fichero «v1/index.php», que tiene este código:

index.php
<?php
/**
 *
 * @About:      API Interface
 * @File:       index.php
 * @Date:       $Date:$ Sep 2025
 * @Version:    $Rev:$ 1.0
 * @Developer:  Federico Guzman || Modificado por Fernando Humanes para PHP 8.3
 **/

/* 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:5173";

// header("Access-Control-Allow-Origin: $dominioPermitido"); // Para restringir desde dónde se pueden hacer peticines
// header("Access-Control-Allow-Origin: *");
// header("Access-Control-Allow-Headers: Content-Type, authorization, Authorization, token-user ");
// header("Access-Control-Allow-Headers: *");

// 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('Content-Type: multipart/form-data');
// header('Content-Type: application/x-www-form-urlencoded');
// header('Content-Type: application/json');
// header('P3P: CP="IDC DSP COR CURa ADMa OUR IND PHY ONL COM STA"');

// session_cache_limiter(false);

include_once '../include/Config.php';       // Configuration Rest Api
include_once '../include/Function.php';     // Funciones generales
// include_once '../include/ExternalSessionMiddleware.php';  // para compartir Cookies de Session

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

// Debug
$debugCode = false; // On | Off, de depuración y volcado en el fichero "error.log"
// custom_error(1,"URL ejecutada: ".$_SERVER["REQUEST_URI"]);          // To debug
// custom_error(2,"Método de petición: ".$_SERVER['REQUEST_METHOD']);  // to Debug
// $body = $body = file_get_contents('php://input');// 
// custom_error(3,"Body: ".$body);                                    // to Debug
//  custom_error(4,"Campos POST: ".print_r($_POST,true));               // to Debug
$debugCode = false;

// use App\Models\Db;  // Utilizamos la conexión de PHPRunner
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
use Slim\Factory\AppFactory;

use Slim\Middleware\ErrorMiddleware;
use Slim\Exception\HttpNotFoundException;
// use DI\Container;
use Slim\Routing\RouteCollectorProxy;
use Slim\Middleware\BodyParsingMiddleware;


require_once __DIR__ . '/../vendor/autoload.php';   // Library SLIM v4

$app = AppFactory::create();

// Middleware de CORS
$app->add(function ($request, $handler) {
    $response = $handler->handle($request);
    return $response
            ->withHeader('Access-Control-Allow-Origin', '*')
            ->withHeader('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Accept, Origin, authorization, Authorization, token-user')
            ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
            ->withHeader('Content-Type', 'application/json');
});

// Gestión de OPTIONS
$app->options('/{routes:.+}', function ($request, $response, $args) {
    return $response;
});

// Activar middleware de sesión externa
// $app->add(new ExternalSessionMiddleware());

$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 donde está trabajando

require_once '../include/DbFunctions.php';
$db = new DbFunctions();



$app->get('/convocatorias', function (Request $request, Response $response, $args) use ($db) {         // sessión 
         return $db->convocatorias($request, $response, $args);
    });
    
$app->get('/candidaturas', function (Request $request, Response $response, $args) use ($db) {         // sessión 
         return $db->candidaturas($request, $response, $args);
    });
    
$app->get('/escanos/{idConvocatoria}', function (Request $request, Response $response, $args) use ($db) {         // sessión 
         return $db->escanos($request, $response, $args);
    });
    
$app->get('/escanos_evolucion', function (Request $request, Response $response, $args) use ($db) {         // sessión 
         return $db->escanos_evolucion($request, $response, $args);
    });

$app->run();

La diferencia principal es que he comentado todos los comando de «header» y los accesos CORS he ha resuelto con Middleware de CORS.

Espero que os guste y que os ayudea resolver vuestros requisitos. Para las dudas que tengáis podéis poneros en contacto conmigo a través de mi email.

Os dejo los fuentes para que podáis descargarlos e instalarlos en vuestros PC’s.

Adjuntos

Archivo Tamaño de archivo Descargas
zip apexchart-svelte 37 KB 0
zip SLIM4-server + Backup de BD 15 KB 0

Blog personal para facilitar soporte gratuito a usuarios de React y PHPRunner