S-028 – Integración SVAR Calendar con Tailwind CSS y Daisy UI en Svelte5

En esta ocasión, he hecho una mezcla de productos que considero es muy fácil, que los desarrolladores los utilicen, ya que considero que está muy generalizado el uso de Tailwind CSS y Daisy UI.

En este ejemplo a la solución S025 – integración con un DataGrid he añadido la representación del DataGrid en un formato Calendario, en concreto el Calendar de SVAR UI.

Objetivo

Estudio de integración de la solución de SVAR Calendar en un proyecto construido en Daisy UI y Tailwind CSS.

El resultado me ha encantado, por todo (funcionalidad, aspecto, facilidad, etc.).

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

Solución Técnica

Este ejemplo consta de 2 páginas:

  • Funcionamiento de un DataGrid, con su CRUD de los datos, utilizando el componentes de Drawer de Daisy.
  • Funcionamiento de un Calendar de Svar, para la presentación de todos los eventos (lo mismo que el DataGrid) y reutilización de los Drawer hechos en Daisy, pero llamados desde el Calendario, no utilizando más componentes, que el Calendar de la solución de SVAR UI.

Así pues, para entender el funcionamiento del DataGRID, debes acceder al artículo S025 que se explica con detalle todos los aspectos.

En la parte de Calendar, veremos que hemos reutilizado todo lo que tenemos para el DataGrid y hemos cambiado el componente del DataGrid por el componente de Calendar de SVAR UI. Además, hemos tenido que definir nuevas variables y nuevas funciones, que nos requiere el componente de SVAR, teniendo en cuenta que el acceso a los datos no se hace a todos los eventos, si no que sólo a los exclusivos de las fechas que se representan en la vista del calendario seleccionado. Resumiendo, cuando cambiamos de vista o de fecha, se accede al Server, para recuperar los eventos del rango de fechas que se representaran.

También, los datos del aplicativo los ajustamos a la estructura y nombres de campos que requiere Calendar, aunque seguimos tratando los datos originales para los Drawer.

(1) EventCalendar.svelte
<script>
  import { onMount } from "svelte";
  import { Calendar, Editor, Willow, ContextMenu, getMenuOptions } from "@svar-ui/svelte-calendar";
  import { Layout } from "@svar-ui/svelte-layout";
  import { Segmented, Locale } from "@svar-ui/svelte-core";
  import {
    en as enCore,
    es as esCore
        } from "@svar-ui/core-locales";
  import {
    en, 
    es
    } from "@svar-ui/calendar-locales";

  import EventCard from "./EventCard.svelte";

  import Swal from "sweetalert2";
  import apiRest from "../lib/api.js";
  import EventDrawer from "./EventDrawer.svelte";


  const dictionaries = {
    en: { calendar: en, core: enCore },
    es: { calendar: es, core: esCore },
  };

  let events = $state([]);

  // let calendars = $state([]);
  let eventsCalendar = $state([]);

  let calendars = $state([]);
  let calendarsCSS = $state([]);

  let loading = $state(true);
  let errorMessage = $state(null);

  let filter = $state("");
  let filteredData = $state([]);

  let selectedCalendars = $state([]); // array de idsvar_calendar Seleccionados para mostrar en el calendario

  let locale = $state("es");    // Cambiar a "en" para inglés
  let api = $state();           // Variable para almacenar la instancia de la API de Calendar

  let fechaBase = new Date();
  let vistaCalendar = "month";
  let { start, end } = getRangeFromView(fechaBase, vistaCalendar);


  let words = $derived({
    ...dictionaries[locale].calendar,
    ...dictionaries[locale].core,
  });

  let drawerOpen = $state(false);
  let drawerMode = $state("view");
  let selectedEvent = $state({});

  // Config options calendar Views
  const views = [
    {
    id: "month",
    sections: {
      month: {
          yScale: {
            visible: true,
            format: "weekNumberFormat"
          }
        }
      }
    },
    {
      id: "week",
      sections: {
        timeGrid: {
          yScale: { startHour: 9, endHour: 17 }
        }
      }
    },
    {
      id: "day",
      sections: {
        timeGrid: {
          yScale: { startHour: 9, endHour: 17 }
        }
      }
    }
  ];


  // Configuración de celdas para resaltar fines de semana y días festivos
  function cellCss(ctx) {
    const { date } = ctx;
    if (!date) return "";
    const day = date.getDay();
    if (day === 0 || day === 6) return "weekend";
    if (day === 5) return "holiday";
    return "";
  }

  // Filtrado de datos según el valor del input y selección de calendarios
  function onFilterInput(event) {
  filter = event.target.value;
  filteredData = filterData();
  }

  function clearFilter() {
    filter = "";
    filteredData = filterData();
  }

  function toggleCalendar(id) {
    if (selectedCalendars.includes(id)) {
      selectedCalendars = selectedCalendars.filter(x => x !== id);
    } else {
      selectedCalendars = [...selectedCalendars, id];
    }

    // cada cambio de selección recalcula el filtro
    filteredData = filterData();
  }

  function normalize(str) {
    return String(str)
      .trim()
      .normalize("NFD")
      .replace(/[\u0300-\u036f]/g, "")
      .toLowerCase();
  }

  function formatDateEs(dateStr) {
    const [fecha, hora] = dateStr.split(" ");
    const [y, m, d] = fecha.split("-");
    const [hh, mm] = hora.split(":");
    return `${d}/${m}/${y} ${hh}:${mm}`;
  }

