first commit

This commit is contained in:
David Vargas 2024-10-27 12:50:51 -06:00
commit 19b9cdba59
99 changed files with 7762 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.git
node_modules
vendor
docker-compose.yml
Dockerfile
README.md

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
APP_NAME=appname
DB_HOST=localhost
DB_NAME=dbname
DB_USER=user
DB_PASS=passwordsecure
DB_DRIVER=mysql
DB_CHARSET=utf8
EMAIL_HOST=smtp.youremail.com
EMAIL_PORT=587
JWT_SECRET=secretkey

56
.gitignore vendored Normal file
View File

@ -0,0 +1,56 @@
# Ignore Composer's vendor directory
/vendor/
# Ignore Composer lock file
/composer.lock
# Ignore environment variables
/.env
# Ignore log files
/log/*
!log/.gitkeep
# Ignore temporary and cache files
/tmp/
*.temp
*.cache
# Ignore system files
.DS_Store
Thumbs.db
# Ignore IDE files
/.vscode/
/.idea/
*.iml
# Ignore system file configurations
*.bak
*.swp
# Ignore Robo task cache
/.robo/
# Ignore Node.js dependencies and build files (if you use Node for assets)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Ignore build artifacts (for assets)
dist/
build/
# Ignore compiled files
/public/js/*.map
/public/css/*.map
/public/js/*.min.*
/public/css/*.min.*
# Ignore backups or old config
*.bak
*.old
# Custom
/public/phpinfo.php

15
000-default.conf Normal file
View File

@ -0,0 +1,15 @@
<VirtualHost *:80>
ServerAdmin webmaster@localhost
ServerName localhost
DocumentRoot /var/www/html/public
<Directory /var/www/html/public>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

51
Dockerfile Normal file
View File

@ -0,0 +1,51 @@
FROM php:8.3-apache AS build
RUN apt-get update && apt-get install -y \
libzip-dev \
unzip \
libonig-dev \
libxml2-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libicu-dev \
libgmp-dev \
libsodium-dev \
&& docker-php-ext-install \
zip \
mbstring \
xml \
bcmath \
intl \
gmp \
sodium \
opcache \
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
&& docker-php-ext-install gd \
&& docker-php-ext-install pdo_mysql
# Xdebug config
RUN pecl install xdebug \
&& docker-php-ext-enable xdebug
COPY xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini
RUN a2enmod rewrite
COPY 000-default.conf /etc/apache2/sites-available/000-default.conf
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY . /var/www/html
RUN composer install --no-dev --optimize-autoloader
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/storage
EXPOSE 80
EXPOSE 9003
CMD ["apache2-foreground"]

144
README.md Normal file
View File

@ -0,0 +1,144 @@
# ERP System - FerreToolsApp
Este proyecto es un **sistema ERP** desarrollado con **PHP 8**, que sigue el patrón de diseño **Adapters** y está diseñado para ser modular y extensible. El sistema incluye módulos de gestión de clientes, productos, pedidos, facturación e inventario. El sistema está pensado para facilitar la gestión de las operaciones diarias en una ferretería o negocio similar, ofreciendo un panel administrativo y una estructura robusta para futuras expansiones.
## Características Principales
- **Módulos** de Ventas, Inventario, Facturación, Clientes, Pedidos.
- **Autenticación** con inicio de sesión y registro de usuarios.
- **JWT para autenticación**: Gestión de roles y sesiones de usuario.
- **Roles de Usuario** para controlar el acceso a las diferentes secciones del sistema.
- **Rutas protegidas** que permiten controlar qué usuarios pueden acceder a cada módulo.
- **Sistema de manejo de errores** HTTP con respuestas claras y detalladas (404, 500, etc.).
- **Reportes en PDF y Excel** para la gestión de informes de ventas, inventario y otros módulos.
- **Diseño limpio y responsive** utilizando Bootstrap 5.
- **Manejo de formularios** con sanitización de datos para evitar ataques de inyección.
## Requisitos
- PHP >= 8.3
- Composer
- Servidor web (Apache, Nginx)
- MySQL (también puedes usar PostgreSQL, Oracle, SQL Server, etc.)
- Extensiones PHP:
- PDO
- cURL
- mbstring
- openssl
## Librerías Utilizadas
El proyecto se construyó utilizando las siguientes librerías:
- **[Medoo](https://medoo.in/)**: ORM ligero para la interacción con la base de datos.
- **[PHPMailer](https://github.com/PHPMailer/PHPMailer)**: Para el envío de correos electrónicos.
- **[Monolog](https://github.com/Seldaek/monolog)**: Para el registro y manejo de logs.
- **[phpdotenv](https://github.com/vlucas/phpdotenv)**: Para la gestión de variables de entorno.
- **[AltoRouter](https://github.com/dannyvankooten/AltoRouter)**: Router simple y rápido para la gestión de rutas.
- **[Symfony HttpFoundation](https://github.com/symfony/http-foundation)**: Para manejar las peticiones y respuestas HTTP.
- **[DOMPDF](https://github.com/dompdf/dompdf)**: Para generar reportes y documentos en formato PDF.
- **[PhpSpreadsheet](https://phpspreadsheet.readthedocs.io/en/latest/)**: Para la generación y manejo de archivos Excel.
- **[MoneyPHP](https://github.com/moneyphp/money)**: Para manejar transacciones monetarias de manera segura.
- **[Firebase JWT](https://github.com/firebase/php-jwt)**: Para la autenticación con JWT (JSON Web Tokens).
## Instalación
### 1. Clona el repositorio
```bash
git clone https://github.com/daviddevgt/proyecto-erp.git
cd proyecto-erp
```
### 2. Instala las dependencias de Composer
Asegúrate de que tienes Composer instalado. Luego, ejecuta el siguiente comando:
```bash
composer install
```
### 3. Configuración del entorno
Crea un archivo `.env` en la raíz del proyecto basado en el archivo `.env.example`. Actualiza las variables con la configuración de tu base de datos y otros ajustes:
```dotenv
DB_DRIVER=mysql
DB_HOST=127.0.0.1
DB_NAME=erp_database
DB_USER=root
DB_PASS=password
DB_CHARSET=utf8mb4
JWT_SECRET=tu_secreto_jwt
```
### 4. Configuración de la base de datos
Crea una base de datos para el sistema e importa el archivo `schema.sql` ubicado en la carpeta `schema`.
```bash
mysql -u root -p erp_database < schema/schema.sql
```
### 5. Iniciar el servidor
Puedes usar el servidor de desarrollo de PHP para correr la aplicación en local:
```bash
php -S localhost:8000 -t public
```
Luego, abre el navegador y ve a `http://localhost:8000`.
## Estructura del Proyecto
```plaintext
.
├── app
│ ├── Config # Configuraciones del sistema, rutas y módulos
│ ├── Controllers # Controladores del sistema
│ ├── Core # Clases de core (ej. conexión a la base de datos, helpers)
│ ├── Entities # Entidades que representan las tablas de la BD
│ ├── Repositories # Repositorios para la gestión de entidades
│ └── Views # Vistas y componentes de la aplicación
├── public
│ ├── assets # Archivos estáticos (CSS, JS, imágenes)
│ └── index.php # Punto de entrada del sistema
├── schema
│ └── schema.sql # Esquema de la base de datos
└── vendor # Dependencias instaladas por Composer
```
## Uso
### 1. Autenticación y Roles
El sistema cuenta con **autenticación JWT**. Los usuarios registrados pueden acceder a las diferentes secciones del ERP según el rol que se les haya asignado.
- **Roles soportados**:
- `ADMIN`
- `GERENCIA`
- `VENTAS`
- `BODEGA`
- `INVITADO`
### 2. Módulos
El sistema está estructurado en módulos, los cuales pueden ser gestionados desde la interfaz de administración. Algunos de los módulos principales son:
- **Clientes**: Gestión de los datos de los clientes.
- **Pedidos**: Gestión y seguimiento de los pedidos.
- **Facturación**: Generación y gestión de facturas.
- **Productos**: Gestión del inventario de productos.
- **Reportes PDF y Excel**: Generación de reportes en PDF y Excel para el manejo de información de clientes, ventas e inventarios.
## Desarrollo
### 1. Agregar Nuevas Rutas
Las rutas del sistema están gestionadas a través de **AltoRouter**. Puedes añadir nuevas rutas editando el archivo `app/Config/Routes.php`. Asegúrate de mapear correctamente los controladores y métodos para cada ruta.
### 2. Estructura de las Vistas
Las vistas están organizadas en la carpeta `app/Views`. Si deseas agregar nuevas vistas, simplemente crea los archivos correspondientes en esta carpeta y asegúrate de referenciarlos correctamente en los controladores.

View File

179
app/Config/Modulos.php Normal file
View File

@ -0,0 +1,179 @@
<?php
namespace App\Config;
// No eliminar este módulo, se utilizara para almacenar las notificaciones del sistema
$trash = [
"nombre" => "Notificaciones",
"descripcion" => "Módulos relacionados con las notificaciones del sistema",
"icono" => "fas fa-bell",
"hijos" => [
[
"nombre" => "Ver Notificaciones",
"descripcion" => "Lista de notificaciones del sistema",
"ruta" => "/notificaciones",
"vista" => "modules/notificaciones/index.php",
"icono" => "fa-solid fa-bell",
],
],
];
// Módulos del sistema ordenados por prioridad/importancia
return [
// Core - Módulo principal
[
"nombre" => "Inicio",
"descripcion" => "Página principal del sistema GTD",
"icono" => "fas fa-home",
"ruta" => "/home", // Ruta del módulo sin hijos
],
// Gestión de tareas
[
"nombre" => "Tareas",
"descripcion" => "Módulos relacionados con la gestión de tareas",
"icono" => "fas fa-tasks",
"hijos" => [
[
"nombre" => "Tablero Kanban",
"descripcion" => "Tablero visual de tareas",
"ruta" => "/tareas/kanban",
"vista" => "modules/tareas/kanban.php",
"icono" => "fa-solid fa-th-large",
],
],
],
[
"nombre" => "Fitness",
"descripcion" => "Módulos relacionados con la salud y el bienestar físico",
"icono" => "fas fa-dumbbell",
"hijos" => [
[
"nombre" => "Rutinas de Ejercicio",
"descripcion" => "Crear y gestionar rutinas de entrenamiento",
"ruta" => "/fitness/rutinas",
"vista" => "modules/fitness/rutinas.php",
"icono" => "fa-solid fa-running",
],
[
"nombre" => "Recetas Saludables",
"descripcion" => "Almacenar y visualizar recetas nutritivas",
"ruta" => "/fitness/recetas",
"vista" => "modules/fitness/recetas.php",
"icono" => "fa-solid fa-utensils",
],
[
"nombre" => "Seguimiento de Actividades",
"descripcion" => "Registrar y monitorear actividades físicas realizadas",
"ruta" => "/fitness/seguimiento",
"vista" => "modules/fitness/seguimiento.php",
"icono" => "fa-solid fa-chart-line",
],
],
],
[
"nombre" => "Finanzas",
"descripcion" => "Módulos para gestionar tus finanzas personales",
"icono" => "fas fa-wallet",
"hijos" => [
[
"nombre" => "Ingresos",
"descripcion" => "Registrar y gestionar fuentes de ingresos",
"ruta" => "/finanzas/ingresos",
"vista" => "modules/finanzas/ingresos.php",
"icono" => "fa-solid fa-plus-circle",
],
[
"nombre" => "Gastos",
"descripcion" => "Registrar y categorizar gastos",
"ruta" => "/finanzas/gastos",
"vista" => "modules/finanzas/gastos.php",
"icono" => "fa-solid fa-minus-circle",
],
[
"nombre" => "Presupuestos",
"descripcion" => "Crear y monitorear presupuestos",
"ruta" => "/finanzas/presupuestos",
"vista" => "modules/finanzas/presupuestos.php",
"icono" => "fa-solid fa-chart-pie",
],
[
"nombre" => "Reportes Financieros",
"descripcion" => "Visualizar gráficos y reportes de finanzas personales",
"ruta" => "/finanzas/reportes",
"vista" => "modules/finanzas/reportes.php",
"icono" => "fa-solid fa-chart-line",
],
],
],
[
"nombre" => "Eventos",
"descripcion" => "Módulos para planificar y gestionar tus eventos importantes",
"icono" => "fas fa-calendar-alt",
"hijos" => [
[
"nombre" => "Crear Evento",
"descripcion" => "Crear y programar nuevos eventos",
"ruta" => "/eventos/crear",
"vista" => "modules/eventos/crear.php",
"icono" => "fa-solid fa-plus",
],
[
"nombre" => "Calendario de Eventos",
"descripcion" => "Visualizar eventos en un calendario interactivo",
"ruta" => "/eventos/calendario",
"vista" => "modules/eventos/calendario.php",
"icono" => "fa-solid fa-calendar",
],
[
"nombre" => "Recordatorios de Eventos",
"descripcion" => "Configurar recordatorios para eventos próximos",
"ruta" => "/eventos/recordatorios",
"vista" => "modules/eventos/recordatorios.php",
"icono" => "fa-solid fa-bell",
],
],
],
// Configuración del sistema
[
"nombre" => "Configuración",
"descripcion" =>
"Módulos relacionados con la configuración del sistema",
"icono" => "fas fa-cogs",
"hijos" => [
[
"nombre" => "Preferencias",
"descripcion" => "Gestión de configuraciones del sistema",
"ruta" => "/configuracion",
"vista" => "modules/configuracion/index.php",
"icono" => "fa-solid fa-sliders-h",
],
[
"nombre" => "Plantillas de Correo",
"descripcion" => "Crear y editar plantillas para diferentes tipos de correos",
"ruta" => "/configuracion/correos/plantillas",
"vista" => "modules/correos/plantillas.php",
"icono" => "fa-solid fa-file-alt",
],
[
"nombre" => "Estados de Correo",
"descripcion" => "Gestionar y visualizar los estados de los correos enviados",
"ruta" => "/configuracion/correos/estados",
"vista" => "modules/correos/estados.php",
"icono" => "fa-solid fa-info-circle",
],
[
"nombre" => "Historial de Correos Enviados",
"descripcion" => "Revisar el historial de correos enviados por el sistema",
"ruta" => "/configuracion/correos/historial",
"vista" => "modules/correos/historial.php",
"icono" => "fa-solid fa-history",
],
],
],
];

380
app/Config/Routes.php Normal file
View File

@ -0,0 +1,380 @@
<?php
use App\Controllers\AuthController;
use App\Controllers\HomeController;
use App\Controllers\KanbanController;
use App\Controllers\NotificationController;
use App\Controllers\SettingController;
use App\Controllers\FinanzasController;
use App\Controllers\FitnessController;
use App\Controllers\EventosController;
use Symfony\Component\HttpFoundation\Request;
return [
/**
* Rutas de la aplicación
*/
[
"method" => "GET",
"uri" => "/home",
"target" => [HomeController::class, "showHome"],
"protected" => true,
],
/*
* Rutas del Módulo Fitness
*/
[
"method" => "GET",
"uri" => "/fitness/rutinas",
"target" => [FitnessController::class, "showRutinas"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/fitness/rutinas/crear",
"target" => [FitnessController::class, "showCrearRutina"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/fitness/rutinas/crear",
"target" => [FitnessController::class, "createRutina"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/fitness/rutinas/editar/[i:id]",
"target" => [FitnessController::class, "showEditarRutina"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/fitness/rutinas/editar/[i:id]",
"target" => [FitnessController::class, "editRutina"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/fitness/rutinas/eliminar",
"target" => [FitnessController::class, "deleteRutina"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/fitness/recetas",
"target" => [FitnessController::class, "showRecetas"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/fitness/recetas/crear",
"target" => [FitnessController::class, "showCrearReceta"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/fitness/recetas/crear",
"target" => [FitnessController::class, "createReceta"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/fitness/recetas/editar/[i:id]",
"target" => [FitnessController::class, "showEditarReceta"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/fitness/recetas/editar/[i:id]",
"target" => [FitnessController::class, "editReceta"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/fitness/recetas/eliminar",
"target" => [FitnessController::class, "deleteReceta"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/fitness/seguimiento",
"target" => [FitnessController::class, "showSeguimiento"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/fitness/seguimiento/registrar",
"target" => [FitnessController::class, "registrarActividad"],
"protected" => true,
],
/*
* Rutas de Tareas
*/
[
"method" => "GET",
"uri" => "/tareas",
"target" => [KanbanController::class, "getAllTasks"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/tarea/get/[i:id]",
"target" => [KanbanController::class, "getTask"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/tarea/create",
"target" => [KanbanController::class, "createTask"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/tarea/update",
"target" => [KanbanController::class, "updateTask"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/tarea/update-status",
"target" => [KanbanController::class, "updateTaskStatus"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/tarea/delete",
"target" => [KanbanController::class, "deleteTask"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/tareas/kanban",
"target" => [KanbanController::class, "showIndex"],
"protected" => true,
],
/*
* Rutas del Módulo Finanzas
*/
[
"method" => "GET",
"uri" => "/finanzas/ingresos",
"target" => [FinanzasController::class, "showIngresos"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/finanzas/ingresos/crear",
"target" => [FinanzasController::class, "createIngreso"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/finanzas/ingresos/eliminar",
"target" => [FinanzasController::class, "deleteIngreso"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/finanzas/gastos",
"target" => [FinanzasController::class, "showGastos"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/finanzas/gastos/crear",
"target" => [FinanzasController::class, "createGasto"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/finanzas/gastos/eliminar",
"target" => [FinanzasController::class, "deleteGasto"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/finanzas/presupuestos",
"target" => [FinanzasController::class, "showPresupuestos"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/finanzas/presupuestos/crear",
"target" => [FinanzasController::class, "createPresupuesto"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/finanzas/presupuestos/eliminar",
"target" => [FinanzasController::class, "deletePresupuesto"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/finanzas/reportes",
"target" => [FinanzasController::class, "showReportes"],
"protected" => true,
],
/*
* Rutas del Módulo Eventos
*/
[
"method" => "GET",
"uri" => "/eventos/crear",
"target" => [EventosController::class, "showCrearEvento"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/eventos/crear",
"target" => [EventosController::class, "createEvento"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/eventos/calendario",
"target" => [EventosController::class, "showCalendario"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/eventos/recordatorios",
"target" => [EventosController::class, "showRecordatorios"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/eventos/recordatorios/crear",
"target" => [EventosController::class, "createRecordatorio"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/eventos/recordatorios/eliminar",
"target" => [EventosController::class, "deleteRecordatorio"],
"protected" => true,
],
/*
* Rutas de Notificaciones
*/
[
"method" => "GET",
"uri" => "/notificaciones",
"target" => [NotificationController::class, "showIndex"],
"protected" => true,
],
/*
* Rutas de Configuraciones
*/
[
"method" => "GET",
"uri" => "/configuracion",
"target" => [SettingController::class, "showSettings"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/configuracion/update",
"target" => [SettingController::class, "updateSettings"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/configuracion/correos/plantillas",
"target" => [SettingController::class, "showPlantillas"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/configuracion/correos/plantillas/crear",
"target" => [SettingController::class, "showCrearPlantilla"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/configuracion/correos/plantillas/crear",
"target" => [SettingController::class, "createPlantilla"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/configuracion/correos/plantillas/editar/[i:id]",
"target" => [SettingController::class, "showEditarPlantilla"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/configuracion/correos/plantillas/editar/[i:id]",
"target" => [SettingController::class, "editPlantilla"],
"protected" => true,
],
[
"method" => "POST",
"uri" => "/configuracion/correos/plantillas/eliminar",
"target" => [SettingController::class, "deletePlantilla"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/configuracion/correos/estados",
"target" => [SettingController::class, "showEstados"],
"protected" => true,
],
[
"method" => "GET",
"uri" => "/configuracion/correos/historial",
"target" => [SettingController::class, "showHistorial"],
"protected" => true,
],
/*
* Rutas de autenticación
*/
[
"method" => "GET",
"uri" => "/login",
"target" => function () {
(new AuthController())->showLogin();
},
"protected" => false,
],
[
"method" => "POST",
"uri" => "/login",
"target" => function () {
$request = Request::createFromGlobals();
(new AuthController())->login($request);
},
"protected" => false,
],
[
"method" => "GET",
"uri" => "/register",
"target" => function () {
(new AuthController())->showRegister();
},
"protected" => false,
],
[
"method" => "POST",
"uri" => "/register",
"target" => function () {
$request = Request::createFromGlobals();
(new AuthController())->register($request);
},
"protected" => false,
],
[
"method" => "GET",
"uri" => "/logout",
"target" => function () {
(new AuthController())->logout();
},
"protected" => true,
],
];

View File

@ -0,0 +1,174 @@
<?php
namespace App\Controllers;
use App\Core\Database;
use App\Core\HttpHelper;
use App\Repositories\UserRepository;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Symfony\Component\HttpFoundation\Request;
class AuthController extends BaseController
{
protected $userRepository;
private $jwtSecret;
public function __construct()
{
$database = new Database();
$this->userRepository = new UserRepository($database);
$this->jwtSecret = $_ENV["JWT_SECRET"];
}
public function showLogin()
{
$this->render("auth/login");
}
public function login(Request $request)
{
$email = HttpHelper::getParam(
$request,
"email",
null,
FILTER_SANITIZE_EMAIL
);
$password = HttpHelper::getParam($request, "password");
$user = $this->userRepository->findBy("email", $email);
if (!$user) {
$this->render("auth/login", [
"error" => "Credenciales incorrectas",
]);
return;
}
if (!password_verify($password, $user["password_hash"])) {
$this->render("auth/login", ["error" => "Contraseña incorrecta"]);
return;
}
$payload = [
"user_id" => $user["user_id"],
"email" => $user["email"],
"exp" => time() + 60 * 60, // Expiración de 1 hora
];
$jwt = JWT::encode($payload, $this->jwtSecret, "HS256");
setcookie("jwt", $jwt, time() + 60 * 60, "/", "", false, true);
header("Location: /home");
}
public function showRegister()
{
$this->render("auth/register");
}
public function register(Request $request)
{
$username = HttpHelper::getParam($request, "username");
$email = HttpHelper::getParam(
$request,
"email",
null,
FILTER_SANITIZE_EMAIL
);
$passwordPlain = HttpHelper::getParam($request, "password");
$errors = []; // Guardar errores
$validations = [
"username" => [
"required" => true,
"message" => "El nombre de usuario es obligatorio.",
],
"email" => [
"required" => true,
"message" => "El correo electrónico es obligatorio.",
"validate" => function ($value) {
return filter_var($value, FILTER_VALIDATE_EMAIL)
? null
: "El correo electrónico no es válido.";
},
],
"password" => [
"required" => true,
"message" => "La contraseña es obligatoria.",
"minLength" => 8,
"lengthMessage" =>
"La contraseña debe tener al menos 8 caracteres.",
],
];
$inputData = [
"username" => $username,
"email" => $email,
"password" => $passwordPlain,
];
foreach ($validations as $key => $rules) {
$value = $inputData[$key] ?? null;
if ($rules["required"] && empty($value)) {
$errors[$key] = $rules["message"];
continue;
}
if (
isset($rules["minLength"]) &&
!is_null($value) &&
strlen($value) < $rules["minLength"]
) {
$errors[$key] = $rules["lengthMessage"];
}
if (isset($rules["validate"]) && empty($errors[$key])) {
$customError = $rules["validate"]($value);
if ($customError) {
$errors[$key] = $customError;
}
}
if ($key === "email" && empty($errors[$key])) {
$existingUser = $this->userRepository->findBy("email", $email);
if ($existingUser) {
$errors[$key] =
"El correo electrónico ya está registrado. Por favor, utiliza otro correo o inicia sesión.";
}
}
}
if (!empty($errors)) {
$this->render("auth/register", [
"errors" => $errors,
"old" => ["username" => $username, "email" => $email],
]);
return;
}
// Continuar con el registro
$password = password_hash($passwordPlain, PASSWORD_DEFAULT);
$userData = [
"username" => $username,
"email" => $email,
"password_hash" => $password,
];
$this->userRepository->insert($userData);
$this->render("auth/register", [
"success" => "Usuario registrado con éxito.",
]);
}
public function logout()
{
setcookie("jwt", "", time() - 3600, "/"); // Eliminar la cookie JWT
header("Location: /login");
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Controllers;
use App\Core\Database;
use App\Repositories\BaseRepository;
class BaseController
{
protected $repository;
public function __construct(BaseRepository $repository)
{
$this->repository = $repository;
}
// Obtener todos los registros
public function index()
{
return $this->repository->getAll();
}
// Crear un nuevo registro
public function create(array $data)
{
return $this->repository->insert($data);
}
// Actualizar un registro
public function update($id, array $data)
{
return $this->repository->update($id, $data);
}
// Eliminar un registro
public function delete($id)
{
return $this->repository->delete($id);
}
// Eliminar un registro activo
public function deleteActive($id)
{
return $this->repository->deleteActive($id);
}
// Renderizar una vista
protected function render($view, $data = [])
{
extract($data);
$viewPath = realpath(__DIR__ . "/../Views/{$view}.php");
if (file_exists($viewPath)) {
include $viewPath;
} else {
echo "Error: La vista '{$view}' no se encontró en {$viewPath}.";
}
}
}

View File

@ -0,0 +1,193 @@
<?php
namespace App\Controllers;
use App\Core\HttpHelper;
use App\Repositories\ClienteRepository;
class ClienteController extends BaseController
{
public function __construct()
{
$clienteRepository = new ClienteRepository(new \App\Core\Database());
parent::__construct($clienteRepository);
}
/**
* Muestra la lista de clientes
*/
public function showIndex()
{
$request = HttpHelper::getRequest();
$page = HttpHelper::getParam($request, "page", 1); // Por defecto, página 1
$perPage = 8; // Número de clientes por página
$totalClientes = $this->repository->countAll();
$clientes = $this->repository->getPaginated($page, $perPage);
$totalPages = ceil($totalClientes / $perPage);
$this->render("modules/clientes/index", [
"clientes" => $clientes,
"currentPage" => $page,
"totalPages" => $totalPages,
]);
}
/**
* Obtener datos de un cliente por ID
*/
public function getCliente($id)
{
$cliente = $this->repository->findById($id);
if ($cliente) {
return HttpHelper::createJsonResponse([
"status" => "success",
"cliente" => $cliente,
]);
}
return HttpHelper::createJsonResponse(
[
"status" => "error",
"message" => "Cliente no encontrado",
],
404
);
}
/**
* Crea un nuevo cliente
*/
public function createCliente()
{
$request = HttpHelper::getRequest();
$nombre = HttpHelper::getParam($request, "nombre");
$direccion = HttpHelper::getParam($request, "direccion");
$telefono = HttpHelper::getParam($request, "telefono");
$email = HttpHelper::getParam($request, "email");
$nit = HttpHelper::getParam($request, "nit");
$cui = HttpHelper::getParam($request, "cui") ?: null;
$fecha_nacimiento = HttpHelper::getParam($request, "fecha_nacimiento");
if (!$nombre || !$nit) {
return HttpHelper::createJsonResponse(
[
"status" => "error",
"message" => "Nombre y NIT son obligatorios",
],
400
);
}
$data = [
"nombre" => $nombre,
"direccion" => $direccion,
"telefono" => $telefono,
"email" => $email,
"nit" => $nit,
"cui" => $cui,
"fecha_nacimiento" => $fecha_nacimiento,
];
$result = $this->repository->insert($data);
if ($result) {
return HttpHelper::createJsonResponse([
"status" => "success",
"message" => "Cliente agregado exitosamente",
]);
}
return HttpHelper::createJsonResponse(
["status" => "error", "message" => "Error al agregar cliente"],
500
);
}
/**
* Actualiza un cliente existente
*/
public function updateCliente()
{
$requestBody = file_get_contents("php://input");
$requestData = json_decode($requestBody, true);
$id = $requestData["id"] ?? null;
$nombre = $requestData["nombre"] ?? null;
$direccion = $requestData["direccion"] ?? null;
$telefono = $requestData["telefono"] ?? null;
$email = $requestData["email"] ?? null;
$nit = $requestData["nit"] ?? null;
$cui = $requestData["cui"] ?? null;
$fecha_nacimiento = $requestData["fecha_nacimiento"] ?? null;
$active = $requestData["active"] ?? 0;
if (!$id || !$nombre || !$nit) {
return HttpHelper::createJsonResponse(
[
"status" => "error",
"message" => "ID, Nombre y NIT son obligatorios",
],
400
);
}
$data = [
"nombre" => $nombre,
"direccion" => $direccion,
"telefono" => $telefono,
"email" => $email,
"nit" => $nit,
"cui" => $cui,
"fecha_nacimiento" => $fecha_nacimiento,
"active" => $active,
];
$result = $this->repository->update($id, $data);
if ($result) {
return HttpHelper::createJsonResponse([
"status" => "success",
"message" => "Cliente actualizado exitosamente",
]);
}
return HttpHelper::createJsonResponse(
["status" => "error", "message" => "Error al actualizar cliente"],
500
);
}
/**
* Elimina un cliente
*/
public function deleteCliente()
{
$request = HttpHelper::getRequest();
$id = HttpHelper::getParam($request, "id");
if (!$id) {
return HttpHelper::createJsonResponse(
["status" => "error", "message" => "El ID es obligatorio"],
400
);
}
$result = $this->repository->deleteActive($id);
if ($result) {
return HttpHelper::createJsonResponse([
"status" => "success",
"message" => "Cliente eliminado",
]);
}
return HttpHelper::createJsonResponse(
["status" => "error", "message" => "Error al eliminar cliente"],
500
);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Controllers;
class ErrorController extends ViewController
{
private function sendErrorHeader($statusCode, $view)
{
http_response_code($statusCode);
$this->render($view);
exit;
}
public function notFound()
{
$this->sendErrorHeader(404, 'error/404');
}
public function forbidden()
{
$this->sendErrorHeader(403, 'error/403');
}
public function internalServerError()
{
$this->sendErrorHeader(500, 'error/500');
}
public function badRequest()
{
$this->sendErrorHeader(400, 'error/400');
}
public function unauthorized()
{
$this->sendErrorHeader(401, 'error/401');
}
public function requestTimeout()
{
$this->sendErrorHeader(408, 'error/408');
}
public function serviceUnavailable()
{
$this->sendErrorHeader(503, 'error/503');
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Controllers;
class EventosController extends BaseController
{
// Add your controller methods here
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Controllers;
use App\Entities\Exercise;
use App\Repositories\ExerciseRepository;
use App\Core\Database;
use App\Core\HttpHelper;
use App\Helpers\AuthHelper;
class ExerciseController extends BaseController
{
protected ExerciseRepository $exerciseRepository;
public function __construct()
{
$database = new Database();
$this->exerciseRepository = new ExerciseRepository($database);
parent::__construct($this->exerciseRepository);
}
public function showExercises()
{
$exercises = $this->exerciseRepository->getAll();
$this->render('modules/fitness/exercises', ['exercises' => $exercises]);
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Controllers;
class FinanzasController extends BaseController
{
// Add your controller methods here
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Controllers;
class FitnessController extends ViewController
{
// Add your controller methods here
public function showRutinas()
{
$this->render('modules/fitness/rutinas');
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Controllers;
class HomeController extends ViewController
{
public function showHome()
{
$this->render('home/home');
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Controllers;
use App\Entities\Task;
use App\Repositories\TaskRepository;
use App\Core\Database;
use App\Core\HttpHelper;
use App\Helpers\AuthHelper;
class KanbanController extends BaseController
{
protected TaskRepository $taskRepository;
public function __construct()
{
$database = new Database();
$this->taskRepository = new TaskRepository($database);
parent::__construct($this->taskRepository);
}
public function showIndex()
{
$tasks = $this->taskRepository->getAll();
$this->render("modules/tareas/kanban", compact('tasks'));
}
public function getAllTasks()
{
$tasks = $this->taskRepository->getAll();
HttpHelper::createJsonResponse(['tasks' => $tasks], 200);
}
public function getTask($id)
{
$task = $this->taskRepository->findById($id);
if ($task) {
HttpHelper::createJsonResponse($task);
} else {
HttpHelper::createJsonResponse(["message" => "No se encontró la tarea"], 404);
}
}
public function createTask()
{
$request = HttpHelper::getRequest();
$userId = AuthHelper::getUserIdFromToken();
if (!$userId) {
HttpHelper::createJsonResponse(["message" => "Usuario no autenticado."], 401);
return;
}
$data = [
'task_title' => HttpHelper::getParam($request, 'task_title', '', FILTER_SANITIZE_STRING),
'description' => HttpHelper::getParam($request, 'description', null, FILTER_SANITIZE_STRING),
'due_date' => HttpHelper::getParam($request, 'due_date', null, FILTER_SANITIZE_STRING),
'color' => HttpHelper::getParam($request, 'color', '#e9ecef', FILTER_SANITIZE_STRING),
'status_id' => HttpHelper::getParam($request, 'status_id', 1, FILTER_VALIDATE_INT),
'user_id' => $userId,
];
error_log("Datos recibidos para la creación de la tarea: " . print_r($data, true));
$task_id = $this->taskRepository->insert($data);
if ($task_id) {
HttpHelper::createJsonResponse(["task_id" => $task_id], 201);
} else {
HttpHelper::createJsonResponse(["message" => "No se pudo crear la tarea"], 400);
}
}
public function updateTask()
{
$request = HttpHelper::getRequest();
$taskId = HttpHelper::getParam($request, 'task_id', null, FILTER_VALIDATE_INT);
if (!$taskId) {
HttpHelper::createJsonResponse(["message" => "El ID de la tarea es obligatorio."], 400);
return;
}
$data = [
'task_title' => HttpHelper::getParam($request, 'task_title', '', FILTER_SANITIZE_STRING),
'description' => HttpHelper::getParam($request, 'description', null, FILTER_SANITIZE_STRING),
'due_date' => HttpHelper::getParam($request, 'due_date', null, FILTER_SANITIZE_STRING),
'color' => HttpHelper::getParam($request, 'color', '#e9ecef', FILTER_SANITIZE_STRING),
'status_id' => HttpHelper::getParam($request, 'status_id', 1, FILTER_VALIDATE_INT)
];
if ($this->taskRepository->update($taskId, $data)) {
HttpHelper::createJsonResponse(["message" => "Tarea actualizada exitosamente."]);
} else {
HttpHelper::createJsonResponse(["message" => "Error al actualizar la tarea."], 500);
}
}
public function updateTaskStatus()
{
$request = HttpHelper::getRequest();
$taskId = HttpHelper::getParam($request, 'task_id', null, FILTER_VALIDATE_INT);
if (!$taskId) {
HttpHelper::createJsonResponse(["message" => "El ID de la tarea es obligatorio."], 400);
return;
}
$statusId = HttpHelper::getParam($request, 'status_id', null, FILTER_VALIDATE_INT);
if (!$statusId) {
HttpHelper::createJsonResponse(["message" => "El ID de estado es obligatorio."], 400);
return;
}
$data = [
'status_id' => $statusId,
'updated_at' => date('Y-m-d H:i:s')
];
if ($this->taskRepository->update($taskId, $data)) {
HttpHelper::createJsonResponse(["message" => "Estado de la tarea actualizado exitosamente."]);
} else {
HttpHelper::createJsonResponse(["message" => "Error al actualizar el estado de la tarea."], 500);
}
}
public function deleteTask()
{
$request = HttpHelper::getRequest();
$taskId = HttpHelper::getParam($request, 'task_id', null, FILTER_VALIDATE_INT);
if (!$taskId) {
HttpHelper::createJsonResponse(["message" => "El ID de la tarea es obligatorio."], 400);
return;
}
if ($this->taskRepository->delete($taskId)) {
HttpHelper::createJsonResponse(["message" => "Tarea eliminada exitosamente."]);
} else {
HttpHelper::createJsonResponse(["message" => "Error al eliminar la tarea."], 500);
}
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Controllers;
class NotificationController extends BaseController
{
// Add your controller methods here
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Controllers;
class SettingController extends BaseController
{
// Add your controller methods here
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Controllers;
/**
*
* Este controlador se utiliza cuando quieres mostrar una vista en la aplicación.
* Se encarga de renderizar la vista y pasarle los datos necesarios. Fue creada para
* vistas que solo muestran información y no requieren de lógica del repositorio. Pero
* puedes usar logica de PHP para precargar datos en la vista.
*
* Author: David Vargas
* Last Modified: 22/09/2024 13:30
*
*/
class ViewController
{
public function render($view, $data = [])
{
extract($data);
$viewPath = realpath(__DIR__ . "/../Views/{$view}.php");
if (file_exists($viewPath)) {
include $viewPath;
} else {
echo "Error: La vista '{$view}' no se encontró en {$viewPath}.";
}
}
}

42
app/Core/Database.php Normal file
View File

@ -0,0 +1,42 @@
<?php
namespace App\Core;
use Medoo\Medoo;
use Dotenv\Dotenv;
use PDO;
use PDOException;
use Exception;
class Database
{
protected $connection;
public function __construct()
{
try {
$dotenv = Dotenv::createImmutable(__DIR__ . "/../../");
$dotenv->load();
$this->connection = new Medoo([
'type' => $_ENV['DB_DRIVER'],
'host' => $_ENV['DB_HOST'],
'database' => $_ENV['DB_NAME'],
'username' => $_ENV['DB_USER'],
'password' => $_ENV['DB_PASS'],
'charset' => $_ENV['DB_CHARSET'],
'error' => PDO::ERRMODE_EXCEPTION,
'timeout' => 15,
]);
} catch (PDOException $e) {
throw new Exception("Error al conectar con la base de datos: " . $e->getMessage());
} catch (Exception $e) {
throw new Exception("Error configuracion base de datos: " . $e->getMessage());
}
}
public function getConnection()
{
return $this->connection;
}
}

118
app/Core/HttpHelper.php Normal file
View File

@ -0,0 +1,118 @@
<?php
namespace App\Core;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
class HttpHelper
{
/**
* Obtiene una solicitud desde las variables globales ($_GET, $_POST, etc.)
*
* @return Request
*/
public static function getRequest(): Request
{
return Request::createFromGlobals();
}
/**
* Crea una respuesta HTML estándar.
*
* @param string $content
* @param int $statusCode
* @param array $headers
* @return Response
*/
public static function createResponse(
string $content,
int $statusCode = 200,
array $headers = []
): Response {
return new Response($content, $statusCode, $headers);
}
/**
* Crea una respuesta JSON.
*
* @param array $data
* @param int $statusCode
* @param array $headers
* @return void
*/
public static function createJsonResponse(
array $data,
int $statusCode = 200,
array $headers = []
) {
http_response_code($statusCode);
header("Content-Type: application/json");
foreach ($headers as $key => $value) {
header("{$key}: {$value}");
}
echo json_encode($data);
exit();
}
/**
* Redirige a una URL específica.
*
* @param string $url
* @param int $statusCode
* @return RedirectResponse
*/
public static function redirect(
string $url,
int $statusCode = 302
): RedirectResponse {
return new RedirectResponse($url, $statusCode);
}
/**
* Extrae un parámetro de la solicitud (GET, POST, etc.) con saneamiento.
* Si es una solicitud JSON, extrae los datos del cuerpo JSON.
*
* @param Request $request
* @param string $key
* @param mixed $default
* @param int|null $filter
* @return mixed
*/
public static function getParam(
Request $request,
string $key,
$default = null,
int $filter = null
) {
$contentType = $request->headers->get("Content-Type", "");
if (strpos($contentType, "application/json") === 0) {
$data = json_decode($request->getContent(), true);
$value = $data[$key] ?? $default;
} else {
$value = $request->get($key, $default);
}
if ($filter !== null) {
return filter_var($value, $filter);
}
return $value;
}
/**
* Obtiene la IP del cliente de manera segura.
*
* @param Request $request
* @return string|null
*/
public static function getClientIp(Request $request): ?string
{
return $request->getClientIp();
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Entities;
abstract class BaseEntity
{
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
abstract public static function getPrimaryKey(): string;
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Entities;
class EmailStatus extends BaseEntity
{
/**
* @var int Identificador único del estado de correo.
*/
public int $status_id;
/**
* @var string Nombre del estado de correo.
*/
public string $status_name;
/**
* @var string|null Descripción del estado de correo.
*/
public ?string $description;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "status_id";
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Entities;
class EmailTemplate extends BaseEntity
{
/**
* @var int Identificador único de la plantilla de correo.
*/
public int $template_id;
/**
* @var string Nombre de la plantilla.
*/
public string $template_name;
/**
* @var string Contenido HTML de la plantilla.
*/
public string $html_content;
/**
* @var string Fecha de creación de la plantilla.
*/
public string $created_at;
/**
* @var string|null Fecha de última actualización de la plantilla.
*/
public ?string $updated_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "template_id";
}
}

63
app/Entities/Exercise.php Normal file
View File

@ -0,0 +1,63 @@
<?php
namespace App\Entities;
use App\Entities\BaseEntity;
class Exercise extends BaseEntity
{
/**
* @var int Identificador único de Exercise.
*/
public int $exercise_id;
/**
* @var string Nombre del ejercicio.
*/
public string $name;
/**
* @var string tipo de ejercicio.
*/
public string $type;
/**
* @var string musculo principal.
*/
public string $muscle;
/**
* @var string Equipamiento necesario.
*/
public string $equipment;
/**
* @var string Dificultad del ejercicio.
*/
public string $difficulty;
/**
* @var string instrucciones del ejercicio.
*/
public string $instructions;
/**
* @var string Fecha de creación de la tarea.
*/
public string $created_at;
/**
* @var string|null Fecha de última actualización de la tarea.
*/
public ?string $updated_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return 'exercise_id';
}
}

56
app/Entities/Expense.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace App\Entities;
class Expense extends BaseEntity
{
/**
* @var int Identificador único del gasto.
*/
public int $expense_id;
/**
* @var int Identificador del usuario asociado al gasto.
*/
public int $user_id;
/**
* @var int Identificador de la categoría de gasto.
*/
public int $category_id;
/**
* @var string Nombre del gasto.
*/
public string $expense_name;
/**
* @var float Monto del gasto.
*/
public float $amount;
/**
* @var int Identificador de la frecuencia del gasto.
*/
public int $frequency_id;
/**
* @var string Fecha de creación del gasto.
*/
public string $created_at;
/**
* @var string|null Fecha de última actualización del gasto.
*/
public ?string $updated_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "expense_id";
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Entities;
class ExpenseCategory extends BaseEntity
{
/**
* @var int Identificador único de la categoría de gasto.
*/
public int $category_id;
/**
* @var string Nombre de la categoría de gasto.
*/
public string $category_name;
/**
* @var string|null Descripción de la categoría de gasto.
*/
public ?string $description;
/**
* @var string Fecha de creación de la categoría.
*/
public string $created_at;
/**
* @var string|null Fecha de última actualización de la categoría.
*/
public ?string $updated_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "category_id";
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Entities;
class Frequency extends BaseEntity
{
/**
* @var int Identificador único de la frecuencia.
*/
public int $frequency_id;
/**
* @var string Nombre de la frecuencia.
*/
public string $frequency_name;
/**
* @var string|null Descripción de la frecuencia.
*/
public ?string $description;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "frequency_id";
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Entities;
class HealthyRecipe extends BaseEntity
{
/**
* @var int Identificador único de la receta.
*/
public int $recipe_id;
/**
* @var int Identificador del usuario asociado a la receta.
*/
public int $user_id;
/**
* @var string Nombre de la receta.
*/
public string $recipe_name;
/**
* @var string|null Descripción de la receta.
*/
public ?string $description;
/**
* @var array Lista de ingredientes de la receta.
*/
public array $ingredients;
/**
* @var string Instrucciones para preparar la receta.
*/
public string $instructions;
/**
* @var int Número de calorías de la receta.
*/
public int $calories;
/**
* @var string Fecha de creación de la receta.
*/
public string $created_at;
/**
* @var string|null Fecha de última actualización de la receta.
*/
public ?string $updated_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "recipe_id";
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Entities;
class ImportantEvent extends BaseEntity
{
/**
* @var int Identificador único del evento importante.
*/
public int $event_id;
/**
* @var int Identificador del usuario asociado al evento.
*/
public int $user_id;
/**
* @var string Nombre del evento.
*/
public string $event_name;
/**
* @var string Fecha del evento.
*/
public string $event_date;
/**
* @var string|null Descripción del evento.
*/
public ?string $description;
/**
* @var string Fecha de creación del evento.
*/
public string $created_at;
/**
* @var string|null Fecha de última actualización del evento.
*/
public ?string $updated_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "event_id";
}
}

51
app/Entities/Income.php Normal file
View File

@ -0,0 +1,51 @@
<?php
namespace App\Entities;
class Income extends BaseEntity
{
/**
* @var int Identificador único del ingreso.
*/
public int $income_id;
/**
* @var int Identificador del usuario asociado al ingreso.
*/
public int $user_id;
/**
* @var string Nombre del ingreso.
*/
public string $income_name;
/**
* @var float Monto del ingreso.
*/
public float $amount;
/**
* @var int Identificador de la frecuencia del ingreso.
*/
public int $frequency_id;
/**
* @var string Fecha de creación del ingreso.
*/
public string $created_at;
/**
* @var string|null Fecha de última actualización del ingreso.
*/
public ?string $updated_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "income_id";
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Entities;
class Notification extends BaseEntity
{
/**
* @var int Identificador único de la notificación.
*/
public int $notification_id;
/**
* @var int Identificador del usuario asociado a la notificación.
*/
public int $user_id;
/**
* @var int Identificador del tipo de notificación.
*/
public int $type_id;
/**
* @var string Mensaje de la notificación.
*/
public string $message;
/**
* @var bool Indica si la notificación ha sido leída.
*/
public bool $is_read;
/**
* @var string Fecha de creación de la notificación.
*/
public string $created_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "notification_id";
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Entities;
class NotificationType extends BaseEntity
{
/**
* @var int Identificador único del tipo de notificación.
*/
public int $type_id;
/**
* @var string Nombre del tipo de notificación.
*/
public string $type_name;
/**
* @var string|null Descripción del tipo de notificación.
*/
public ?string $description;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "type_id";
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Entities;
class SentEmail extends BaseEntity
{
/**
* @var int Identificador único del correo enviado.
*/
public int $email_id;
/**
* @var int Identificador del usuario que envió el correo.
*/
public int $user_id;
/**
* @var int Identificador de la plantilla del correo.
*/
public int $template_id;
/**
* @var int Identificador del estado del correo.
*/
public int $status_id;
/**
* @var string Asunto del correo.
*/
public string $subject;
/**
* @var string Cuerpo del mensaje del correo.
*/
public string $message;
/**
* @var string Fecha en la que se envió el correo.
*/
public string $sent_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "email_id";
}
}

41
app/Entities/Setting.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Entities;
class Setting extends BaseEntity
{
/**
* @var int Identificador único de la configuración.
*/
public int $setting_id;
/**
* @var string Clave de la configuración.
*/
public string $setting_key;
/**
* @var string Valor de la configuración.
*/
public string $setting_value;
/**
* @var string|null Descripción de la configuración.
*/
public ?string $description;
/**
* @var string Fecha de creación de la configuración.
*/
public string $created_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "setting_id";
}
}

61
app/Entities/Task.php Normal file
View File

@ -0,0 +1,61 @@
<?php
namespace App\Entities;
class Task extends BaseEntity
{
/**
* @var int Identificador único de la tarea.
*/
public int $task_id;
/**
* @var int Identificador del usuario que creó la tarea.
*/
public int $user_id;
/**
* @var int Identificador del estado de la tarea.
*/
public int $status_id;
/**
* @var string Título de la tarea.
*/
public string $task_title;
/**
* @var string Color de la tarea.
*/
public string $color;
/**
* @var string|null Descripción de la tarea.
*/
public ?string $description;
/**
* @var string Fecha de vencimiento de la tarea.
*/
public string $due_date;
/**
* @var string Fecha de creación de la tarea.
*/
public string $created_at;
/**
* @var string|null Fecha de última actualización de la tarea.
*/
public ?string $updated_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "task_id";
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Entities;
class TaskStatus extends BaseEntity
{
/**
* @var int Identificador único del estado de la tarea.
*/
public int $status_id;
/**
* @var string Nombre del estado.
*/
public string $status_name;
/**
* @var string|null Descripción del estado.
*/
public ?string $description;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "status_id";
}
}

46
app/Entities/User.php Normal file
View File

@ -0,0 +1,46 @@
<?php
namespace App\Entities;
class User extends BaseEntity
{
/**
* @var int Identificador único del usuario.
*/
public int $user_id;
/**
* @var string Nombre de usuario.
*/
public string $username;
/**
* @var string Correo electrónico del usuario.
*/
public string $email;
/**
* @var string Hash de la contraseña.
*/
public string $password_hash;
/**
* @var string Fecha de creación.
*/
public string $created_at;
/**
* @var string|null Fecha de última actualización.
*/
public ?string $updated_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "user_id";
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Entities;
class WorkoutRoutine extends BaseEntity
{
/**
* @var int Identificador único de la rutina.
*/
public int $routine_id;
/**
* @var int Identificador del usuario asociado.
*/
public int $user_id;
/**
* @var string Nombre de la rutina.
*/
public string $routine_name;
/**
* @var string|null Descripción de la rutina.
*/
public ?string $description;
/**
* @var string Tipo de rutina.
*/
public string $routine_type;
/**
* @var array array de ejercicios asociados a la rutina.
*/
public array $exercises;
/**
* @var int tiempo en minutos de la rutina.
*/
public int $duration;
/**
* @var string fecha de creación de la rutina.
*/
public string $created_at;
/**
* @var string|null fecha de última actualización de la rutina.
*/
public ?string $updated_at;
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return "routine_id";
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Helpers;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class AuthHelper
{
public static function getUserIdFromToken(): ?int
{
$jwt = $_COOKIE["jwt"] ?? null;
if (!$jwt) {
return null;
}
try {
$decoded = JWT::decode($jwt, new Key($_ENV["JWT_SECRET"], "HS256"));
return $decoded->user_id ?? null;
} catch (\Throwable $th) {
return null;
}
}
}

View File

@ -0,0 +1,315 @@
<?php
namespace App\Helpers;
use DOMDocument;
use DOMElement;
use JsonException;
use RuntimeException;
use WeakMap;
use Stringable;
use ValueError;
class SerializeHelper
{
private const JSON_OPTIONS =
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
private const XML_VERSION = "1.0";
private const XML_ENCODING = "UTF-8";
/**
* Caché usando WeakMap para mejor gestión de memoria
*/
private static WeakMap $cache;
public function __construct()
{
self::$cache = new WeakMap();
}
/**
* Convierte datos a JSON
*
* @template T
* @param T $data
* @return string
* @throws JsonException
*/
public static function toJson(mixed $data): string
{
try {
return json_encode(
self::normalize($data),
self::JSON_OPTIONS | JSON_THROW_ON_ERROR
);
} catch (JsonException $e) {
throw new JsonException(
"Error en la serialización JSON: {$e->getMessage()}"
);
}
}
/**
* Convierte datos a XML
*/
public static function toXml(
mixed $data,
string $rootElement = "root"
): string {
$dom = new DOMDocument(self::XML_VERSION, self::XML_ENCODING);
$dom->formatOutput = true;
$root = $dom->createElement($rootElement);
$dom->appendChild($root);
self::arrayToDomElement($data, $root, $dom);
return $dom->saveXML() ?:
throw new RuntimeException("Error generando XML");
}
/**
* Método auxiliar para convertir array a elementos DOM
*/
private static function arrayToDomElement(
mixed $data,
DOMElement $parent,
DOMDocument $dom
): void {
$data = match (true) {
$data instanceof Stringable => (string) $data,
is_object($data) => (array) $data,
default => $data,
};
foreach ((array) $data as $key => $value) {
$key = preg_replace("/[^a-z0-9_]/i", "_", (string) $key) ?: "item";
$child = match (true) {
is_array($value),
is_object($value)
=> self::createComplexElement($dom, $key, $value),
default => self::createSimpleElement($dom, $key, $value),
};
$parent->appendChild($child);
}
}
/**
* Crea un elemento XML complejo
*/
private static function createComplexElement(
DOMDocument $dom,
string $key,
mixed $value
): DOMElement {
$element = $dom->createElement($key);
self::arrayToDomElement($value, $element, $dom);
return $element;
}
/**
* Crea un elemento XML simple
*/
private static function createSimpleElement(
DOMDocument $dom,
string $key,
mixed $value
): DOMElement {
$element = $dom->createElement($key);
$element->appendChild($dom->createTextNode((string) $value));
return $element;
}
/**
* Serializa datos de manera optimizada
*/
public static function serialize(mixed $data): string
{
return match (true) {
extension_loaded("igbinary") => igbinary_serialize($data),
default => serialize($data),
};
}
/**
* Deserializa datos
*/
public static function unserialize(string $data): mixed
{
return match (true) {
extension_loaded("igbinary") &&
!str_starts_with($data, "a:") &&
!str_starts_with($data, "O:")
=> igbinary_unserialize($data),
default => unserialize($data, ["allowed_classes" => true]),
};
}
/**
* Compara objetos o arrays
*/
public static function compare(mixed $obj1, mixed $obj2): array
{
return self::calculateDifferences(
self::normalize($obj1),
self::normalize($obj2)
);
}
/**
* Calcula diferencias entre arrays
*/
private static function calculateDifferences(
array $array1,
array $array2,
string $path = ""
): array {
$differences = [];
foreach ($array1 as $key => $value) {
$currentPath = $path ? "{$path}.{$key}" : $key;
if (!array_key_exists($key, $array2)) {
$differences[$currentPath] = [
"type" => "removed",
"value" => $value,
];
continue;
}
$differences = match (true) {
is_array($value) && is_array($array2[$key]) => [
...$differences,
...self::calculateDifferences(
$value,
$array2[$key],
$currentPath
),
],
$value !== $array2[$key] => [
...$differences,
$currentPath => [
"type" => "modified",
"old" => $value,
"new" => $array2[$key],
],
],
default => $differences,
};
}
// Verificar elementos adicionales
foreach ($array2 as $key => $value) {
if (!array_key_exists($key, $array1)) {
$currentPath = $path ? "{$path}.{$key}" : $key;
$differences[$currentPath] = [
"type" => "added",
"value" => $value,
];
}
}
return $differences;
}
/**
* Normaliza datos para comparación
*/
public static function normalize(mixed $data): array
{
return match (true) {
is_object($data) => self::normalizeObject($data),
is_array($data) => self::normalizeArray($data),
default => [$data],
};
}
/**
* Normaliza un objeto
*/
private static function normalizeObject(object $object): array
{
return match (true) {
method_exists($object, "toArray") => $object->toArray(),
default => self::normalizeArray(get_object_vars($object)),
};
}
/**
* Normaliza un array
*/
private static function normalizeArray(array $array): array
{
return array_map(
fn($value) => match (true) {
is_object($value), is_array($value) => self::normalize($value),
default => $value,
},
$array
);
}
/**
* Convierte a array
*/
public static function toArray(mixed $data): array
{
return match (true) {
is_object($data) && method_exists($data, "toArray")
=> $data->toArray(),
is_object($data) => self::normalize($data),
is_array($data) => array_map([self::class, "toArray"], $data),
default => [$data],
};
}
/**
* Clonación profunda
*
* @template T of object
* @param T $object
* @return T
*/
public static function deepClone(object $object): object
{
if (self::$cache->offsetExists($object)) {
return self::$cache->offsetGet($object);
}
$clone = match (true) {
extension_loaded("igbinary") => igbinary_unserialize(
igbinary_serialize($object)
),
method_exists($object, "__clone") => self::cloneWithProperties(
$object
),
default => unserialize(serialize($object)),
};
self::$cache->offsetSet($object, $clone);
return $clone;
}
/**
* Clona un objeto y sus propiedades
*/
private static function cloneWithProperties(object $object): object
{
$clone = clone $object;
foreach (get_object_vars($clone) as $property => $value) {
if (is_object($value)) {
$clone->$property = self::deepClone($value);
}
}
return $clone;
}
/**
* Limpia la caché
*/
public static function clearCache(): void
{
self::$cache = new WeakMap();
}
}

View File

@ -0,0 +1,304 @@
<?php
namespace App\Helpers;
class StringHelper
{
private $string;
public function __construct(?string $string = "")
{
$this->string = $string;
}
/**
* Establece el string a manipular
*/
public function setString(string $string): self
{
$this->string = $string;
return $this;
}
/**
* Obtiene el string actual
*/
public function getString(): string
{
return $this->string;
}
/**
* Convierte el string a mayúsculas
*/
public function toUpper(): self
{
$this->string = mb_strtoupper($this->string);
return $this;
}
/**
* Convierte el string a minúsculas
*/
public function toLower(): self
{
$this->string = mb_strtolower($this->string);
return $this;
}
/**
* Capitaliza la primera letra
*/
public function capitalize(): self
{
$this->string = ucfirst($this->string);
return $this;
}
/**
* Capitaliza cada palabra
*/
public function capitalizeWords(): self
{
$this->string = ucwords($this->string);
return $this;
}
/**
* Elimina espacios en blanco al inicio y final
*/
public function trim(): self
{
$this->string = trim($this->string);
return $this;
}
/**
* Remueve caracteres especiales y acentos
*/
public function removeSpecialChars(): self
{
$unwanted_array = [
"Š" => "S",
"š" => "s",
"Ž" => "Z",
"ž" => "z",
"À" => "A",
"Á" => "A",
"Â" => "A",
"Ã" => "A",
"Ä" => "A",
"Å" => "A",
"Æ" => "A",
"Ç" => "C",
"È" => "E",
"É" => "E",
"Ê" => "E",
"Ë" => "E",
"Ì" => "I",
"Í" => "I",
"Î" => "I",
"Ï" => "I",
"Ñ" => "N",
"Ò" => "O",
"Ó" => "O",
"Ô" => "O",
"Õ" => "O",
"Ö" => "O",
"Ø" => "O",
"Ù" => "U",
"Ú" => "U",
"Û" => "U",
"Ü" => "U",
"Ý" => "Y",
"Þ" => "B",
"ß" => "ss",
"à" => "a",
"á" => "a",
"â" => "a",
"ã" => "a",
"ä" => "a",
"å" => "a",
"æ" => "a",
"ç" => "c",
"è" => "e",
"é" => "e",
"ê" => "e",
"ë" => "e",
"ì" => "i",
"í" => "i",
"î" => "i",
"ï" => "i",
"ð" => "o",
"ñ" => "n",
"ò" => "o",
"ó" => "o",
"ô" => "o",
"õ" => "o",
"ö" => "o",
"ø" => "o",
"ù" => "u",
"ú" => "u",
"û" => "u",
"ý" => "y",
"ý" => "y",
"þ" => "b",
"ÿ" => "y",
];
$this->string = strtr($this->string, $unwanted_array);
return $this;
}
/**
* Genera un slug a partir del string
*/
public function slug(): self
{
$this->removeSpecialChars();
$this->string = strtolower(
trim(preg_replace("/[^A-Za-z0-9-]+/", "-", $this->string))
);
return $this;
}
/**
* Trunca el string a una longitud específica
*/
public function truncate(int $length, string $append = "..."): self
{
if (strlen($this->string) > $length) {
$this->string = substr($this->string, 0, $length) . $append;
}
return $this;
}
/**
* Cuenta las palabras en el string
*/
public function wordCount(): int
{
return str_word_count($this->string);
}
/**
* Revierte el string
*/
public function reverse(): self
{
$this->string = strrev($this->string);
return $this;
}
/**
* Reemplaza todas las ocurrencias de un string por otro
*/
public function replace(string $search, string $replace): self
{
$this->string = str_replace($search, $replace, $this->string);
return $this;
}
/**
* Extrae una substring
*/
public function substring(int $start, ?int $length = null): self
{
$this->string = substr($this->string, $start, $length);
return $this;
}
/**
* Convierte el string a camelCase
*/
public function toCamelCase(): self
{
$this->string = lcfirst(
str_replace(
" ",
"",
ucwords(str_replace(["_", "-"], " ", $this->string))
)
);
return $this;
}
/**
* Convierte el string a snake_case
*/
public function toSnakeCase(): self
{
$this->string = strtolower(
preg_replace(
["/([a-z\d])([A-Z])/", "/([^_])([A-Z][a-z])/"],
'$1_$2',
$this->string
)
);
return $this;
}
/**
* Verifica si el string contiene una substring
*/
public function contains(string $needle): bool
{
return strpos($this->string, $needle) !== false;
}
/**
* Verifica si el string empieza con una substring
*/
public function startsWith(string $needle): bool
{
return strpos($this->string, $needle) === 0;
}
/**
* Verifica si el string termina con una substring
*/
public function endsWith(string $needle): bool
{
return substr($this->string, -strlen($needle)) === $needle;
}
/**
* Limpia el string de HTML y PHP tags
*/
public function stripTags(): self
{
$this->string = strip_tags($this->string);
return $this;
}
/**
* Convierte caracteres especiales a entidades HTML
*/
public function htmlEntities(): self
{
$this->string = htmlentities($this->string, ENT_QUOTES, "UTF-8");
return $this;
}
/**
* Genera un hash MD5 del string
*/
public function toMd5(): self
{
$this->string = md5($this->string);
return $this;
}
/**
* Genera un string aleatorio
*/
public function random(int $length = 10): self
{
$characters =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
$this->string = "";
for ($i = 0; $i < $length; $i++) {
$this->string .= $characters[rand(0, strlen($characters) - 1)];
}
return $this;
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Middleware;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use App\Controllers\ViewController;
class AuthorizationMiddleware
{
private $jwtSecret;
public function __construct()
{
$this->jwtSecret = $_ENV["JWT_SECRET"];
}
public function handle($requestUri, $requestMethod)
{
$routes = include __DIR__ . "/../Config/Routes.php";
$isProtected = $this->isProtectedRoute(
$routes,
$requestUri,
$requestMethod
);
if (!$isProtected) {
// La ruta es pública, permitir acceso
return true;
}
$jwt = $_COOKIE["jwt"] ?? null;
if (!$jwt) {
// El usuario no está logueado, redirigir al login
header("Location: /login");
exit();
}
try {
$decoded = JWT::decode($jwt, new Key($this->jwtSecret, "HS256"));
} catch (\Exception $e) {
// Token inválido, redirigir al login
header("Location: /login");
exit();
}
// Token válido, continuar
return true;
}
private function isProtectedRoute($routes, $requestUri, $requestMethod)
{
foreach ($routes as $route) {
if (
$route["uri"] === $requestUri &&
$route["method"] === $requestMethod
) {
return $route["protected"];
}
}
// Si la ruta no se encuentra, se considera protegida por defecto
return true;
}
}

View File

@ -0,0 +1,196 @@
<?php
namespace App\Repositories;
use App\Core\Database;
use App\Entities\BaseEntity;
use Exception;
use PDOStatement;
class BaseRepository
{
protected $db;
protected string $table;
protected string $primaryKey;
protected string $entityClass;
/**
* Constructor
*
* @param Database $database Conexión a la base de datos.
* @param string $table Nombre de la tabla en la base de datos.
* @param string $entityClass Clase de la entidad (debe extender BaseEntity).
*
* @throws Exception Si la clase de entidad proporcionada no es válida.
*/
public function __construct(Database $database, string $table, string $entityClass)
{
$this->db = $database->getConnection();
$this->table = $table;
if (!is_subclass_of($entityClass, BaseEntity::class)) {
throw new Exception("La clase de entidad debe extender de BaseEntity.");
}
$this->entityClass = $entityClass;
$this->primaryKey = $entityClass::getPrimaryKey();
}
/**
* Obtiene todas las filas de la tabla.
*
* @return array
*/
public function getAll(): array
{
return $this->db->select($this->table, "*");
}
/**
* Encuentra una fila por un campo específico.
*
* @param string $field Campo por el cual buscar.
* @param mixed $value Valor del campo.
*
* @return array|null
*/
public function findBy(string $field, $value): ?array
{
return $this->db->get($this->table, "*", [$field => $value]);
}
/**
* Encuentra una fila por su ID.
*
* @param mixed $id Identificador de la fila.
*
* @return array|null
*/
public function findById($id): ?array
{
return $this->db->get($this->table, "*", [$this->primaryKey => $id]);
}
/**
* Inserta una fila en la tabla.
*
* @param array $data Datos a insertar.
*
* @return bool|int
*/
public function insert(array $data): int|false
{
try {
$this->db->insert($this->table, $data);
$lastInsertId = (int) $this->db->id();
if ($lastInsertId) {
return $lastInsertId;
} else {
error_log("Error al insertar en la tabla {$this->table}: " . print_r($this->db->error(), true));
return false;
}
} catch (Exception $e) {
// Registra el error de la excepción
error_log("Excepción al insertar en la tabla {$this->table}: " . $e->getMessage());
return false;
}
}
/**
* Actualiza una fila por su ID.
*
* @param mixed $id Identificador de la fila a actualizar.
* @param array $data Datos a actualizar.
*
* @return bool
*/
public function update($id, array $data): bool
{
return $this->db->update($this->table, $data, [
$this->primaryKey => $id,
])->rowCount() > 0;
}
/**
* Elimina una fila por su ID.
*
* @param mixed $id Identificador de la fila a eliminar.
*
* @return bool
*/
public function delete($id): bool
{
return $this->db->delete($this->table, [$this->primaryKey => $id])->rowCount() > 0;
}
/**
* Marca una fila como inactiva (soft delete).
*
* @param mixed $id Identificador de la fila.
*
* @return bool
*/
public function deleteActive($id): bool
{
return $this->db->update(
$this->table,
["active" => false],
[$this->primaryKey => $id]
)->rowCount() > 0;
}
/**
* Obtiene una lista paginada de filas.
*
* @param int $page Número de página.
* @param int $perPage Número de filas por página.
*
* @return array
*/
public function getPaginated(int $page, int $perPage): array
{
$perPage = min($perPage, 100);
$offset = ($page - 1) * $perPage;
return $this->db->select($this->table, "*", [
"LIMIT" => [$offset, $perPage],
]);
}
/**
* Cuenta el total de filas en la tabla.
*
* @return int
*/
public function countAll(): int
{
return $this->db->count($this->table);
}
/**
* Inicia una transacción.
*/
public function beginTransaction()
{
$this->db->pdo->beginTransaction();
}
/**
* Confirma la transacción.
*/
public function commit()
{
$this->db->pdo->commit();
}
/**
* Revierte la transacción.
*/
public function rollBack()
{
if ($this->db->pdo->inTransaction()) {
$this->db->pdo->rollBack();
}
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Entities\EmailStatus;
use App\Core\Database;
class EmailStatusRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "email_status", EmailStatus::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Entities\EmailTemplate;
use App\Core\Database;
class EmailTemplateRepository extends BaseRepository
{
// Add your repository methods here
public function __construct(Database $database)
{
parent::__construct($database, "email_templates", EmailTemplate::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Core\Database;
use App\Entities\Exercise;
class ExerciseRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, 'exercises', Exercise::class);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Repositories;
use App\Entities\ExpenseCategory;
use App\Core\Database;
class ExpenseCategoryRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "expense_categories", ExpenseCategory::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Entities\Expense;
use App\Core\Database;
class ExpenseRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "expenses", Expense::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Entities\Frequency;
use App\Core\Database;
class FrequencyRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "frequencies", Frequency::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Entities\HealthyRecipe;
use App\Core\Database;
class HealthyRecipeRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "healthy_recipes", HealthyRecipe::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Entities\ImportantEvent;
use App\Core\Database;
class ImportantEventRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "important_events", ImportantEvent::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Entities\Income;
use App\Core\Database;
class IncomeRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "incomes", Income::class);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Repositories;
use App\Core\Database;
use App\Entities\Notification;
class NotificationRepository extends BaseRepository
{
// Add your repository methods here
public function __construct(Database $database)
{
parent::__construct($database, "notifications", Notification::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Core\Database;
use App\Entities\NotificationType;
class NotificationTypeRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "notification_types", NotificationType::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Entities\SentEmail;
use App\Core\Database;
class SentEmailRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "sent_emails", SentEmail::class);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Repositories;
use App\Core\Database;
use App\Entities\Setting;
class SettingRepository extends BaseRepository
{
// Add your repository methods here
public function __construct(Database $database)
{
parent::__construct($database, "settings", Setting::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Core\Database;
use App\Entities\Task;
class TaskRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "tasks", Task::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Core\Database;
use App\Entities\TaskStatus;
class TaskStatusRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "task_status", TaskStatus::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Entities\User;
use App\Core\Database;
class UserRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "users", User::class);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories;
use App\Core\Database;
use App\Entities\WorkoutRoutine;
class WorkoutRoutineRepository extends BaseRepository
{
public function __construct(Database $database)
{
parent::__construct($database, "workout_routines", WorkoutRoutine::class);
}
}

0
app/Views/auth/.gitkeep Normal file
View File

157
app/Views/auth/login.php Normal file
View File

@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Iniciar Sesión</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
/* Estilos comunes */
body, html {
height: 100%;
font-family: 'Poppins', sans-serif;
background-color: #f8f9fa;
margin: 0;
}
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 2rem;
background-color: #ffffff;
}
.form-control {
border-radius: 8px;
}
.btn-primary {
border-radius: 8px;
transition: background-color 0.3s ease, transform 0.3s ease;
}
.btn-primary:hover {
transform: scale(1.02);
}
.text-muted a {
color: #007bff;
text-decoration: none;
transition: color 0.3s ease;
}
.text-muted a:hover {
text-decoration: underline;
color: #0056b3;
}
.text-center {
text-align: center;
}
.fw-bold {
font-weight: 600;
}
/* Ajustes para pantallas pequeñas */
@media (max-width: 576px) {
.card {
padding: 1.5rem;
border-radius: 8px;
}
.btn-primary {
font-size: 0.95rem;
}
.text-muted a {
font-size: 0.9rem;
}
}
/* Ajustes para pantallas medianas */
@media (min-width: 768px) and (max-width: 992px) {
.card {
padding: 2.5rem;
}
.btn-primary {
font-size: 1rem;
}
.text-muted a {
font-size: 1rem;
}
}
/* Ajustes para pantallas grandes */
@media (min-width: 992px) {
.card {
padding: 3rem;
}
.btn-primary {
font-size: 1.1rem;
}
.text-muted a {
font-size: 1rem;
}
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="col-md-6">
<div class="card p-4">
<h1 class="text-center fw-bold mb-2">GTD Assistant</h1>
<p class="text-center mb-4">Dale un respiro a tu mente organizándote con este sistema.</p>
<?php if (isset($error)): ?>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
Swal.fire({
icon: 'error',
title: 'Error',
text: '<?php echo htmlspecialchars(
$error,
ENT_QUOTES,
"UTF-8"
); ?>',
confirmButtonColor: '#d33',
confirmButtonText: 'Aceptar'
});
</script>
<?php endif; ?>
<form action="/login" method="POST">
<div class="mb-3">
<label for="email" class="form-label">Correo Electrónico</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Contraseña</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Iniciar Sesión</button>
</form>
<p class="mt-3 text-center text-muted">¿No tienes una cuenta? <a href="/register">Regístrate</a></p>
</div>
</div>
</div>
</body>
</html>

221
app/Views/auth/register.php Normal file
View File

@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registrarse</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900&display=swap"
rel="stylesheet">
<style>
/* Estilos comunes */
body, html {
height: 100%;
font-family: 'Poppins', sans-serif;
background-color: #f8f9fa;
margin: 0;
}
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 2rem;
background-color: #ffffff;
}
.form-control {
border-radius: 8px;
}
.btn-primary {
border-radius: 8px;
transition: background-color 0.3s ease, transform 0.3s ease;
}
.btn-primary:hover {
transform: scale(1.02);
}
.text-muted a {
color: #007bff;
text-decoration: none;
transition: color 0.3s ease;
}
.text-muted a:hover {
text-decoration: underline;
color: #0056b3;
}
.text-center {
text-align: center;
}
.fw-bold {
font-weight: 600;
}
/* Ajustes para pantallas pequeñas */
@media (max-width: 576px) {
.card {
padding: 1.5rem;
border-radius: 8px;
}
.btn-primary {
font-size: 0.95rem;
}
.text-muted a {
font-size: 0.9rem;
}
}
/* Ajustes para pantallas medianas */
@media (min-width: 768px) and (max-width: 992px) {
.card {
padding: 2.5rem;
}
.btn-primary {
font-size: 1rem;
}
.text-muted a {
font-size: 1rem;
}
}
/* Ajustes para pantallas grandes */
@media (min-width: 992px) {
.card {
padding: 3rem;
}
.btn-primary {
font-size: 1.1rem;
}
.text-muted a {
font-size: 1rem;
}
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="col-md-6">
<div class="card p-4">
<h3 class="text-center fw-bold mb-4">Nuevo Usuario</h3>
<form action="/register" method="POST" novalidate>
<div class="mb-3">
<label for="username" class="form-label">Nombre de Usuario</label>
<input type="text" class="form-control <?php echo isset(
$errors["username"]
)
? "is-invalid"
: ""; ?>" id="username" name="username" value="<?php echo htmlspecialchars(
$old["username"] ?? "",
ENT_QUOTES,
"UTF-8"
); ?>" required>
<?php if (isset($errors["username"])): ?>
<div class="invalid-feedback">
<?php echo htmlspecialchars(
$errors["username"],
ENT_QUOTES,
"UTF-8"
); ?>
</div>
<?php endif; ?>
</div>
<div class="mb-3">
<label for="email" class="form-label">Correo Electrónico</label>
<input type="email" class="form-control <?php echo isset(
$errors["email"]
)
? "is-invalid"
: ""; ?>" id="email" name="email" value="<?php echo htmlspecialchars(
$old["email"] ?? "",
ENT_QUOTES,
"UTF-8"
); ?>" required>
<?php if (isset($errors["email"])): ?>
<div class="invalid-feedback">
<?php echo htmlspecialchars(
$errors["email"],
ENT_QUOTES,
"UTF-8"
); ?>
</div>
<?php endif; ?>
</div>
<div class="mb-3">
<label for="password" class="form-label">Contraseña</label>
<input type="password" class="form-control <?php echo isset(
$errors["password"]
)
? "is-invalid"
: ""; ?>" id="password" name="password" required>
<?php if (isset($errors["password"])): ?>
<div class="invalid-feedback">
<?php echo htmlspecialchars(
$errors["password"],
ENT_QUOTES,
"UTF-8"
); ?>
</div>
<?php endif; ?>
</div>
<button type="submit" class="btn btn-primary w-100">Registrarse</button>
</form>
<p class="mt-3 text-center text-muted">¿Ya tienes una cuenta? <a href="/login">Iniciar Sesión</a></p>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<?php if (isset($success)): ?>
<script>
Swal.fire({
icon: 'success',
title: '¡Registro Exitoso!',
text: '<?php echo htmlspecialchars(
$success,
ENT_QUOTES,
"UTF-8"
); ?>',
confirmButtonColor: '#3085d6',
confirmButtonText: 'Aceptar'
}).then(() => {
window.location.href = "/login";
});
</script>
<?php endif; ?>
<?php if (isset($error)): ?>
<script>
Swal.fire({
icon: 'error',
title: 'Error',
text: '<?php echo htmlspecialchars(
$error,
ENT_QUOTES,
"UTF-8"
); ?>',
confirmButtonColor: '#d33',
confirmButtonText: 'Aceptar'
});
</script>
<?php endif; ?>
</body>
</html>

View File

@ -0,0 +1,31 @@
<?php
$modulos = include __DIR__ . '/../../Config/Modulos.php';
$uriActual = $_SERVER['REQUEST_URI'];
$moduloActual = null;
foreach ($modulos as $moduloPadre) {
if (!empty($moduloPadre['hijos'])) {
foreach ($moduloPadre['hijos'] as $moduloHijo) {
if ($moduloHijo['ruta'] === $uriActual) {
// Asignar el módulo actual
$moduloActual = [
'nombre' => $moduloHijo['nombre'],
'icono' => $moduloHijo['icono']
];
break 2;
}
}
}
}
?>
<?php if ($moduloActual): ?>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">
<i class="<?= $moduloActual['icono'] ?>"></i> <?= $moduloActual['nombre'] ?>
</li>
</ol>
</nav>
<?php endif; ?>

View File

@ -0,0 +1,155 @@
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<!-- Logo -->
<a class="navbar-brand" href="/home">
<?php echo $_ENV["APP_NAME"]; ?>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<?php
$modulos = include __DIR__ . "/../../Config/Modulos.php";
foreach ($modulos as $moduloPadre) {
// Módulo "Inicio" sin hijos
if ($moduloPadre["nombre"] === "Inicio") {
echo '<li class="nav-item">';
echo '<a class="nav-link" href="' .
htmlspecialchars(
$moduloPadre["ruta"],
ENT_QUOTES,
"UTF-8"
) .
'">';
echo '<i class="' .
htmlspecialchars(
$moduloPadre["icono"],
ENT_QUOTES,
"UTF-8"
) .
' me-2"></i>' .
htmlspecialchars(
$moduloPadre["nombre"],
ENT_QUOTES,
"UTF-8"
);
echo "</a>";
echo "</li>";
} elseif (
isset($moduloPadre["hijos"]) &&
!empty($moduloPadre["hijos"])
) {
// Módulos con submódulos
echo '<li class="nav-item dropdown">';
echo '<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">';
echo '<i class="' .
htmlspecialchars(
$moduloPadre["icono"],
ENT_QUOTES,
"UTF-8"
) .
' me-2"></i>' .
htmlspecialchars(
$moduloPadre["nombre"],
ENT_QUOTES,
"UTF-8"
);
echo "</a>";
echo '<ul class="dropdown-menu" aria-labelledby="navbarDropdown">';
foreach ($moduloPadre["hijos"] as $moduloHijo) {
echo '<li><a class="dropdown-item" href="' .
htmlspecialchars(
$moduloHijo["ruta"],
ENT_QUOTES,
"UTF-8"
) .
'">';
echo '<i class="' .
htmlspecialchars(
$moduloHijo["icono"],
ENT_QUOTES,
"UTF-8"
) .
' me-2"></i>';
echo htmlspecialchars(
$moduloHijo["nombre"],
ENT_QUOTES,
"UTF-8"
);
echo "</a></li>";
}
echo "</ul>";
echo "</li>";
} else {
// Módulos sin submódulos
echo '<li class="nav-item">';
echo '<a class="nav-link" href="' .
htmlspecialchars(
$moduloPadre["ruta"],
ENT_QUOTES,
"UTF-8"
) .
'">';
echo '<i class="' .
htmlspecialchars(
$moduloPadre["icono"],
ENT_QUOTES,
"UTF-8"
) .
' me-2"></i>' .
htmlspecialchars(
$moduloPadre["nombre"],
ENT_QUOTES,
"UTF-8"
);
echo "</a>";
echo "</li>";
}
}
?>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<form action="/logout" method="GET">
<button class="btn btn-danger" type="submit">Cerrar sesión</button>
</form>
</li>
</ul>
</div>
</div>
</nav>
<style>
/* Estilos para la navbar */
.navbar-nav .nav-link {
transition: background-color 0.3s ease, color 0.3s ease; /* Transición suave */
}
/* Efecto hover para los enlaces de la navbar */
.navbar-nav .nav-link:hover {
background-color: #007bff; /* Cambia a un azul más oscuro */
color: white; /* Cambia el color del texto a blanco */
border-radius: 0.25rem; /* Añadir bordes redondeados */
}
/* Efecto hover para los dropdowns */
.dropdown-menu {
transition: opacity 0.3s ease; /* Transición suave para el dropdown */
}
.dropdown-menu .dropdown-item:hover {
background-color: #f8f9fa; /* Cambia el fondo en hover */
color: #007bff; /* Cambia el color del texto */
}
/* Sombra sutil para la navbar */
.navbar {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); /* Sombra sutil */
}
</style>

0
app/Views/error/.gitkeep Normal file
View File

56
app/Views/error/400.php Normal file
View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error 400 - Solicitud Incorrecta</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body, html {
height: 100%;
font-family: 'Poppins', sans-serif;
}
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.image-placeholder {
width: 100%;
height: 200px;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
color: #888;
font-size: 18px;
margin-bottom: 20px;
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="col-md-6">
<div class="card p-4">
<h4 class="text-center fw-bold mb-4">Solicitud Incorrecta</h4>
<div class="image-placeholder">
[Placeholder para imagen de error 400]
</div>
<h2 class="text-center mb-3">Error 400</h2>
<p class="text-center mb-4">La solicitud enviada no es válida o está mal estructurada.</p>
</div>
</div>
</div>
</body>
</html>

56
app/Views/error/401.php Normal file
View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error 401 - No Autorizado</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body, html {
height: 100%;
font-family: 'Poppins', sans-serif;
}
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.image-placeholder {
width: 100%;
height: 200px;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
color: #888;
font-size: 18px;
margin-bottom: 20px;
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="col-md-6">
<div class="card p-4">
<h4 class="text-center fw-bold mb-4">No Autorizado</h4>
<div class="image-placeholder">
[Placeholder para imagen de error 401]
</div>
<h2 class="text-center mb-3">Error 401</h2>
<p class="text-center mb-4">No tienes autorización para acceder a esta página.</p>
</div>
</div>
</div>
</body>
</html>

60
app/Views/error/403.php Normal file
View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error 403 - Prohibido</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body,
html {
height: 100%;
font-family: 'Poppins', sans-serif;
}
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.image-placeholder {
width: 100%;
height: 200px;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
color: #888;
font-size: 18px;
margin-bottom: 20px;
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="col-md-6">
<div class="card p-4">
<h4 class="text-center fw-bold mb-4">Acceso Prohibido</h4>
<div class="image-placeholder">
[Placeholder para imagen de error 403]
</div>
<h2 class="text-center mb-3">Error 403</h2>
<p class="text-center mb-4">No tienes permisos para acceder a esta página.</p>
</div>
</div>
</div>
</body>
</html>

66
app/Views/error/404.php Normal file
View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error 404 - Página no encontrada</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body,
html {
height: 100%;
font-family: 'Poppins', sans-serif;
}
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.btn-primary {
border-radius: 8px;
}
.image-placeholder {
width: 100%;
height: 200px;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
color: #888;
font-size: 18px;
margin-bottom: 20px;
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="col-md-6">
<div class="card p-4">
<h4 class="text-center fw-bold mb-4">Oops! Página no encontrada</h4>
<div class="image-placeholder">
[Placeholder para imagen de error 404]
</div>
<h2 class="text-center mb-3">Error 404</h2>
<p class="text-center mb-4">Lo sentimos, la página que estás buscando no existe o ha sido movida.</p>
</div>
</div>
</div>
</body>
</html>

56
app/Views/error/408.php Normal file
View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error 408 - Tiempo de Espera Agotado</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body, html {
height: 100%;
font-family: 'Poppins', sans-serif;
}
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.image-placeholder {
width: 100%;
height: 200px;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
color: #888;
font-size: 18px;
margin-bottom: 20px;
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="col-md-6">
<div class="card p-4">
<h4 class="text-center fw-bold mb-4">Tiempo de Espera Agotado</h4>
<div class="image-placeholder">
[Placeholder para imagen de error 408]
</div>
<h2 class="text-center mb-3">Error 408</h2>
<p class="text-center mb-4">El servidor no pudo procesar tu solicitud en el tiempo esperado.</p>
</div>
</div>
</div>
</body>
</html>

60
app/Views/error/500.php Normal file
View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error 500 - Error Interno del Servidor</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body,
html {
height: 100%;
font-family: 'Poppins', sans-serif;
}
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.image-placeholder {
width: 100%;
height: 200px;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
color: #888;
font-size: 18px;
margin-bottom: 20px;
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="col-md-6">
<div class="card p-4">
<h4 class="text-center fw-bold mb-4">Error Interno del Servidor</h4>
<div class="image-placeholder">
[Placeholder para imagen de error 500]
</div>
<h2 class="text-center mb-3">Error 500</h2>
<p class="text-center mb-4">Ocurrió un problema inesperado en el servidor.</p>
</div>
</div>
</div>
</body>
</html>

56
app/Views/error/503.php Normal file
View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error 503 - Servicio No Disponible</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body, html {
height: 100%;
font-family: 'Poppins', sans-serif;
}
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.image-placeholder {
width: 100%;
height: 200px;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
color: #888;
font-size: 18px;
margin-bottom: 20px;
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="col-md-6">
<div class="card p-4">
<h4 class="text-center fw-bold mb-4">Servicio No Disponible</h4>
<div class="image-placeholder">
[Placeholder para imagen de error 503]
</div>
<h2 class="text-center mb-3">Error 503</h2>
<p class="text-center mb-4">El servidor no está disponible temporalmente.</p>
</div>
</div>
</div>
</body>
</html>

440
app/Views/home/home.php Normal file
View File

@ -0,0 +1,440 @@
<?php include __DIR__ . "/../layouts/header.php"; ?>
<style>
.task-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background-color: #f9f9f9;
border-radius: 0.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.task-form__input-group {
display: flex;
gap: 1rem;
align-items: center;
}
.task-form__input {
flex: 1;
}
.task-form__badge {
margin-top: 0.5rem;
}
@media (max-width: 767.98px) {
#calendar {
max-width: 100%;
}
.fc-header-toolbar {
font-size: 0.75rem;
}
}
</style>
<div class="container-fluid mt-4">
<div class="row g-3">
<!-- Título del Dashboard -->
<div class="col-12 text-center my-4">
<h1 class="display-4 text-dark"><?php echo $_ENV["APP_NAME"]; ?></h1>
<p class="lead text-muted">Automatiza tus tareas diarias y mejora tu productividad con
<b><?php echo $_ENV["APP_NAME"]; ?></b></p>
</div>
<!-- Tarjetas de Resumen -->
<div class="row g-3 mb-4">
<!-- Balance General de Dinero -->
<div class="col-12 col-sm-6 col-xl-3">
<div class="card border-start border-success border-4 shadow h-100">
<div class="card-body py-3">
<div class="row align-items-center g-0">
<div class="col-8">
<div class="small fw-bold text-success text-uppercase mb-1">
Balance General</div>
<div class="h5 mb-0 fw-bold text-gray-800" id="balanceGeneralAmount">Q 0</div>
</div>
<div class="col-4 text-end">
<i class="fas fa-wallet fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Eventos Próximos -->
<div class="col-12 col-sm-6 col-xl-3">
<div class="card border-start border-warning border-4 shadow h-100">
<div class="card-body py-3">
<div class="row align-items-center g-0">
<div class="col-8">
<div class="small fw-bold text-warning text-uppercase mb-1">
Eventos Próximos</div>
<div class="h5 mb-0 fw-bold text-gray-800" id="upcomingEventsCount">0</div>
</div>
<div class="col-4 text-end">
<i class="fas fa-calendar-alt fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Tareas En Proceso -->
<div class="col-12 col-sm-6 col-xl-3">
<div class="card border-start border-info border-4 shadow h-100">
<div class="card-body py-3">
<div class="row align-items-center g-0">
<div class="col-8">
<div class="small fw-bold text-info text-uppercase mb-1">
Tareas En Proceso</div>
<div class="h5 mb-0 fw-bold text-gray-800" id="inProgressTasksCount">0</div>
</div>
<div class="col-4 text-end">
<i class="fas fa-tasks fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Tareas Completadas -->
<div class="col-12 col-sm-6 col-xl-3">
<div class="card border-start border-primary border-4 shadow h-100">
<div class="card-body py-3">
<div class="row align-items-center g-0">
<div class="col-8">
<div class="small fw-bold text-primary text-uppercase mb-1">
Tareas Completadas</div>
<div class="h5 mb-0 fw-bold text-gray-800" id="completedTasksCount">0</div>
</div>
<div class="col-4 text-end">
<i class="fas fa-check-circle fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Calendario y Gráficos -->
<div class="row mb-5 g-3">
<!-- Calendario -->
<div class="col-12 col-lg-8">
<div class="card shadow h-100">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h6 class="my-0">Calendario de Eventos</h6>
<button class="btn btn-sm btn-light" id="newEventBtn">
<i class="fas fa-plus"></i> Nuevo Evento
</button>
</div>
<div class="card-body">
<div id="calendar"></div>
</div>
</div>
</div>
<!-- Panel Lateral -->
<div class="col-12 col-lg-4">
<!-- Gráfico de Actividades Fitness -->
<div class="card shadow mb-4">
<div class="card-header bg-primary text-white">
<h6 class="my-0">Actividad Física Últimos 7 Días</h6>
</div>
<div class="card-body">
<canvas id="fitnessActivityChart"></canvas>
</div>
</div>
<!-- Lista de Próximos Eventos -->
<div class="card shadow">
<div class="card-header bg-warning text-white">
<h6 class="my-0">Próximos Eventos</h6>
</div>
<div class="card-body">
<div id="upcomingEventsList" class="list-group list-group-flush">
<!-- Los eventos se cargarán dinámicamente aquí -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<link href='https://cdn.jsdelivr.net/npm/fullcalendar@5.11.3/main.min.css' rel='stylesheet' />
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@5.11.3/main.min.js'></script>
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@5.11.3/locales-all.min.js'></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Datos simulados
const data = {
balanceGeneral: 3500,
upcomingEvents: 5,
inProgressTasks: 8,
completedTasks: 15,
fitnessActivities: [10000, 6000, 6500, 5000, 12000, 5500, 9500],
events: [
{
id: '1',
title: 'Reunión de equipo',
start: '2024-10-28T10:00:00',
end: '2024-10-28T11:30:00',
backgroundColor: '#0275d8',
description: 'Reunión semanal de seguimiento'
},
{
id: '2',
title: 'Entrenamiento',
start: '2024-10-29T15:00:00',
end: '2024-10-29T16:00:00',
backgroundColor: '#5cb85c',
description: 'Sesión de ejercicio'
}
// ... agrega más eventos si lo deseas
]
};
// Actualizar tarjetas de resumen
document.getElementById('balanceGeneralAmount').textContent = `Q ${data.balanceGeneral}`;
document.getElementById('upcomingEventsCount').textContent = data.upcomingEvents;
document.getElementById('inProgressTasksCount').textContent = data.inProgressTasks;
document.getElementById('completedTasksCount').textContent = data.completedTasks;
// Inicializar FullCalendar
const calendarEl = document.getElementById('calendar');
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
locale: 'es',
events: data.events,
editable: true,
selectable: true,
selectMirror: true,
dayMaxEvents: true,
eventClick: function (info) {
showEventDetails(info.event);
},
select: function (info) {
showEventForm(null, info);
}
});
calendar.render();
// Gráfico de Actividades Fitness
const fitnessActivityCtx = document.getElementById('fitnessActivityChart').getContext('2d');
new Chart(fitnessActivityCtx, {
type: 'bar',
data: {
labels: ['Hace 6 días', 'Hace 5 días', 'Hace 4 días', 'Hace 3 días', 'Hace 2 días', 'Ayer', 'Hoy'],
datasets: [{
label: 'Pasos diarios',
data: data.fitnessActivities,
backgroundColor: 'rgba(0, 123, 255, 0.7)',
borderColor: 'rgba(0, 123, 255, 1)',
borderWidth: 1,
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
// Actualizar lista de próximos eventos
updateUpcomingEventsList();
// Manejadores de eventos
document.getElementById('newEventBtn').addEventListener('click', () => showEventForm());
// Funciones auxiliares
function showEventForm(event = null, selectInfo = null) {
let eventData = {
id: '',
title: '',
start: '',
end: '',
color: '#0275d8',
description: ''
};
if (event) {
// Editar evento existente
eventData = {
id: event.id,
title: event.title,
start: event.startStr,
end: event.endStr,
color: event.backgroundColor,
description: event.extendedProps.description
};
} else if (selectInfo) {
// Nuevo evento con fechas seleccionadas
eventData.start = selectInfo.startStr;
eventData.end = selectInfo.endStr;
}
Swal.fire({
title: event ? 'Editar Evento' : 'Nuevo Evento',
html: `
<div class="task-form">
<input type="text" id="swalEvtTitle" class="swal2-input" placeholder="Título" value="${escapeHtml(eventData.title)}">
<input type="datetime-local" id="swalEvtStart" class="swal2-input" value="${formatDateTimeInput(eventData.start)}">
<input type="datetime-local" id="swalEvtEnd" class="swal2-input" value="${formatDateTimeInput(eventData.end)}">
<div class="task-form__input-group">
<label for="swalEvtColor">Color:</label>
<select id="swalEvtColor" class="swal2-input task-form__input">
<option value="#0275d8" ${eventData.color === '#0275d8' ? 'selected' : ''}>Azul</option>
<option value="#5cb85c" ${eventData.color === '#5cb85c' ? 'selected' : ''}>Verde</option>
<option value="#f0ad4e" ${eventData.color === '#f0ad4e' ? 'selected' : ''}>Amarillo</option>
<option value="#d9534f" ${eventData.color === '#d9534f' ? 'selected' : ''}>Rojo</option>
</select>
</div>
<textarea id="swalEvtDesc" class="swal2-textarea" placeholder="Descripción">${escapeHtml(eventData.description)}</textarea>
</div>
`,
focusConfirm: false,
showCancelButton: true,
confirmButtonText: 'Guardar',
cancelButtonText: 'Cancelar',
preConfirm: () => {
const title = document.getElementById('swalEvtTitle').value;
const start = document.getElementById('swalEvtStart').value;
const end = document.getElementById('swalEvtEnd').value;
const color = document.getElementById('swalEvtColor').value;
const description = document.getElementById('swalEvtDesc').value;
if (!title || !start || !end) {
Swal.showValidationMessage('Por favor completa todos los campos obligatorios');
return false;
}
return { title, start, end, color, description };
}
}).then((result) => {
if (result.isConfirmed) {
const formData = result.value;
if (event) {
// Actualizar evento existente
event.setProp('title', formData.title);
event.setStart(formData.start);
event.setEnd(formData.end);
event.setProp('backgroundColor', formData.color);
event.setExtendedProp('description', formData.description);
} else {
// Crear nuevo evento
calendar.addEvent({
id: String(Date.now()), // Generar un ID único
title: formData.title,
start: formData.start,
end: formData.end,
backgroundColor: formData.color,
description: formData.description
});
}
updateUpcomingEventsList();
}
});
}
function showEventDetails(event) {
Swal.fire({
title: event.title,
html: `
<div class="task-form">
<p><strong>Inicio:</strong> ${formatDateTimeDisplay(event.start)}</p>
<p><strong>Fin:</strong> ${formatDateTimeDisplay(event.end)}</p>
<p><strong>Descripción:</strong> ${escapeHtml(event.extendedProps.description || 'Sin descripción')}</p>
</div>
`,
showCancelButton: true,
showDenyButton: true,
confirmButtonText: 'Editar',
denyButtonText: 'Eliminar',
cancelButtonText: 'Cerrar',
confirmButtonColor: '#0d6efd',
denyButtonColor: '#dc3545',
}).then((result) => {
if (result.isConfirmed) {
// Editar evento
showEventForm(event);
} else if (result.isDenied) {
// Eliminar evento
Swal.fire({
title: '¿Estás seguro?',
text: 'Esta acción no se puede deshacer',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Sí, eliminar',
cancelButtonText: 'Cancelar',
confirmButtonColor: '#dc3545',
}).then((res) => {
if (res.isConfirmed) {
event.remove();
updateUpcomingEventsList();
Swal.fire('Eliminado', 'El evento ha sido eliminado', 'success');
}
});
}
});
}
function updateUpcomingEventsList() {
const events = calendar.getEvents();
const upcomingEvents = events
.filter(event => event.start >= new Date())
.sort((a, b) => a.start - b.start)
.slice(0, 5);
const listContainer = document.getElementById('upcomingEventsList');
listContainer.innerHTML = '';
upcomingEvents.forEach(event => {
const item = document.createElement('div');
item.className = 'list-group-item';
item.innerHTML = `
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">${event.title}</h6>
<small>${formatDateTimeDisplay(event.start)}</small>
</div>
<p class="mb-1">${event.extendedProps.description || ''}</p>
`;
listContainer.appendChild(item);
});
// Actualizar el contador de eventos próximos
document.getElementById('upcomingEventsCount').textContent = upcomingEvents.length;
}
function formatDateTimeInput(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
const isoString = date.toISOString();
return isoString.slice(0, 16);
}
function formatDateTimeDisplay(date) {
if (!date) return '';
const options = { dateStyle: 'medium', timeStyle: 'short' };
return new Intl.DateTimeFormat('es-ES', options).format(new Date(date));
}
});
</script>
<?php include __DIR__ . "/../layouts/footer.php"; ?>

View File

@ -0,0 +1,8 @@
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"></script>
</body>
</html>

View File

@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?php echo $_ENV['APP_NAME']; ?></title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- FontAwesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="/assets/css/main.css">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet">
<script>
// Precharged JS code for the app
/**
* @function showMessage
* @description Muestra un mensaje usando SweetAlert2
* @param {string} message - Mensaje a mostrar
* @param {string} [type='success'] - Tipo de mensaje ('success', 'error', etc.)
*/
const showMessage = (message, type = 'success') => {
Swal.fire({
toast: true,
position: 'top-end',
icon: type,
title: message,
showConfirmButton: false,
timer: 3000,
timerProgressBar: true,
});
};
/**
* @function formatDate
* @description Formatea una fecha en formato corto
* @param {string} dateStr - Fecha en formato ISO
* @returns {string} Fecha formateada
*/
const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
};
/**
* @function escapeHtml
* @description Escapa caracteres HTML para prevenir XSS
* @param {string} text - Texto a escapar
* @returns {string} Texto escapado
*/
const escapeHtml = (text) => {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (m) => map[m]);
};
/**
* @typedef {Object} StateManager
* @description Gestor de estado que maneja el almacenamiento y persistencia de datos de la aplicación
*/
const StateManager = (() => {
const state = {};
/**
* @private
* @function initialize
* @description Inicializa el estado desde localStorage si existen datos persistidos
* @returns {void}
*/
const initialize = () => {
const persistedState = JSON.parse(localStorage.getItem('appState'));
if (persistedState) {
Object.assign(state, persistedState);
}
};
/**
* @private
* @function persistState
* @description Persiste el estado actual en localStorage
* @returns {void}
*/
const persistState = () => {
localStorage.setItem('appState', JSON.stringify(state));
};
/**
* @function get
* @description Obtiene un valor del estado
* @param {string} key - Clave del valor a obtener
* @param {*} [defaultValue=null] - Valor por defecto si la clave no existe
* @returns {*} El valor almacenado o el valor por defecto
*/
const get = (key, defaultValue = null) => {
return state[key] !== undefined ? state[key] : defaultValue;
};
/**
* @function set
* @description Establece un valor en el estado y lo persiste
* @param {string} key - Clave bajo la cual almacenar el valor
* @param {*} value - Valor a almacenar
* @returns {void}
*/
const set = (key, value) => {
state[key] = value;
persistState();
};
/**
* @function update
* @description Actualiza propiedades específicas de un objeto en el estado
* @param {string} key - Clave del objeto a actualizar
* @param {Object} updates - Objeto con las actualizaciones a realizar
* @returns {void}
* @throws {Warning} Si el valor no es un objeto o no existe
*/
const update = (key, updates) => {
if (typeof state[key] === 'object' && !Array.isArray(state[key])) {
Object.assign(state[key], updates);
persistState();
} else {
console.warn(`El valor de ${key} no es un objeto o no existe.`);
}
};
/**
* @function remove
* @description Elimina una clave y su valor del estado
* @param {string} key - Clave a eliminar
* @returns {void}
*/
const remove = (key) => {
delete state[key];
persistState();
};
/**
* @function clear
* @description Limpia todo el estado y el almacenamiento persistente
* @returns {void}
*/
const clear = () => {
for (let key in state) {
delete state[key];
}
localStorage.removeItem('appState');
};
/**
* @function has
* @description Verifica si una clave existe en el estado
* @param {string} key - Clave a verificar
* @returns {boolean} True si la clave existe, false en caso contrario
*/
const has = (key) => {
return state.hasOwnProperty(key);
};
/**
* @function getState
* @description Obtiene una copia del estado completo
* @returns {Object} Copia del estado actual
*/
const getState = () => {
return { ...state };
};
// Inicializamos la store
initialize();
return {
get,
set,
update,
remove,
clear,
has,
getState,
};
})();
</script>
</head>
<body class="bg-body-secondary">
<?php include __DIR__ . "/../components/navbar.php"; ?>

View File

View File

@ -0,0 +1,446 @@
<?php include __DIR__ . "/../../layouts/header.php";
function truncate($text, $length)
{
return strlen($text) > $length ? substr($text, 0, $length) . "..." : $text;
}
?>
<div class="container-fluid mt-4">
<div class="row">
<div class="col-md-6 mb-2">
<?php include __DIR__ . "/../../components/breadcrumb.php"; ?>
</div>
<div class="col-md-2 mb-2">
</div>
<div class="col-md-4 mb-2 text-end">
<!-- Botón para agregar nuevo cliente -->
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addClienteModal">
<i class="fas fa-plus"></i> Agregar Cliente
</button>
</div>
</div>
<div class="row m-1 bg-white rounded">
<div class="col-md-12 p-4 table-responsive">
<table class="table table-striped table-hover table-bordered" id="clientesTable">
<thead>
<tr class="table-dark">
<th>Nombre</th>
<th>Dirección</th>
<th>Teléfono</th>
<th>NIT</th>
<th class="text-center">Activo</th>
<th class="text-center">Acciones</th>
</tr>
</thead>
<tbody id="clientesBody">
<?php foreach ($clientes as $cliente): ?>
<tr data-id="<?php echo $cliente["id"]; ?>">
<td><?php echo truncate(
$cliente["nombre"],
20
); ?></td>
<td><?php echo truncate(
$cliente["direccion"],
40
); ?>
<td><?php echo $cliente["telefono"]; ?></td>
<td><?php echo $cliente["nit"]; ?></td>
<td class="text-center"><?php echo $cliente[
"active"
]
? ""
: ""; ?></td>
<td class="text-center">
<!-- Botón para editar -->
<button class="btn btn-warning btn-sm edit-btn" data-id="<?php echo $cliente[
"id"
]; ?>"
data-bs-toggle="modal" data-bs-target="#editClienteModal">
<i class="fas fa-edit"></i>
</button>
<!-- Botón para eliminar -->
<button class="btn btn-danger btn-sm delete-btn" data-id="<?php echo $cliente[
"id"
]; ?>"
data-bs-toggle="modal" data-bs-target="#deleteClienteModal" <?php echo $cliente[
"active"
]
? ""
: "disabled"; ?>>
<i class="fas fa-trash"></i>
</button>
</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<!-- Paginación -->
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center" id="pagination">
<?php for ($i = 1; $i <= $totalPages; $i++): ?>
<li class="page-item <?php echo $i == $currentPage
? "active"
: ""; ?>">
<a class="page-link" href="#" data-page="<?php echo $i; ?>"><?php echo $i; ?></a>
</li>
<?php endfor; ?>
</ul>
</nav>
</div>
</div>
</div>
<!-- Modal para agregar cliente -->
<div class="modal fade" id="addClienteModal" tabindex="-1" aria-labelledby="addClienteLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="addClienteLabel">
<i class="fas fa-user-plus me-2"></i>Agregar Cliente
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="addClienteForm" class="needs-validation" novalidate>
<div class="row g-3">
<div class="col-md-6">
<label for="nombre" class="form-label">Nombre <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="nombre" required>
<div class="invalid-feedback">Por favor, ingrese un nombre.</div>
</div>
<div class="col-md-6">
<label for="email" class="form-label">Email <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="email" required>
<div class="invalid-feedback">Por favor, ingrese un email válido.</div>
</div>
<div class="col-md-6">
<label for="telefono" class="form-label">Teléfono</label>
<input type="tel" class="form-control" id="telefono" pattern="[0-9]{8,}">
<div class="invalid-feedback">Por favor, ingrese un número de teléfono válido.</div>
</div>
<div class="col-md-6">
<label for="direccion" class="form-label">Dirección</label>
<input type="text" class="form-control" id="direccion">
</div>
<div class="col-md-6">
<label for="nit" class="form-label">NIT</label>
<input type="text" class="form-control" id="nit">
</div>
<div class="col-md-6">
<label for="cui" class="form-label">CUI</label>
<input type="text" class="form-control" id="cui">
</div>
<div class="col-md-6">
<label for="fecha_nacimiento" class="form-label">Fecha de Nacimiento</label>
<input type="date" class="form-control" id="fecha_nacimiento">
</div>
<div class="col-md-6">
<label for="activoSwitch" class="form-label d-block">Estado</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="activoSwitch" checked>
<label class="form-check-label" for="activoSwitch">Activo</label>
</div>
</div>
</div>
<div class="mt-4 text-end">
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>Guardar Cliente
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Modal para editar cliente -->
<div class="modal fade" id="editClienteModal" tabindex="-1" aria-labelledby="editClienteLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-warning text-dark">
<h5 class="modal-title" id="editClienteLabel">
<i class="fas fa-user-edit me-2"></i>Editar Cliente
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="editClienteForm" class="needs-validation" novalidate>
<input type="hidden" id="edit-id">
<div class="row g-3">
<div class="col-md-6">
<label for="edit-nombre" class="form-label">Nombre <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="edit-nombre" required>
<div class="invalid-feedback">Por favor, ingrese un nombre.</div>
</div>
<div class="col-md-6">
<label for="edit-email" class="form-label">Email <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="edit-email" required>
<div class="invalid-feedback">Por favor, ingrese un email válido.</div>
</div>
<div class="col-md-6">
<label for="edit-telefono" class="form-label">Teléfono</label>
<input type="tel" class="form-control" id="edit-telefono" pattern="[0-9]{8,}">
<div class="invalid-feedback">Por favor, ingrese un número de teléfono válido.</div>
</div>
<div class="col-md-6">
<label for="edit-direccion" class="form-label">Dirección</label>
<input type="text" class="form-control" id="edit-direccion">
</div>
<div class="col-md-6">
<label for="edit-nit" class="form-label">NIT</label>
<input type="text" class="form-control" id="edit-nit">
</div>
<div class="col-md-6">
<label for="edit-cui" class="form-label">CUI</label>
<input type="text" class="form-control" id="edit-cui">
</div>
<div class="col-md-6">
<label for="edit-fecha_nacimiento" class="form-label">Fecha de Nacimiento</label>
<input type="date" class="form-control" id="edit-fecha_nacimiento">
</div>
<div class="col-md-6">
<label for="edit-activoSwitch" class="form-label d-block">Estado</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="edit-activoSwitch">
<label class="form-check-label" for="edit-activoSwitch">Activo</label>
</div>
</div>
</div>
<div class="mt-4 text-end">
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">Cancelar</button>
<button type="submit" class="btn btn-warning">
<i class="fas fa-save me-2"></i>Guardar Cambios
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
addPaginationListeners();
addModalListeners();
resetFormOnModalClose();
});
const resetFormOnModalClose = () => {
const addClienteModal = document.getElementById('addClienteModal');
addClienteModal.addEventListener('show.bs.modal', () => {
resetForm('addClienteForm');
});
const editClienteModal = document.getElementById('editClienteModal');
editClienteModal.addEventListener('hide.bs.modal', () => {
resetForm('editClienteForm');
});
}
const resetForm = (formId) => {
const form = document.getElementById(formId);
form.reset(); // Esto limpia todos los inputs del formulario
if (formId === 'editClienteForm') {
document.getElementById('edit-activoSwitch').checked = false; // Asegurar que el checkbox también se limpie
}
}
const addPaginationListeners = () => {
document.querySelectorAll('.page-link').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
const page = link.getAttribute('data-page');
fetch(`/clientes?page=${page}`)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
document.getElementById('clientesBody').innerHTML = doc.querySelector('#clientesBody').innerHTML;
document.getElementById('pagination').innerHTML = doc.querySelector('#pagination').innerHTML;
addPaginationListeners();
addModalListeners(); // Re-inicializar modales para los nuevos elementos
})
.catch(error => {
console.error('Error al cambiar de página:', error);
});
});
});
}
// Crear cliente
document.getElementById('addClienteForm').addEventListener('submit', (e) => {
e.preventDefault();
const nombre = document.getElementById('nombre').value;
const direccion = document.getElementById('direccion').value;
const telefono = document.getElementById('telefono').value;
const email = document.getElementById('email').value;
const nit = document.getElementById('nit').value;
const cui = document.getElementById('cui').value;
const fecha_nacimiento = document.getElementById('fecha_nacimiento').value;
const active = document.getElementById('activoSwitch').checked ? 1 : 0;
fetch('/cliente/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ nombre, direccion, telefono, email, nit, cui, fecha_nacimiento, active })
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
Swal.fire({
icon: 'success',
title: 'Cliente agregado',
text: 'El cliente fue agregado exitosamente.',
showConfirmButton: false,
timer: 1500
}).then(() => {
location.reload();
});
} else {
Swal.fire('Error', data.message, 'error');
}
})
.catch(error => {
console.error('Error al agregar cliente:', error);
Swal.fire('Error', 'Ocurrió un error al intentar agregar el cliente.', 'error');
});
});
// Función para volver a inicializar los modales
const addModalListeners = () => {
document.querySelectorAll('.edit-btn').forEach(button => {
button.addEventListener('click', () => {
const id = button.getAttribute('data-id'); // Obtener el ID del cliente
fetch(`/cliente/get/${id}`)
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
// Llenar el formulario de edición con los datos recibidos
document.getElementById('edit-id').value = data.cliente.id;
document.getElementById('edit-nombre').value = data.cliente.nombre;
document.getElementById('edit-direccion').value = data.cliente.direccion;
document.getElementById('edit-telefono').value = data.cliente.telefono;
document.getElementById('edit-email').value = data.cliente.email;
document.getElementById('edit-nit').value = data.cliente.nit;
document.getElementById('edit-cui').value = data.cliente.cui;
document.getElementById('edit-fecha_nacimiento').value = data.cliente.fecha_nacimiento;
document.getElementById('edit-activoSwitch').checked = data.cliente.active == 1;
} else {
Swal.fire('Error', 'No se pudo obtener los datos del cliente', 'error');
}
})
.catch(error => {
console.error('Error al obtener los datos del cliente:', error);
Swal.fire('Error', 'Ocurrió un error al obtener los datos del cliente.', 'error');
});
});
});
// Editar cliente (enviar cambios al servidor)
document.getElementById('editClienteForm').addEventListener('submit', (e) => {
e.preventDefault();
const id = document.getElementById('edit-id').value;
const nombre = document.getElementById('edit-nombre').value;
const direccion = document.getElementById('edit-direccion').value;
const telefono = document.getElementById('edit-telefono').value;
const email = document.getElementById('edit-email').value;
const nit = document.getElementById('edit-nit').value;
const cui = document.getElementById('edit-cui').value;
const fecha_nacimiento = document.getElementById('edit-fecha_nacimiento').value;
const active = document.getElementById('edit-activoSwitch').checked ? 1 : 0;
fetch('/cliente/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id, nombre, direccion, telefono, email, nit, cui, fecha_nacimiento, active })
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
Swal.fire({
icon: 'success',
title: 'Cliente actualizado',
text: 'El cliente fue actualizado exitosamente.',
showConfirmButton: false,
timer: 1000
}).then(() => {
setTimeout(() => {
location.reload();
}, 600);
});
} else {
Swal.fire('Error', data.message, 'error');
}
})
.catch(error => {
console.error('Error al actualizar cliente:', error);
Swal.fire('Error', 'Ocurrió un error al intentar actualizar el cliente.', 'error');
});
});
// Eliminar cliente
document.querySelectorAll('.delete-btn').forEach(button => {
button.addEventListener('click', () => {
const id = button.getAttribute('data-id'); // Obtener el ID del cliente a eliminar
Swal.fire({
title: '¿Estás seguro?',
text: 'No podrás revertir esta acción',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Sí, eliminar',
cancelButtonText: 'Cancelar'
}).then((result) => {
if (result.isConfirmed) {
fetch('/cliente/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id }) // Enviar el ID del cliente en la solicitud
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
Swal.fire({
icon: 'success',
title: 'Cliente eliminado',
text: 'El cliente fue eliminado exitosamente.',
showConfirmButton: false,
timer: 1000
}).then(() => {
location.reload();
});
} else {
Swal.fire('Error', data.message, 'error');
}
})
.catch(error => {
console.error('Error al eliminar cliente:', error);
Swal.fire('Error', 'Ocurrió un error al intentar eliminar el cliente.', 'error');
});
}
});
});
});
}
</script>
<?php include __DIR__ . "/../../layouts/footer.php"; ?>

View File

View File

@ -0,0 +1,483 @@
<?php include __DIR__ . "/../../layouts/header.php"; ?>
<style>
/* --- Diseño adaptable (Responsive) y estilos de juego --- */
body {
background-color: #f0f2f5;
}
.game-container {
background-color: #f5f5f5;
border-radius: 15px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-top: 30px;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease-in-out;
}
.card:hover {
transform: translateY(-2px);
}
.achievement {
transition: transform 0.3s ease-in-out;
}
.achievement:hover {
transform: scale(1.05);
}
.progress {
height: 25px;
font-size: 0.8rem;
background-color: #e9ecef;
border-radius: 1rem;
overflow: hidden;
}
.progress-bar {
display: flex;
align-items: center;
justify-content: center;
background-color: #007bff;
color: white;
transition: width 0.6s ease;
}
.btn-primary {
background-color: #4e73df;
border-color: #4e73df;
}
.btn-primary:hover {
background-color: #2e59d9;
border-color: #2653d4;
}
.list-group-item {
border-left: 5px solid #4e73df;
margin-bottom: 10px;
border-radius: 5px;
}
.badge {
font-size: 0.8rem;
padding: 0.4em 0.6em;
}
.text-primary {
color: #4e73df !important;
}
/* Animaciones */
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.075);
}
100% {
transform: scale(1);
}
}
.pulse {
animation: pulse 2s infinite;
}
/* Breakpoint para tablets y pantallas medianas (≥ 768px) */
@media (max-width: 767.98px) {
.achievement-card {
margin-bottom: 1rem;
}
}
/* Breakpoint para teléfonos y pantallas pequeñas (≤ 576px) */
@media (max-width: 576px) {
.card-title {
font-size: 1.25rem;
}
}
/* Block: profile-card */
.profile-card {
background: linear-gradient(145deg, #ffffff, #f5f5f5);
border-radius: 16px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
font-family: 'Segoe UI', system-ui, sans-serif;
}
/* Elements */
.profile-card__header {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
}
.profile-card__icon {
background: linear-gradient(45deg, #4e73df, #2e59d9);
color: white;
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
font-size: 1.5rem;
}
.profile-card__title {
color: #1a1a1a;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.profile-card__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.profile-card__stat-item {
background: rgba(78, 115, 223, 0.1);
padding: 1rem;
border-radius: 12px;
text-align: center;
}
.profile-card__stat-label {
color: #666;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.profile-card__stat-value {
color: #1a1a1a;
font-size: 1.25rem;
font-weight: 600;
}
.profile-card__progress {
background: #e0e0e0;
border-radius: 8px;
height: 12px;
overflow: hidden;
position: relative;
}
.profile-card__progress-bar {
background: linear-gradient(90deg, #4e73df, #2e59d9); /* Colores ajustados */
height: 100%;
transition: width 0.3s ease;
}
/* Modifiers */
.profile-card__progress-bar--low {
background: linear-gradient(90deg, #FFA726, #F57C00);
}
.profile-card__progress-bar--high {
background: linear-gradient(90deg, #66BB6A, #388E3C);
}
</style>
<div class="container-fluid px-3 px-md-4 mt-4">
<div class="game-container">
<div class="row mt-3">
<div class="col-12">
<h1 class="text-center fw-bold h2 text-dark">
<i class="fas fa-dumbbell mr-2"></i> Rutinas Personalizadas
</h1>
</div>
</div>
<div class="row mt-4">
<div class="col-12 col-md-4">
<div class="profile-card">
<div class="profile-card__header">
<div class="profile-card__icon">
<i class="fas fa-user-circle"></i>
</div>
<h2 class="profile-card__title">Perfil de Gymbro</h2>
</div>
<div class="profile-card__stats">
<div class="profile-card__stat-item">
<div class="profile-card__stat-label">Nivel</div>
<div class="profile-card__stat-value" id="userLevel">1</div>
</div>
<div class="profile-card__stat-item">
<div class="profile-card__stat-label">Puntos XP</div>
<div class="profile-card__stat-value" id="userPoints">0</div>
</div>
</div>
<div class="profile-card__progress">
<div class="profile-card__progress-bar" id="expProgress" role="progressbar" aria-valuenow="0"
aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
<div class="col-12 col-md-8">
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title"><i class="fas fa-tasks mr-2"></i> Misión Diaria</h5>
<select id="routineType" class="form-select mb-3">
<option value="fullBody">Full Body</option>
<option value="upperLower">Torso/Pierna</option>
</select>
<ul id="exerciseList" class="list-group mb-3">
<!-- Los ejercicios se agregarán dinámicamente aquí -->
</ul>
<button id="completeRoutine" class="btn btn-primary btn-lg btn-block">
<i class="fas fa-check-circle mr-2"></i> Completar Misión
</button>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<h3 class="text-center mb-3 text-primary"><i class="fas fa-trophy mr-2"></i> Logros Desbloqueados</h3>
<div id="achievements" class="row">
<!-- Los logros se agregarán dinámicamente aquí -->
</div>
</div>
</div>
</div>
</div>
<!-- SweetAlert2 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const exercisesByType = {
fullBody: [
{ name: 'Sentadillas', points: 15, icon: 'fa-dumbbell' },
{ name: 'Flexiones', points: 10, icon: 'fa-child' },
{ name: 'Peso muerto', points: 20, icon: 'fa-dumbbell' },
{ name: 'Dominadas', points: 15, icon: 'fa-child' },
{ name: 'Burpees', points: 20, icon: 'fa-running' }
],
upperLower: {
upper: [
{ name: 'Press de banca', points: 15, icon: 'fa-dumbbell' },
{ name: 'Remo con barra', points: 15, icon: 'fa-dumbbell' },
{ name: 'Press militar', points: 12, icon: 'fa-dumbbell' },
{ name: 'Curl de bíceps', points: 10, icon: 'fa-dumbbell' },
{ name: 'Tríceps en polea', points: 10, icon: 'fa-dumbbell' }
],
lower: [
{ name: 'Sentadillas', points: 15, icon: 'fa-dumbbell' },
{ name: 'Peso muerto', points: 20, icon: 'fa-dumbbell' },
{ name: 'Prensa de piernas', points: 15, icon: 'fa-dumbbell' },
{ name: 'Elevaciones de pantorrilla', points: 10, icon: 'fa-dumbbell' },
{ name: 'Hip thrust', points: 12, icon: 'fa-dumbbell' }
]
},
pushPullLegs: {
push: [
{ name: 'Press de banca', points: 15, icon: 'fa-dumbbell' },
{ name: 'Press militar', points: 12, icon: 'fa-dumbbell' },
{ name: 'Fondos en paralelas', points: 15, icon: 'fa-child' },
{ name: 'Elevaciones laterales', points: 10, icon: 'fa-dumbbell' },
{ name: 'Extensiones de tríceps', points: 10, icon: 'fa-dumbbell' }
],
pull: [
{ name: 'Dominadas', points: 15, icon: 'fa-child' },
{ name: 'Remo con barra', points: 15, icon: 'fa-dumbbell' },
{ name: 'Curl de bíceps', points: 10, icon: 'fa-dumbbell' },
{ name: 'Face pull', points: 12, icon: 'fa-dumbbell' },
{ name: 'Remo en T', points: 12, icon: 'fa-dumbbell' }
],
legs: [
{ name: 'Sentadillas', points: 15, icon: 'fa-dumbbell' },
{ name: 'Peso muerto', points: 20, icon: 'fa-dumbbell' },
{ name: 'Prensa de piernas', points: 15, icon: 'fa-dumbbell' },
{ name: 'Extensiones de cuádriceps', points: 10, icon: 'fa-dumbbell' },
{ name: 'Curl de isquiotibiales', points: 10, icon: 'fa-dumbbell' }
]
}
};
const achievements = [
{ name: 'Principiante', description: 'Completa tu primera misión', icon: 'fa-star', unlocked: false },
{ name: 'Constante', description: 'Completa 5 misiones', icon: 'fa-fire', unlocked: false },
{ name: 'Maestro del Fitness', description: 'Alcanza el nivel 5', icon: 'fa-fist-raised', unlocked: false },
{ name: 'Imparable', description: 'Acumula 1000 puntos', icon: 'fa-trophy', unlocked: false }
];
let userPoints = 0;
let userLevel = 1;
let routinesCompleted = 0;
function updateUserInterface() {
document.getElementById('userPoints').textContent = userPoints;
document.getElementById('userLevel').textContent = userLevel;
const expProgress = (userPoints % 100) / 100 * 100;
const progressBar = document.getElementById('expProgress');
progressBar.style.width = `${expProgress}%`;
progressBar.setAttribute('aria-valuenow', expProgress);
// Eliminado: progressBar.textContent
}
function populateExerciseList() {
const exerciseList = document.getElementById('exerciseList');
exerciseList.innerHTML = '';
const routineType = document.getElementById('routineType').value;
let exercises;
if (routineType === 'fullBody') {
exercises = exercisesByType.fullBody;
} else if (routineType === 'upperLower') {
exercises = Math.random() < 0.5 ? exercisesByType.upperLower.upper : exercisesByType.upperLower.lower;
} else {
const pplTypes = ['push', 'pull', 'legs'];
const randomType = pplTypes[Math.floor(Math.random() * pplTypes.length)];
exercises = exercisesByType.pushPullLegs[randomType];
}
exercises.forEach((exercise, index) => {
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.innerHTML = `
<span><i class="fas ${exercise.icon} mr-2"></i> ${exercise.name}</span>
<span class="badge bg-primary rounded-pill">${exercise.points} XP</span>
`;
exerciseList.appendChild(li);
});
}
function completeRoutine() {
const routineType = document.getElementById('routineType').value;
let exercises;
if (routineType === 'fullBody') {
exercises = exercisesByType.fullBody;
} else if (routineType === 'upperLower') {
exercises = Math.random() < 0.5 ? exercisesByType.upperLower.upper : exercisesByType.upperLower.lower;
} else {
const pplTypes = ['push', 'pull', 'legs'];
const randomType = pplTypes[Math.floor(Math.random() * pplTypes.length)];
exercises = exercisesByType.pushPullLegs[randomType];
}
const earnedPoints = exercises.reduce((sum, exercise) => sum + exercise.points, 0);
userPoints += earnedPoints;
routinesCompleted++;
while (userPoints >= userLevel * 100) {
userPoints -= userLevel * 100;
userLevel++;
showLevelUpMessage(userLevel);
}
updateUserInterface();
checkAchievements();
showMessageToast(`¡Misión cumplida! Has ganado ${earnedPoints} puntos de experiencia.`);
}
function showLevelUpMessage(newLevel) {
Swal.fire({
title: '¡Subiste de nivel!',
text: `Has alcanzado el nivel ${newLevel}. ¡Sigue así, campeón!`,
icon: 'success',
confirmButtonText: '¡Genial!'
});
}
function checkAchievements() {
achievements.forEach((achievement, index) => {
if (!achievement.unlocked) {
if (
(achievement.name === 'Principiante' && routinesCompleted >= 1) ||
(achievement.name === 'Constante' && routinesCompleted >= 5) ||
(achievement.name === 'Maestro del Fitness' && userLevel >= 5) ||
(achievement.name === 'Imparable' && userPoints + (userLevel - 1) * 100 >= 1000)
) {
achievement.unlocked = true;
updateAchievementDisplay(index);
showAchievementUnlockedMessage(achievement);
}
}
});
}
function updateAchievementDisplay(index) {
const achievementElement = document.getElementById(`achievement-${index}`);
if (achievementElement) {
achievementElement.classList.remove('bg-secondary');
achievementElement.classList.add('bg-success', 'pulse');
}
}
function showAchievementUnlockedMessage(achievement) {
Swal.fire({
title: '¡Logro Desbloqueado!',
text: `Has conseguido el logro "${achievement.name}": ${achievement.description}`,
icon: 'success',
confirmButtonText: '¡Increíble!'
});
}
function populateAchievements() {
const achievementsContainer = document.getElementById('achievements');
achievements.forEach((achievement, index) => {
const achievementElement = document.createElement('div');
achievementElement.className = 'col-md-3 mb-3';
achievementElement.innerHTML = `
<div id="achievement-${index}" class="card text-white ${achievement.unlocked ? 'bg-success' : 'bg-secondary'} achievement">
<div class="card-body text-center">
<h5 class="card-title"><i class="fas ${achievement.icon} mr-2"></i> ${achievement.name}</h5>
<p class="card-text">${achievement.description}</p>
</div>
</div>
`;
achievementsContainer.appendChild(achievementElement);
});
}
function showMessageToast(message, type = 'success') {
Swal.fire({
text: message,
icon: type,
showConfirmButton: false,
timer: 2000,
toast: true,
position: 'top-end',
background: '#f8f9fa',
color: '#000',
iconColor: type === 'error' ? '#dc3545' : type === 'warning' ? '#ffc107' : '#28a745',
customClass: {
popup: 'shadow-sm'
}
});
}
document.getElementById('routineType').addEventListener('change', populateExerciseList);
document.getElementById('completeRoutine').addEventListener('click', completeRoutine);
populateExerciseList();
populateAchievements();
updateUserInterface();
});
</script>
<?php include __DIR__ . "/../../layouts/footer.php"; ?>

View File

@ -0,0 +1,635 @@
<?php include __DIR__ . "/../../layouts/header.php"; ?>
<style>
.kanban-container {
display: flex;
justify-content: space-between;
margin-top: 2rem;
}
.kanban-column {
flex: 1;
margin: 0 1rem;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
overflow: hidden;
transition: box-shadow 0.3s;
}
.kanban-column:hover {
box-shadow: 0 0 10px rgba(0, 123, 255, 0.5);
}
.kanban-header {
background-color: #007bff;
color: white;
padding: 1rem;
text-align: center;
font-weight: bold;
}
.kanban-task-container {
min-height: 40vh;
max-height: 60vh;
overflow-y: auto;
padding: 0.5rem;
}
.kanban-task-item {
background-color: #e9ecef;
margin: 0.5rem 0;
padding: 0.5rem;
border-radius: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.3s;
cursor: move;
position: relative;
}
.kanban-task-item:hover {
background-color: #d3d3d3;
}
.kanban-task-actions button {
margin-left: 0.5rem;
}
.task-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background-color: #f9f9f9;
border-radius: 0.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.task-form__input-group {
display: flex;
gap: 1rem;
align-items: center;
}
.task-form__input {
flex: 1;
}
.task-form__badge {
margin-top: 0.5rem;
}
.sortable-ghost {
opacity: 0.4;
}
.color-container-html {
min-width: 5rem;
border-radius: 0.5rem;
border: 2px solid #ccc;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
transition: border 0.3s ease, box-shadow 0.3s ease;
cursor: pointer;
}
.color-container-html:hover {
border-color: #33d17a;
}
.color-container-html:focus {
border-color: #33d17a;
box-shadow: 0 0 5px rgba(51, 209, 122, 0.5);
outline: none;
}
.priority-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
.priority-high {
background-color: #dc3545;
color: white;
}
.priority-medium {
background-color: #ffc107;
color: black;
}
.priority-low {
background-color: #28a745;
color: white;
}
/* Estilo para la descripción en la tarjeta */
.task-description {
font-size: 0.875rem;
margin-top: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Estilo para la fecha de vencimiento */
.due-date {
font-size: 0.75rem;
margin-top: 0.25rem;
color: #6c757d;
}
/* Estilos para el formulario de tareas */
.task-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background-color: #f9f9f9;
border-radius: 0.5rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.swal2-input,
.swal2-textarea,
.swal2-select {
border: 1px solid #ced4da;
border-radius: 0.3rem;
padding: 0.5rem;
font-size: 1rem;
transition: border-color 0.3s;
}
.swal2-input:focus,
.swal2-textarea:focus,
.swal2-select:focus {
border-color: #007bff;
outline: none;
box-shadow: 0 0 5px rgba(0, 123, 255, 0.5);
}
.color-picker {
display: flex;
align-items: center;
gap: 0.5rem;
}
#taskColor,
#editTaskColor {
min-width: 5rem;
}
label {
font-weight: bold;
margin-bottom: 0.5rem;
}
@media (max-width: 480px) {
.task-form {
padding: 0.5rem;
}
.swal2-input,
.swal2-textarea,
.swal2-select {
font-size: 0.9rem;
}
}
@media (max-width: 767.98px) {
.kanban-container {
flex-direction: column;
}
.kanban-column {
margin-bottom: 1rem;
}
}
</style>
<div class="container-fluid px-3 px-md-4 mt-4">
<div class="text-center mb-4">
<h1 class="fw-bold">Kanban Dashboard</h1>
</div>
<div id="alertMessages" class="container mb-3"></div>
<div class="d-flex justify-content-center mb-3">
<button id="addTaskButton" class="btn btn-success">
<i class="fas fa-plus"></i> Añadir Tarea
</button>
</div>
<div class="kanban-container">
<div class="kanban-column" id="pendingList" data-status="1">
<div class="kanban-header">Pendiente</div>
<div class="kanban-task-container"></div>
</div>
<div class="kanban-column" id="inProgressList" data-status="2">
<div class="kanban-header">En Proceso</div>
<div class="kanban-task-container"></div>
</div>
<div class="kanban-column" id="completedList" data-status="3">
<div class="kanban-header">Completada</div>
<div class="kanban-task-container"></div>
</div>
<div class="kanban-column" id="canceledList" data-status="4">
<div class="kanban-header">Cancelada</div>
<div class="kanban-task-container"></div>
</div>
</div>
</div>
<!-- Scripts necesarios -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.14.0/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.4.2/dist/tinycolor-min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const apiEndpoints = {
fetch: '/tareas',
create: '/tarea/create',
update: '/tarea/update',
updateStatus: '/tarea/update-status',
delete: '/tarea/delete',
get: (id) => `/tarea/get/${id}`
};
const columns = Array.from(document.querySelectorAll('.kanban-column'));
const addTaskButton = document.getElementById('addTaskButton');
/**
* @function fetchTasks
* @description Obtiene las tareas desde el servidor y las carga en la UI y el StateManager
*/
const fetchTasks = async () => {
try {
const response = await fetch(apiEndpoints.fetch);
const { tasks } = await response.json();
const taskMap = tasks.reduce((acc, task) => {
acc[task.task_id] = task;
return acc;
}, {});
StateManager.set('tasks', taskMap);
tasks.forEach(task => addTaskToColumn(task));
} catch (error) {
console.error('Error al cargar las tareas:', error);
showMessage('Error al cargar las tareas', 'error');
}
};
/**
* @function buildTaskHTML
* @description Crea el HTML para una tarea
* @param {Object} task - Objeto de tarea
* @returns {HTMLElement} Elemento DOM de la tarea
*/
const buildTaskHTML = (task) => {
const taskElement = document.createElement('div');
taskElement.className = 'kanban-task-item';
taskElement.setAttribute('data-task-id', task.task_id);
taskElement.style.backgroundColor = task.color;
taskElement.style.color = tinycolor(task.color).isLight() ? '#000000' : '#ffffff';
taskElement.innerHTML = `
<div>
<h5>${task.task_title}</h5>
<p class="task-description">${task.description || ''}</p>
<small class="due-date">Vence: ${formatDate(task.due_date)}</small>
</div>
<div class="kanban-task-actions">
<button class="btn btn-sm btn-outline-${tinycolor(task.color).isLight() ? 'dark' : 'light'} edit-task-btn">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-outline-${tinycolor(task.color).isLight() ? 'danger' : 'light'} delete-task-btn">
<i class="fas fa-trash"></i>
</button>
</div>
`;
taskElement.querySelector('.edit-task-btn').addEventListener('click', () => handleEditTask(task.task_id));
taskElement.querySelector('.delete-task-btn').addEventListener('click', () => handleDeleteTask(task.task_id));
return taskElement;
};
/**
* @function addTaskToColumn
* @description Agrega una tarea a la columna correspondiente en la UI
* @param {Object} task - Objeto de tarea
*/
const addTaskToColumn = (task) => {
const column = document.querySelector(`[data-status="${task.status_id}"] .kanban-task-container`);
if (column) {
column.appendChild(buildTaskHTML(task));
} else {
console.warn(`Columna con status_id ${task.status_id} no encontrada.`);
}
};
/**
* @function updateTaskStatus
* @description Actualiza el estado de una tarea si ha cambiado
* @param {number} taskId - ID de la tarea
* @param {number} newStatus - Nuevo estado de la tarea
*/
const updateTaskStatus = async (taskId, newStatus) => {
const currentTasks = StateManager.get('tasks', {});
const currentTask = currentTasks[taskId];
if (currentTask && currentTask.status_id === newStatus) {
return;
}
StateManager.update('tasks', {
[taskId]: { ...currentTask, status_id: newStatus }
});
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
if (taskElement) {
const newColumn = document.querySelector(`[data-status="${newStatus}"] .kanban-task-container`);
if (newColumn) {
newColumn.appendChild(taskElement);
} else {
console.warn(`Columna con status_id ${newStatus} no encontrada.`);
}
}
try {
const response = await fetch(apiEndpoints.updateStatus, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId, status_id: newStatus })
});
const data = await response.json();
if (data.message) {
showMessage(data.message, 'success');
}
} catch (error) {
StateManager.update('tasks', {
[taskId]: { ...currentTask, status_id: currentTask.status_id }
});
if (taskElement) {
const originalColumn = document.querySelector(`[data-status="${currentTask.status_id}"] .kanban-task-container`);
if (originalColumn) {
originalColumn.appendChild(taskElement);
}
}
showMessage('Error al actualizar el estado de la tarea', 'error');
console.error('Error al actualizar el estado de la tarea:', error);
}
};
/**
* @function handleCreateTask
* @description Maneja la creación de una nueva tarea
*/
const handleCreateTask = () => {
Swal.fire({
title: 'Crear Nueva Tarea',
html: `
<div class="task-form">
<input type="text" id="taskTitle" class="swal2-input" placeholder="Título de la Tarea">
<textarea id="taskDescription" class="swal2-textarea" placeholder="Descripción"></textarea>
<input type="date" id="taskDueDate" class="swal2-input">
<div class="color-picker">
<input type="color" id="taskColor" class="swal2-input" value="#e9ecef">
</div>
</div>
`,
showCancelButton: true,
confirmButtonText: 'Crear',
cancelButtonText: 'Cancelar',
preConfirm: () => {
const title = document.getElementById('taskTitle').value.trim();
if (!title) {
Swal.showValidationMessage('El título de la tarea es obligatorio');
return false;
}
return {
task_title: title,
description: document.getElementById('taskDescription').value.trim(),
due_date: document.getElementById('taskDueDate').value,
color: document.getElementById('taskColor').value,
status_id: 1
};
}
}).then(async (result) => {
if (result.isConfirmed) {
try {
const response = await fetch(apiEndpoints.create, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result.value)
});
const data = await response.json();
if (data.task_id) {
const newTask = {
task_id: data.task_id,
...result.value
};
addTaskToColumn(newTask);
const currentTasks = StateManager.get('tasks', {});
StateManager.update('tasks', {
[data.task_id]: newTask
});
showMessage('Tarea creada exitosamente', 'success');
} else {
showMessage('Error al crear la tarea', 'error');
}
} catch (error) {
console.error('Error al crear la tarea:', error);
showMessage('Error al crear la tarea', 'error');
}
}
});
};
/**
* @function handleEditTask
* @description Maneja la edición de una tarea existente
* @param {number} taskId - ID de la tarea a editar
*/
const handleEditTask = async (taskId) => {
try {
const response = await fetch(apiEndpoints.get(taskId));
const task = await response.json();
if (!task) {
showMessage('Tarea no encontrada', 'error');
return;
}
const { task_title, description, due_date, color } = task;
Swal.fire({
title: 'Editar Tarea',
html: `
<div class="task-form">
<input type="text" id="editTaskTitle" class="swal2-input" placeholder="Título de la Tarea" value="${escapeHtml(task_title)}">
<textarea id="editTaskDescription" class="swal2-textarea" placeholder="Descripción">${escapeHtml(description)}</textarea>
<input type="date" id="editTaskDueDate" class="swal2-input" value="${due_date ? due_date.split('T')[0] : ''}">
<div class="color-picker">
<input type="color" id="editTaskColor" class="swal2-input" value="${color}">
</div>
</div>
`,
showCancelButton: true,
confirmButtonText: 'Guardar',
cancelButtonText: 'Cancelar',
preConfirm: () => {
const title = document.getElementById('editTaskTitle').value.trim();
if (!title) {
Swal.showValidationMessage('El título de la tarea es obligatorio');
return false;
}
return {
task_title: title,
description: document.getElementById('editTaskDescription').value.trim(),
due_date: document.getElementById('editTaskDueDate').value,
color: document.getElementById('editTaskColor').value
};
}
}).then(async (result) => {
if (result.isConfirmed) {
const updatedTask = {
task_id: taskId,
...result.value
};
try {
const updateResponse = await fetch(apiEndpoints.update, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updatedTask)
});
const updateData = await updateResponse.json();
if (updateResponse.ok) {
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
if (taskElement) {
taskElement.querySelector('h5').textContent = updatedTask.task_title;
taskElement.querySelector('.task-description').textContent = updatedTask.description;
taskElement.querySelector('.due-date').textContent = `Vence: ${formatDate(updatedTask.due_date)}`;
taskElement.style.backgroundColor = updatedTask.color;
const textColor = tinycolor(updatedTask.color).isLight() ? '#000000' : '#ffffff';
taskElement.style.color = textColor;
}
StateManager.update('tasks', {
[taskId]: updatedTask
});
showMessage(updateData.message, 'success');
} else {
showMessage(updateData.message || 'Error al actualizar la tarea', 'error');
}
} catch (error) {
console.error('Error al actualizar la tarea:', error);
showMessage('Error al actualizar la tarea', 'error');
}
}
});
} catch (error) {
console.error('Error al obtener los datos de la tarea:', error);
showMessage('Error al cargar la tarea para editar', 'error');
}
};
/**
* @function handleDeleteTask
* @description Maneja la eliminación de una tarea
* @param {number} taskId - ID de la tarea a eliminar
*/
const handleDeleteTask = async (taskId) => {
try {
const result = await Swal.fire({
title: '¿Estás seguro?',
text: 'Esta acción no se puede deshacer',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#dc3545',
cancelButtonColor: '#6c757d',
confirmButtonText: 'Sí, eliminar',
cancelButtonText: 'Cancelar',
});
if (result.isConfirmed) {
try {
const response = await fetch(apiEndpoints.delete, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId })
});
const data = await response.json();
if (data.message) {
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
if (taskElement) {
taskElement.remove();
}
const currentTasks = StateManager.get('tasks', {});
if (currentTasks[taskId]) {
delete currentTasks[taskId];
StateManager.set('tasks', currentTasks);
}
showMessage(data.message, 'success');
} else {
showMessage('Error al eliminar la tarea', 'error');
}
} catch (error) {
console.error('Error al eliminar la tarea:', error);
showMessage('Error al eliminar la tarea', 'error');
}
}
} catch (error) {
console.error('Error al eliminar la tarea:', error);
showMessage('Error al eliminar la tarea', 'error');
}
};
/**
* @function setupSortable
* @description Configura SortableJS para las columnas
* @param {HTMLElement} column - Elemento de columna
*/
const setupSortable = (column) => {
new Sortable(column.querySelector('.kanban-task-container'), {
group: 'tasks',
animation: 150,
ghostClass: 'sortable-ghost',
onEnd: ({ item, to }) => {
const taskId = item.getAttribute('data-task-id');
const newStatus = parseInt(to.closest('.kanban-column').getAttribute('data-status'), 10);
updateTaskStatus(taskId, newStatus);
}
});
};
if (!StateManager.has('tasks')) {
StateManager.set('tasks', {});
}
columns.forEach(column => {
setupSortable(column);
});
addTaskButton.addEventListener('click', handleCreateTask);
fetchTasks();
});
</script>
<?php include __DIR__ . "/../../layouts/footer.php"; ?>

229
bin/Crafter.php Normal file
View File

@ -0,0 +1,229 @@
<?php
namespace Console;
use Exception;
class Crafter
{
private $config;
public function run($command, $arguments)
{
if (is_null($command)) {
$this->printHelp();
return;
}
switch ($command) {
case "create:controller":
$this->createController($arguments);
break;
case "create:repository":
$this->createRepository($arguments);
break;
case "create:entity":
$this->createEntity($arguments);
break;
case "create:config":
$this->createNewConfig($arguments);
break;
default:
echo "Unknown command: $command\n";
$this->printHelp();
break;
}
}
/**
* Create a new file with the given content.
* This method ensures that the directory exists and the file does not already exist.
*
* @param string $filePath The path to the file.
* @param string $content The content to write to the file.
* @throws Exception If the file already exists or if it cannot be created.
*/
private function createFile(string $filePath, string $content): void
{
$directory = dirname($filePath);
if (!is_dir($directory)) {
if (!mkdir($directory, 0755, true)) {
throw new Exception("Failed to create directory: $directory");
}
}
if (file_exists($filePath)) {
throw new Exception("File already exists: $filePath");
}
if (file_put_contents($filePath, $content) === false) {
throw new Exception("Failed to write file: $filePath");
}
echo "File created successfully: $filePath\n";
}
/**
* Creates a new controller class file.
* This function generates a new PHP class file for a controller in the 'app/Controllers' directory.
* It uses the provided name or defaults to 'NewController' if no name is given. The controller
* class is created with a basic structure including the namespace and an empty class body.
*
* @param array $arguments The arguments passed to the command. The first
* argument is the controller name.
*
* @throws Exception If the file already exists or if it cannot be created.
*
* @return void
*
* Usage example:
* $this->createController(['User']); // Creates a UserController
*/
private function createController($arguments)
{
$controllerName = $arguments[0] ?? "NewController";
$controllerName = ucfirst($arguments[0] ?? "New") . "Controller";
$filePath = "app/Controllers/{$controllerName}.php";
$controllerContent = "<?php\n\nnamespace App\Controllers;\n\nclass {$controllerName} extends BaseController\n{\n // Add your controller methods here\n}\n";
try {
$this->createFile($filePath, $controllerContent);
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}
}
/**
* Creates a new repository class file.
*
* This function generates a new PHP class file for a repository in the 'app/Repositories' directory.
* It uses the provided name or defaults to 'NewRepository' if no name is given. The repository
* class is created with a basic structure including the namespace and an empty class body
* that extends BaseRepository.
*
* @param array $arguments The arguments passed to the command. The first element, if present,
* is used as the repository name.
*
* @throws Exception If there's an error in file creation, which is caught and its message displayed.
*
* @return void
*
* Usage example:
* $this->createRepository(['User']); // Creates a UserRepository
*/
private function createRepository($arguments)
{
$repositoryName = $arguments[0] ?? "NewRepository";
$repositoryName = ucfirst($arguments[0] ?? "New") . "Repository";
$filePath = "app/Repositories/{$repositoryName}.php";
$repositoryContent = "<?php\n\nnamespace App\Repositories;\n\nclass {$repositoryName} extends BaseRepository\n{\n // Add your repository methods here\n}\n";
try {
$this->createFile($filePath, $repositoryContent);
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}
}
private function createNewConfig($arguments)
{
$configName = $arguments[0] ?? "NewConfig";
echo "Creating new config file: $configName\n";
echo "Arguments: " . implode(", ", $arguments) . "\n";
}
/**
* Creates a new entity class file.
*
* This function generates a new PHP class file for an entity in the 'app/Entities' directory.
* It uses the provided name or defaults to 'NewEntity' if no name is given. The entity
* class is created with a basic structure including the namespace and an empty class body.
*
* @param array $arguments An array of command-line arguments. The first element, if present,
* is used as the entity name.
*
* @throws Exception If there's an error in file creation, which is caught and its message displayed.
*
* @return void
*
* Usage example:
* $this->createEntity(['User']); // Creates a User entity
*/
private function createEntity($arguments)
{
$entityName = $arguments[0] ?? "NewEntity";
$entityName = ucfirst($entityName);
$primaryKey = 'id'; // Valor por defecto
foreach ($arguments as $index => $arg) {
if ($arg === '--pk' && isset($arguments[$index + 1])) {
$primaryKey = $arguments[$index + 1];
break;
}
}
$filePath = "app/Entities/{$entityName}.php";
$entityContent = <<<PHP
<?php
namespace App\Entities;
use App\Entities\BaseEntity;
class {$entityName} extends BaseEntity
{
/**
* @var int Identificador único de {$entityName}.
*/
public int \${$primaryKey};
/**
* Obtiene el nombre de la clave primaria de la entidad.
*
* @return string
*/
public static function getPrimaryKey(): string
{
return '{$primaryKey}';
}
}
PHP;
try {
$this->createFile($filePath, $entityContent);
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}
}
protected function printHelp()
{
echo "
( (
( ( ) ( )\ ) )\ )
)\ ( ) )\ ) ( /( ( ( )\ (()/( (()/(
(((_) )( ( /( (()/( )\()) ))\ )( (((_) /(_)) /(_))
)\___ (()\ )(_)) /(_)) (_))/ /((_) (()\ )\___ (_)) (_))
((/ __| ((_) ((_)_ (_) _| | |_ (_)) ((_) ___ ((/ __| | | |_ _|
| (__ | '_| / _` | | _| | _| / -_) | '_| |___| | (__ | |__ | |
\___| |_| \__,_| |_| \__| \___| |_| \___| |____| |___|
\n\n";
echo "Available Commands:\n";
echo " create:controller Create a new controller\n";
echo " create:repository Create a new repository\n";
echo " create:entity Create a new entity\n";
echo " create:config Create a new config\n";
echo "\nUsage:\n";
echo " crafter <command> [arguments]\n";
echo "\nExamples:\n";
echo " crafter create:controller User\n";
echo " crafter create:repository User\n";
echo " crafter create:entity User\n";
echo " crafter create:config database\n";
}
}

0
bin/SysTask/.gitkeep Normal file
View File

23
composer.json Normal file
View File

@ -0,0 +1,23 @@
{
"require": {
"catfan/medoo": "^2.1",
"phpmailer/phpmailer": "^6.9",
"monolog/monolog": "^3.7",
"vlucas/phpdotenv": "^5.6",
"consolidation/robo": "^5.0",
"altorouter/altorouter": "^2.0",
"symfony/http-foundation": "^7.1",
"dompdf/dompdf": "^3.0",
"phpoffice/phpspreadsheet": "^2.2",
"moneyphp/money": "^4.5",
"firebase/php-jwt": "^6.10",
"paragonie/sodium_compat": "^2.1",
"jms/serializer": "^3.30"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Console\\": "bin/"
}
}
}

11
crafter Normal file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env php
<?php
require __DIR__ . '/vendor/autoload.php';
use Console\Crafter;
$command = $_SERVER['argv'][1] ?? null;
$handler = new Crafter();
$handler->run($command, array_slice($_SERVER['argv'], 2));

43
docker-compose.yml Normal file
View File

@ -0,0 +1,43 @@
services:
app:
build: .
container_name: gtd_app
ports:
- "8000:80"
#- "9003:9003" # Xdebug
volumes:
- .:/var/www/html
environment:
- APP_ENV=local
- DB_HOST=${DB_HOST}
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- DB_DRIVER=${DB_DRIVER}
- DB_CHARSET=${DB_CHARSET}
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- db
command: >
bash -c "
/var/www/html/docker/init_scripts/update_composer.sh &&
/var/www/html/docker/init_scripts/check_php_extensions.sh &&
apache2-foreground
"
db:
image: mysql:8.0
container_name: gtd_db
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASS}
MYSQL_DATABASE: ${DB_NAME}
volumes:
- db_data:/var/lib/mysql
- ./schema:/docker-entrypoint-initdb.d
ports:
- "3310:3306"
volumes:
db_data:

View File

@ -0,0 +1,52 @@
#!/bin/bash
GREEN='\033[0;32m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}=======================================${NC}"
echo -e "${BLUE} Verificación de Extensiones de PHP ${NC}"
echo -e "${BLUE}=======================================${NC}"
REQUIRED_EXTENSIONS=(
"xdebug"
"zip"
"mbstring"
"xml"
"intl"
"gmp"
"sodium"
"pdo_mysql"
)
echo -e "\nVerificando extensiones requeridas:\n"
ENABLED_COUNT=0
DISABLED_COUNT=0
for extension in "${REQUIRED_EXTENSIONS[@]}"; do
if php -m | grep -q "$extension"; then
echo -e " ${GREEN}$extension${NC}"
((ENABLED_COUNT++))
else
echo -e " ${RED}$extension${NC}"
((DISABLED_COUNT++))
fi
done
echo -e "\n${BLUE}=======================================${NC}"
echo -e "${BLUE} Resumen ${NC}"
echo -e "${BLUE}=======================================${NC}"
echo -e " Extensiones habilitadas: ${GREEN}$ENABLED_COUNT${NC}"
echo -e " Extensiones faltantes: ${RED}$DISABLED_COUNT${NC}"
echo -e "${BLUE}=======================================${NC}"
if [ $DISABLED_COUNT -eq 0 ]; then
echo -e "\n${GREEN}¡Todas las extensiones requeridas están habilitadas!${NC}"
else
echo -e "\n${RED}Algunas extensiones requeridas no están habilitadas.${NC}"
echo -e "Por favor, instale las extensiones faltantes."
fi
#php -m

View File

@ -0,0 +1,63 @@
#!/bin/bash
GREEN='\033[0;32m'
RED='\033[0;31m'
BLUE='\033[0;34m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
NC='\033[0m'
print_message() {
echo -e "${BLUE}=======================================${NC}"
echo -e "${BLUE} $1${NC}"
echo -e "${BLUE}=======================================${NC}"
}
PROJECT_DIR="/var/www/html"
print_message "Actualización de Dependencias de Composer"
[ ! -d "$PROJECT_DIR" ] && { echo -e "${RED}Error: El directorio $PROJECT_DIR no existe.${NC}"; exit 1; }
cd "$PROJECT_DIR" || { echo -e "${RED}Error: No se pudo acceder al directorio $PROJECT_DIR${NC}"; exit 1; }
[ ! -f composer.json ] && { echo -e "${RED}Error: No se encontró el archivo composer.json en $PROJECT_DIR${NC}"; exit 1; }
echo -e "\n${YELLOW}Actualizando dependencias...${NC}"
COMPOSER_OUTPUT=$(composer install --no-dev --optimize-autoloader 2>&1)
if echo "$COMPOSER_OUTPUT" | grep -q "Xdebug: \[Step Debug\] Could not connect to debugging client"; then
echo -e "\n${CYAN}Nota sobre Xdebug:${NC}"
echo -e "Se detectó un mensaje de Xdebug intentando conectarse a un cliente de depuración."
echo -e "Esto es normal si no estás depurando activamente y no afecta la actualización de dependencias."
echo -e "\nPara desactivar Xdebug temporalmente, puedes usar:"
echo -e "${YELLOW}php -dxdebug.mode=off /usr/local/bin/composer [comando]${NC}"
echo -e "\nPara desactivar Xdebug permanentemente:"
echo -e "1. Localiza tu php.ini (usa 'php --ini' para encontrarlo)"
echo -e "2. Comenta la línea que carga Xdebug añadiendo un ';' al principio"
echo -e "3. O cambia 'xdebug.mode' a 'off' en la configuración de Xdebug"
echo -e "\nPara evitar intentos de conexión automáticos, añade esta línea a tu php.ini:"
echo -e "${YELLOW}xdebug.start_with_request = trigger${NC}"
fi
echo -e "\n$COMPOSER_OUTPUT"
if [ $? -eq 0 ]; then
echo -e "\n${GREEN}¡Todas las dependencias han sido actualizadas exitosamente!${NC}"
else
echo -e "\n${RED}Error: Hubo un problema al actualizar las dependencias.${NC}"
exit 1
fi
echo -e "\n${YELLOW}Verificando actualizaciones disponibles...${NC}"
OUTDATED=$(composer outdated --direct)
if [ -n "$OUTDATED" ]; then
echo -e "\n${YELLOW}Las siguientes dependencias tienen actualizaciones disponibles:${NC}"
echo "$OUTDATED"
echo -e "\n${YELLOW}Considere actualizarlas manualmente con 'composer update <package>'${NC}"
else
echo -e "\n${GREEN}Todas las dependencias están actualizadas.${NC}"
fi
print_message "Proceso Completado"

7
public/.htaccess Normal file
View File

@ -0,0 +1,7 @@
ReWriteEngine On
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^ index.php [L]

192
public/assets/css/main.css Normal file
View File

@ -0,0 +1,192 @@
/* Fuente base */
* {
font-family: "Poppins", sans-serif;
}
/* Configuración de la barra de navegación */
.navbar {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background-color: #f8f9fa;
}
/* Logo de la barra de navegación */
.navbar-brand {
padding: 0.4rem 1rem;
font-size: 1.6rem;
font-weight: 800;
color: #1a1a1a;
transition:
color 0.3s ease,
transform 0.3s ease;
}
.navbar-brand:hover {
color: hsl(0, 0%, 30%);
transform: scale(1.1);
}
/* Elementos de la barra de navegación */
.nav-item {
margin-right: 0.65rem;
}
.nav-link {
color: #495057;
font-weight: 500;
font-size: 1.1rem;
padding: 12px 20px;
transition: all 0.3s ease;
}
.nav-link:hover,
.nav-link:focus {
color: #007bff;
background-color: rgba(0, 123, 255, 0.1);
}
.nav-link.dropdown-toggle {
font-weight: 600;
}
/* Menú desplegable */
.dropdown-menu {
background-color: #ffffff;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 0.5rem 0;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transition:
opacity 0.3s ease,
transform 0.3s ease;
transform: translateY(10px);
opacity: 0;
}
.dropdown-menu.show {
opacity: 1;
transform: translateY(0);
}
.dropdown-item {
display: flex;
align-items: center;
padding: 10px 20px;
font-size: 1rem;
color: #495057;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
.dropdown-item-content i {
margin-right: 10px;
font-size: 1.2rem;
}
.dropdown-item:hover,
.dropdown-item:focus {
background-color: #e9ecef;
color: #000;
}
/* Estilo de breadcrumb */
.breadcrumb {
background-color: #eeeeee;
border-radius: 0.5rem;
padding: 0.5rem 0.85rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: inline-block;
}
.breadcrumb-item a {
color: #007bff;
text-decoration: none;
font-size: 1rem;
}
.breadcrumb-item a:hover {
text-decoration: underline;
}
.breadcrumb-item.active {
color: #6c757d;
font-weight: 600;
}
.breadcrumb i {
margin-right: 5px;
}
/* --- Diseño adaptable (Responsive) --- */
/* Breakpoint para tablets y pantallas medianas (≥ 768px) */
@media (max-width: 767.98px) {
.navbar-brand {
font-size: 1.4rem;
}
.nav-link {
font-size: 1rem;
padding: 10px 15px;
}
.dropdown-item {
padding: 8px 15px;
}
.breadcrumb-item a {
font-size: 0.9rem;
}
.breadcrumb {
padding: 0.4rem 0.75rem;
}
}
/* Breakpoint para teléfonos y pantallas pequeñas (≤ 576px) */
@media (max-width: 576px) {
.navbar-brand {
font-size: 1.2rem;
}
.nav-item {
margin-right: 0.4rem;
}
.nav-link {
padding: 8px 12px;
font-size: 0.9rem;
}
.dropdown-item {
padding: 7px 12px;
}
.breadcrumb-item a {
font-size: 0.85rem;
}
.breadcrumb {
padding: 0.3rem 0.65rem;
}
}
/* Breakpoint para pantallas grandes (≥ 1200px) */
@media (min-width: 1200px) {
.navbar-brand {
font-size: 1.7rem;
}
.nav-link {
font-size: 1.2rem;
padding: 15px 25px;
}
.breadcrumb-item a {
font-size: 1.1rem;
}
.breadcrumb {
padding: 0.6rem 1rem;
}
}

View File

38
public/index.php Normal file
View File

@ -0,0 +1,38 @@
<?php
require '../vendor/autoload.php';
date_default_timezone_set('America/Guatemala');
use Dotenv\Dotenv;
$dotenv = Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->load();
$router = new AltoRouter();
$routes = include __DIR__ . '/../app/Config/Routes.php';
foreach ($routes as $route) {
$router->map($route['method'], $route['uri'], $route['target']);
}
$match = $router->match();
if ($match) {
try {
if (is_callable($match['target'])) {
call_user_func_array($match['target'], $match['params']);
} else {
if (isset($match['target']) && is_array($match['target'])) {
list($controller, $method) = $match['target'];
$controller = new $controller();
call_user_func_array([$controller, $method], $match['params']);
} else {
(new \App\Controllers\ErrorController())->notFound();
}
}
} catch (Exception $e) {
(new \App\Controllers\ErrorController())->internalServerError();
}
} else {
(new \App\Controllers\ErrorController())->notFound();
}

222
schema/schema.sql Normal file
View File

@ -0,0 +1,222 @@
-- Base de Datos Personal Assistant
CREATE DATABASE IF NOT EXISTS personal_assistant_db;
USE personal_assistant_db;
-- @table: users
-- @description: Tabla de usuarios que contiene la información básica del usuario
CREATE TABLE IF NOT EXISTS users (
user_id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- @table: task_status
-- @description: Tabla de estados de las tareas
CREATE TABLE IF NOT EXISTS task_status (
status_id INT AUTO_INCREMENT PRIMARY KEY,
status_name VARCHAR(50) NOT NULL UNIQUE,
description TEXT
);
-- @table: tasks
-- @description: Tabla de tareas que almacena las tareas creadas por los usuarios
CREATE TABLE IF NOT EXISTS tasks (
task_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
status_id INT NOT NULL,
task_title VARCHAR(150) NOT NULL,
description TEXT,
color VARCHAR(10),
due_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- @table: notification_types
-- @description: Tabla de tipos de notificación
CREATE TABLE IF NOT EXISTS notification_types (
type_id INT AUTO_INCREMENT PRIMARY KEY,
type_name VARCHAR(50) NOT NULL UNIQUE,
description TEXT
);
-- @table: notifications
-- @description: Tabla de notificaciones que almacena las notificaciones enviadas a los usuarios
CREATE TABLE IF NOT EXISTS notifications (
notification_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
type_id INT NOT NULL,
message TEXT NOT NULL,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- @table: settings
-- @description: Tabla de configuraciones del sistema
CREATE TABLE IF NOT EXISTS settings (
setting_id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(50) NOT NULL UNIQUE,
setting_value VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- @table: frequencies
-- @description: Tabla de frecuencias utilizadas para ingresos y gastos
CREATE TABLE IF NOT EXISTS frequencies (
frequency_id INT AUTO_INCREMENT PRIMARY KEY,
frequency_name VARCHAR(50) NOT NULL UNIQUE,
description TEXT
);
-- @table: incomes
-- @description: Tabla de ingresos de los usuarios
CREATE TABLE IF NOT EXISTS incomes (
income_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
income_name VARCHAR(100) NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
frequency_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- @table: expense_categories
-- @description: Tabla de categorías de gastos
CREATE TABLE IF NOT EXISTS expense_categories (
category_id INT AUTO_INCREMENT PRIMARY KEY,
category_name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- @table: expenses
-- @description: Tabla de gastos de los usuarios
CREATE TABLE IF NOT EXISTS expenses (
expense_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
category_id INT NOT NULL,
expense_name VARCHAR(100) NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
frequency_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- @table: email_templates
-- @description: Tabla de plantillas de correo
CREATE TABLE IF NOT EXISTS email_templates (
template_id INT AUTO_INCREMENT PRIMARY KEY,
template_name VARCHAR(100) NOT NULL UNIQUE,
html_content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- @table: email_status
-- @description: Tabla de estados de correo
CREATE TABLE IF NOT EXISTS email_status (
status_id INT AUTO_INCREMENT PRIMARY KEY,
status_name VARCHAR(50) NOT NULL UNIQUE,
description TEXT
);
-- @table: sent_emails
-- @description: Tabla de correos enviados por el sistema
CREATE TABLE IF NOT EXISTS sent_emails (
email_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
template_id INT NOT NULL,
status_id INT NOT NULL,
subject VARCHAR(150) NOT NULL,
message TEXT NOT NULL,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- @table: healthy_recipes
-- @description: Tabla de recetas saludables
CREATE TABLE IF NOT EXISTS healthy_recipes (
recipe_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
recipe_name VARCHAR(100) NOT NULL,
description TEXT,
ingredients TEXT,
instructions TEXT,
calories DECIMAL(6, 2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- @table: workout_routines
-- @description: Tabla de rutinas de gimnasio o calistenia
CREATE TABLE IF NOT EXISTS workout_routines (
routine_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
routine_name VARCHAR(100) NOT NULL,
description TEXT,
routine_type VARCHAR(50) NOT NULL,
exercises TEXT,
duration INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- @table: exercises
-- @description: Tabla de ejercicios para rutinas de gimnasio o calistenia
CREATE TABLE IF NOT EXISTS exercises (
exercise_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type VARCHAR(50) NOT NULL,
muscle VARCHAR(50) NOT NULL,
equipment VARCHAR(50),
difficulty VARCHAR(20) NOT NULL,
instructions TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- @table: important_events
-- @description: Tabla de eventos importantes
CREATE TABLE IF NOT EXISTS important_events (
event_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
event_name VARCHAR(100) NOT NULL,
event_date DATE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- @insert: task_status
-- @description: Inserta los estados iniciales de las tareas
INSERT INTO task_status (status_name, description) VALUES
('Pendiente', 'Tarea pendiente de realizar'),
('En Proceso', 'Tarea en ejecución'),
('Completada', 'Tarea completada'),
('Cancelada', 'Tarea cancelada');
-- @insert: notification_types
-- @description: Inserta los tipos de notificación iniciales
INSERT INTO notification_types (type_name, description) VALUES
('Alerta', 'Notificación de alerta de alta prioridad'),
('Recordatorio', 'Notificación para recordar tareas o eventos'),
('Información', 'Notificación de información general');
-- @insert: settings
-- @description: Inserta configuraciones iniciales del sistema
INSERT INTO settings (setting_key, setting_value, description) VALUES
('theme', 'dark', 'Tema predeterminado de la aplicación'),
('language', 'es', 'Idioma predeterminado de la aplicación'),
('timezone', 'UTC-6', 'Zona horaria predeterminada de la aplicación');
-- @insert: frequencies
-- @description: Inserta las frecuencias de ingresos y gastos
INSERT INTO frequencies (frequency_name, description) VALUES
('Diario', 'Frecuencia diaria'),
('Semanal', 'Frecuencia semanal'),
('Mensual', 'Frecuencia mensual'),
('Anual', 'Frecuencia anual');

0
storage/.gitkeep Normal file
View File

8
xdebug.ini Normal file
View File

@ -0,0 +1,8 @@
; zend_extension=xdebug.so
[xdebug]
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.idekey=VSCODE