function filterData() {
  let base = events;

  // 1. FILTRO POR CALENDARIO
  if (selectedCalendars.length > 0) {
    base = base.filter(e => selectedCalendars.includes(e.id_calendar));
  }

  // si no hay seleccionados, base = events (todos)

  // 2. FILTRO POR TEXTO
  let filtered;
  
  if (!filter) {
    filtered = base;
  } else {
    const f = normalize(filter);

    filtered = base.filter((row) => {
      return (
        normalize(row.title).includes(f) ||
        normalize(row.calendar_name).includes(f) ||
        normalize(row.priority_label).includes(f) ||
        normalize(row.names_people).includes(f) ||
        normalize(row.names_resource).includes(f) ||
        normalize(formatDateEs(row.start)).includes(f) ||
        normalize(formatDateEs(row.end)).includes(f)
      );
    });
  }
  // console.log("Datos filtrados: ", filtered);

  // 3. RECONSTRUIR eventsCalendar desde los Eventos filtrados para que el calendario los muestre correctamente
  eventsCalendar = filtered.map(e => {
      const isAllDay = e.all_day === "1";

      // Convertimos fechas
      const start = new Date(e.start);
      const end = new Date(e.end);

      if (isAllDay) {
          // Ajuste para evitar que SVAR Calendar reparta el día en 24 horas
          start.setHours(1, 0, 0, 0);   // 01:00:00
          end.setHours(23, 0, 0, 0);    // 23:00:00
      }

      return {
          ...e,
          id: e.id,
          text: e.title,
          start,
          end,
          allDay: isAllDay,
          calendarId: e.id_calendar, // clave para el color
      };
      // console.log("Evento para Calendar: ", eventsCalendar);
  });

  return filtered;
}
  // Función para obtener los calendarios y eventos desde el backend
  async function fetchCalendars() {
  try {

      const res2 = await apiRest.get("/calendar/list");
      calendars = res2.data;
      console.log("Calendars: ", calendars);

      // reconstruye CSS de Calendarios
      calendarsCSS = calendars.map(c => ({
        id: c.idsvar_calendar,
        label: c.name,
        css: `cal-${c.idsvar_calendar}`, // clase base para SVAR
        color: c.color,                  // por si lo necesitas en otros sitios
      }));
      console.log("Calendars CSS: ", calendarsCSS);
    
    } catch (err) {
      errorMessage = "No se pudieron cargar los eventos.";
    } finally {
      // loading = false;
    }
  
  }   

  //
  async function fetchEvents() {
    console.log("Fetching Calendars...");
    try {
      console.log("Rango de fechas para fetchEvents: ", start, end);

      const resEvents = await apiRest.post("/event/list", {
          start: start,
          end: end
        });
      console.log("datos de Eventos: ",resEvents);

      events = resEvents.data;            // Carga nueva cartga de eventos
      console.log("Datos de eventos cargados: ", events); 

      filteredData = filterData();
      console.log("Datos filtrados: ", filteredData);
    } catch (err) {
      errorMessage = "No se pudieron cargar los eventos.";
    } finally {
      loading = false;
    }
  }

  // Llamamos a fetchEvents y fetchCalendars al montar el componente
  onMount(() => {
    fetchCalendars(); // Llamada inicial para cargar calendarios
    fetchEvents(); // Llamada inicial para cargar eventos
  });

  function handleAdd() {
    drawerMode = "add";

    const now = new Date();
    const yyyy = now.getFullYear();
    const mm = String(now.getMonth() + 1).padStart(2, "0");
    const dd = String(now.getDate()).padStart(2, "0");

    const hh = "00";
    const min = "00";

    selectedEvent = {
      id: null,
      title: "",
      details: "",
      start: `${yyyy}-${mm}-${dd}T${hh}:${min}`,
      end: `${yyyy}-${mm}-${dd}T${hh}:${min}`,
      all_day: "0",
      id_calendar: "",
      id_priority: "",
      id_people: "",
      id_resource: ""
    };

    drawerOpen = true;
  }

  function handleView(row) {
    drawerMode = "view";

    selectedEvent = {
      ...row,
      start: formatDateEs(row.start),
      end: formatDateEs(row.end)
    };

    drawerOpen = true;
  }


  function handleEdit(row) {
    drawerMode = "edit";
    selectedEvent = { ...row };
    drawerOpen = true;
  }

  async function handleDelete(row) {
    const id = row.id;
    const name = row.title;

    const result = await Swal.fire({
      title: "¿Eliminar evento?",
      text: `Se eliminará "${name}".`,
      icon: "warning",
      showCancelButton: true,
      confirmButtonText: "Eliminar",
      cancelButtonText: "Cancelar"
    });

    if (!result.isConfirmed) return;

    try {
      const res = await apiRest.delete(`/event/${id}`);
      Swal.fire("Eliminado", res.data.message, "success");
      fetchEvents();
    } catch (err) {
      const msg = err.response?.data?.message ?? "Error desconocido";
      Swal.fire("Error", msg, "error");
    }
  }

  async function saveEvent(values) {
    try {
      let res;

      if (drawerMode === "add") {
        res = await apiRest.post("/event", values);
      } else {
        res = await apiRest.put(`/event/${selectedEvent.id}`, values);
      }

      Swal.fire("Correcto", res.data.message, "success");

      closeDrawer();
      fetchEvents();

    } catch (err) {
      const msg = err.response?.data?.message ?? "Error desconocido";
      Swal.fire("Error", msg, "error");
    }
  }

  function closeDrawer() {
    drawerOpen = false;
    selectedEvent = {};
  }

  // Menú conextual del Calendario
  const contextOptions = [
    // { id: "switch", text: " Cambia 🧺 ", icon: "wxi-arrows-h" },
    { id: "view", text: "Ver", icon: "wxi-eye" },
    { id: "edit", text: "Editar", icon: "wxi-edit" },
    // { id: "add", text: "Agregar", icon: "wxi-plus" },
    { id: "delete", text: "Eliminar", icon: "wxi-delete-outline" }
  ];


  function contextHandler( ev) {
      console.log("Contexto del menú: ", ev, ev.action, ev.context);

      console.log("Contexto del menú, Id: ", ev.action.id);

       // handleView(ev.context );
    
      const id = ev.context.id;
      const row = id ? filteredData.find(r => r.id === id) : null;
     /*
      if (!Array.isArray(editorItems)) {
        console.warn("editorItems no es un array:", editorItems);
      }
    */
      switch (ev.action?.id) {
        case "add":
          handleAdd();
          break;
        case "edit":
          if (row) {
            handleEdit(row);
          }
          break;
        case "view":
          if (row) {
            handleView(row);
            };
          break;
        case "delete":
          if (row) {
            handleDelete(row);
          }
          break;
      }
    }
  // Menú de Calendario
    const toolbar = {
    items: [
      { id: "nav", comp: "dateNav" },
      { id: "today", comp: "todayButton" },
      { comp: "spacer" },
      { id: "title", comp: "dateLabel" },
      { comp: "spacer" },
      { id: "modes", comp: "richselect" },
      // { id: "add-event", comp: "addEventButton" },
    ],
  };

// Función para manejar el clic en un evento del calendario
  function getRangeFromView(baseDate, view) {
    const start = new Date(baseDate);
    const end = new Date(baseDate);

    if (view === "day") {
        // Día completo
        start.setHours(0, 0, 0, 0);
        end.setHours(23, 59, 59, 999);
    }

    else if (view === "week") {
        // Semana completa (lunes a domingo)
        const day = start.getDay(); // 0=domingo, 1=lunes...
        const diff = (day === 0 ? -6 : 1 - day); // mover al lunes
        start.setDate(start.getDate() + diff);
        start.setHours(0, 0, 0, 0);

        end.setTime(start.getTime());
        end.setDate(start.getDate() + 6);
        end.setHours(23, 59, 59, 999);
    }

    else if (view === "month") {
        // Primer día del mes
        start.setDate(1);
        start.setHours(0, 0, 0, 0);

        // Último día del mes
        end.setMonth(start.getMonth() + 1);
        end.setDate(0); // día 0 = último del mes anterior
        end.setHours(23, 59, 59, 999);
    }

    return {
        start: toMySQL(start),
        end: toMySQL(end)
    };
}

function toMySQL(date) {
    const pad = n => String(n).padStart(2, "0");

    return (
        date.getFullYear() + "-" +
        pad(date.getMonth() + 1) + "-" +
        pad(date.getDate()) + " " +
        pad(date.getHours()) + ":" +
        pad(date.getMinutes()) + ":" +
        pad(date.getSeconds())
    );
}
// Marcar estilos para la visualización de los eventos en el calendario
function cssByCalendar(ctx) {
    return `cal-${ctx.event.calendarId}`;
}
 
// Aplicar los estilos de los calendarios al inicializar el calendario
function handleInit(api) {
    console.log("Calendar inicializado, generando CSS dinámico…");
    // Después de tener `calendars` con idsvar_calendar y color
  const style = document.createElement("style");

  style.innerHTML = calendars.map(c => `
    .cal-${c.idsvar_calendar}.wx-box-event,
    .cal-${c.idsvar_calendar}.wx-bar-event,
    .cal-${c.idsvar_calendar}.wx-month-event,
    .cal-${c.idsvar_calendar}.wx-month-box-event {
      background-color: ${c.color} !important;
      color: white !important;
    }
  `).join("\n");

  document.head.appendChild(style);

  }

</script>

<div class="drawer drawer-end">
  <input id="event-drawer" type="checkbox" class="drawer-toggle" bind:checked={drawerOpen} />

  <div class="drawer-content">
    <div class="max-w-5xl mt-6 px-4">

      <h2 class="text-2xl font-bold mb-4">Gestión de Eventos</h2>

      <div class="flex items-center justify-between gap-4 mb-4">

        <!-- 1️⃣ CALENDARIOS -->
        <div class="flex items-center gap-2 flex-wrap">
          <span class="font-semibold">Calendarios:</span>

          {#each calendars as cal}
            <label
              class="flex items-center gap-2 px-3 py-1 rounded-full text-sm cursor-pointer border"
              style={`background-color: ${cal.color}; color: white;`}
            >
              <input
                type="checkbox"
                class="checkbox checkbox-xs"
                checked={selectedCalendars.includes(cal.idsvar_calendar)}
                onchange={() => toggleCalendar(cal.idsvar_calendar)}
              />
              {cal.name}
            </label>
          {/each}
        </div>

        <!-- 2️⃣ FILTRO (más pequeño) -->
        <div class="relative w-60"> <!-- antes w-1/2 -->
          <input
            type="text"
            placeholder="🔎 Filtrar..."
            class="input input-bordered w-full pr-10 input-sm"
            value={filter}
            oninput={onFilterInput}
          />

          {#if filter}
            <button
              type="button"
              class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
              onclick={clearFilter}
            >
              ✖
            </button>
          {/if}
        </div>

        <!-- 3️⃣ BOTÓN "+" con tooltip -->
        <div class="tooltip tooltip-left" data-tip="Añadir nuevo evento">
          <button
            class="btn btn-circle btn-success w-12 h-12 text-2xl"
            onclick={handleAdd}
          >
            +
          </button>
        </div>

      </div>

      {#if loading}
        <p class="text-center py-6">Cargando eventos...</p>
      {:else if errorMessage}
        <div class="alert alert-error">{errorMessage}</div>
      {:else}
        <Willow>
        <div class="h-150"> 

          {#key locale}
            <ContextMenu api={api} options={contextOptions} onclick={contextHandler}  >
              <Locale {words} >         
                <Calendar
                  // init={(v) => api = v}
                  bind:this={api}
                  init={handleInit}
                  events={eventsCalendar}
                  eventCss={cssByCalendar}
                  view="month" 
                  // views={["day", "week", "month"]}
                  views={views}
                  date={new Date()}
                  toolbar={toolbar}
                  readonly={true}
                  eventPopup={EventCard}
                  onnavigateto={p => {
                      console.log("Navegando a: ", p);
                      fechaBase = p.date ?? fechaBase;
                      vistaCalendar = p.view ?? vistaCalendar;
                      console.log("Fecha base: ", fechaBase);
                      console.log("Vista calendar: ", vistaCalendar);

                      const range = getRangeFromView(fechaBase, vistaCalendar);
                      start = range.start;
                      end = range.end;
                      console.log("Rango de fechas: ", start, end);
                      fetchEvents(); // llamada a tu backend para recargar eventos según el nuevo rango
                  }}

                  {cellCss}
                />
                <!-- {#if api}<Editor {api} />{/if} -->
              </Locale>
            </ContextMenu>
          {/key}

        </div>
      </Willow>
      {/if}
    </div>
  </div>

  <div class="drawer-side">
    <label for="event-drawer" class="drawer-overlay"></label>

    {#if drawerOpen && selectedEvent}
      <EventDrawer
        mode={drawerMode}
        event={selectedEvent}
        onSave={saveEvent}
        onClose={closeDrawer}
      />
    {/if}
  </div>
</div>

<style>
  :global(.weekend) {
    background-color: #f5f0ff !important;
  }
  :global(.holiday) {
    background-color: #fde8e8 !important;
  }
  :global(.wx-x-headers-row) {
    z-index: 5 !important;
  }

/*
  :global {
    .cal-1.wx-box-event,
    .cal-1.wx-bar-event {
      background-color: #4081EB !important;
      color: white  !important;
    }

    .cal-2.wx-box-event,
    .cal-2.wx-bar-event {
      background-color: #35E638 !important;
      color: #444 !important;
    }

    .cal-3.wx-box-event,
    .cal-3.wx-bar-event {
      background-color: #EDA92B !important;
      color: #444 !important;
    }
  }
  */
</style>
(2) EventCard.svelte
<script>
    const { event, close } = $props();

    // Formato español dinámico según allDay (boolean)
    function formatDateEs(dateStr, allDay) {
        // Si viene como Date
        if (dateStr instanceof Date) {
            return dateStr.toLocaleString("es-ES", {
                day: "2-digit",
                month: "2-digit",
                year: "numeric",
                ...(allDay === false && {
                    hour: "2-digit",
                    minute: "2-digit",
                    hour12: false
                })
            });
        }

        // Si viene como string "YYYY-MM-DD HH:mm:ss"
        const [fecha, hora] = dateStr.split(" ");
        const [y, m, d] = fecha.split("-");

        // Si es todo el día → solo fecha
        if (allDay === true) {
            return `${d}/${m}/${y}`;
        }

        // Si NO es todo el día → fecha + hora
        const [hh, mm] = hora.split(":");
        return `${d}/${m}/${y} ${hh}:${mm}`;
    }

    // console.log("event en EventCard.svelte: ", event);
    // Ahora usamos event.allDay (boolean)

    // svelte-ignore state_referenced_locally
    const allDayText = event.allDay ? "Sí" : "No";
    // svelte-ignore state_referenced_locally
    const startEs = formatDateEs(event.start, event.allDay);
    // svelte-ignore state_referenced_locally
    const endEs = formatDateEs(event.end, event.allDay);

    // svelte-ignore state_referenced_locally
    const headerStyle = `background-color: ${event.calendar_color}`;
    // svelte-ignore state_referenced_locally
    const calendarioStyle = `background-color: ${event.calendar_color}; color: white;`;
    // svelte-ignore state_referenced_locally
    const borderStyle = `border-color: ${event.calendar_color}`;

    // svelte-ignore state_referenced_locally
    const people = event.names_people
        ? event.names_people.split(",").map(p => p.trim())
        : [];

    // svelte-ignore state_referenced_locally
    const resources = event.names_resource
        ? event.names_resource.split(",").map(r => r.trim())
        : [];
</script>

<div class="event-card w-80 rounded-lg shadow-xl bg-base-100 p-0 overflow-hidden border-2"
     style={borderStyle}>

    <!-- Título con color dinámico -->
    <header class="text-center text-white font-bold py-3" style={headerStyle}>
        {event.title}
    </header>

    <div class="p-4 space-y-3">

        <!-- Fechas -->
        <div class="grid grid-cols-1 gap-1 text-sm">
            <div><span class="font-semibold">Fecha inicial:</span> {startEs}</div>
            <div><span class="font-semibold">Fecha final:</span> {endEs}</div>
            <div><span class="font-semibold">Día completo:</span> {allDayText}</div>
        </div>

        <!-- Detalle -->
        <div class="text-sm">
            <span class="font-semibold">Detalle:</span>
            <div class="mt-1">{event.details}</div>
        </div>

        <!-- Calendario -->
        <div class="text-sm">
            <span class="font-semibold" style={calendarioStyle}>Calendario:</span> {event.calendar_name}</div>

        <!-- Prioridad -->
        <div class="text-sm">
            <span class="font-semibold">Prioridad:</span> {event.priority_label}</div>

        <!-- Personas -->
        <div class="text-sm">
            <span class="font-semibold">Personas:</span>
            <div class="mt-1 flex flex-wrap gap-2">
                {#each people as p}
                    <span class="badge badge-primary badge-sm">{p}</span>
                {/each}
            </div>
        </div>

        <!-- Recursos -->
        <div class="text-sm">
            <span class="font-semibold">Recursos:</span>
            <div class="mt-1 flex flex-wrap gap-2">
                {#each resources as r}
                    <span class="badge badge-secondary badge-sm">{r}</span>
                {/each}
            </div>
        </div>

        <!-- Botón cerrar -->
        <div class="pt-3 text-center">
            <button class="btn btn-sm btn-outline" onclick={close}>Cerrar</button>
        </div>
    </div>
</div>

El fichero (2) es el código para elaborar la tarjeta de un evento (tooltip de Calendar), que aparece al hacer clic en cualquier evento. Está desarrollado en Daisy UI.

El fichero (1) es la sustitución del DataGrid ajustado al calendario.

Algunos de los aspectos más relevantes con:

  • Líneas de 61 a 89.- Define características especiales de las Vistas, como son horarios de actividad del día, la aparición del número de semana en la vista de mes.
  • El número de Calendarios y su color es dinámico, por ello, antes de que aparezca nada (renderizar) se debe acceder a la tabla de calendarios y configurar, dinámicamente, los CSS que van a disponer cada calendario. También, hay que añadir a cada uno de los eventos el campo del CSS que se ha creado de acuerdo al calendario. Esta parte me costó bastante ajustarla para que fuese dinámica.
    Líneas 113 -122 y  461-484.
  • El filtro de texto se ha dejado igual, pero se ha añadido otro filtro por el calendario. También, en el filtro de los datos se hacen los 2 conjuntos de datos de los eventos (el que trata igual que en el DataGrid y el que requiere el Calendar).
    Líneas 113-122 y 139-198,
  • Se ha definido el menú contextual desplegable con botón derecho y la función de tratamiento de las opciones de menu.
    Líneas 351-395.
  • En líneas 562-590, está la llamada al componente Calendar, todas las propiedades y eventos que he utilizado del mismo, y en concreto, hay que estudiar el evento «onnavigateto», que se utiliza para identificar el cambio de vista y los rangos de fechas, de tal forma que se acceda al server para obtener los eventos que  se deben de presentar en el componente.

Todo el código tiene comentarios para que se sepa qué es lo que hacen las funciones. Os podeís poner en contacto conmigo a través de email, para aclarar las dudas que tengais.

También, en el código, os dejo la parte que corre en el server y que accede a la base de datos de MySQL(persistencia de datos). Está desarrollada en PHP con el framework SLIM 4.0 (como todos los que he hecho). Este caso es un excelente ejemplo para estudiarlo, entenderlo (es de fácil comprensión) y desde mi punto de vista, es una excelente solución para el acceso a los datos de MySQL.

Os facilito los fuentes para que podáis descargarlo, probarlo todo en vuestros equipos y hacer lkos ajustes que preciséis.

Recordaz que se debe instalar estos desarrollos:

  • Front-End: npm install
  • Backe-End: composer install

Adjuntos

Archivo Tamaño de archivo Descargas
zip Front-End Svelte 5 56 KB 0
zip Back-End PHP SLIM 4 10 KB 1
zip Backup de Base de datos 2 KB 0

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