Compare commits

..

6 Commits

Author SHA1 Message Date
9850f1a85e Finalización del módulo Luz Cámara: Corrección de errores JS, exportación profesional a PDF y reportes de deudores 2026-02-14 16:07:25 -06:00
5f90790c7a Optimización de rendimiento: página de pagos
- Agregados índices en BD (payments, houses) para mejorar queries
- Consolidada carga de pagos: 12 queries → 1 query
- Implementado caché de monthly_bills en vista (eliminadas ~2,400 queries)
- Nuevo método Payment::updateBatch() para guardado masivo con transacciones
- Reducción total: ~2,437 queries → 13 queries (99.5% mejora)
- Tiempo de carga: 3-5s → <0.5s
- Tiempo de guardado: 8-12s → 1-2s
2026-02-14 14:17:31 -06:00
23b527d3f5 feat: Botón Guardar Todo en conceptos y mejoras Docker
Cambios realizados:
- concept_view.php: Agregado botón 'Guardar Todo' arriba y abajo de la tabla, eliminado botón individual por fila
- dashboard.php: Agregado endpoint save_all_concept_payments para guardar múltiples pagos
- docker-entrypoint.sh: Corregidos permisos de volúmenes para ZimaOS/CasaOS (cambia dueño a www-data)
- docker/Dockerfile: Corregida ruta del entrypoint
- build-and-push.sh: Script interactivo para crear imagen Docker con opción de caché/sin caché

Los cambios permiten guardar todos los pagos de conceptos de una sola vez y mejoran la compatibilidad con despliegues en ZimaOS.
2026-02-13 23:09:45 -06:00
8f2f04951f fix: Corregir cálculo de excedente para casas con consumo_only
- Agregar método getExpectedAmountWithDiscount() que retorna el monto sin descuento de 00
- El excedente ahora se calcula contra el monto original configurado, no contra el monto con descuento
- Casas que pagan exactamente el monto por casa aparecen al corriente (/bin/bash.00)
- Casas que pagan más del monto por casa muestran excedente
2026-01-16 17:18:18 -06:00
c82cf3de89 feat: Agregar información de servidor y puerto de base de datos en menú
Se muestra la IP y puerto del servidor de base de datos arriba del nombre
de la base de datos en el menú desplegable del usuario para administradores.
2026-01-10 15:13:25 -06:00
6f4bf30e72 cleanup: Remover logs de debug restantes en filtros de Deudores de Conceptos
- Eliminar console.log de debug en JavaScript
- Limpiar código de producción
- Archivo debug_concept_filters.log ya eliminado
2026-01-05 16:32:28 -06:00
43 changed files with 5299 additions and 1202 deletions

50
.dockerignore Executable file
View File

@@ -0,0 +1,50 @@
# Archivos y directorios a excluir de Docker
# .dockerignore (no necesita estar en la imagen)
.dockerignore
# Git
.git
.gitignore
# Entorno
.env
.env.*
.env.local
# Documentación
README.md
docs/
*.md
# Docker (toda la carpeta de configuración Docker)
docker/
# OS
.DS_Store
Thumbs.db
# Logs y archivos temporales
*.log
*.tmp
*.swp
*~
# Uploads (se monta como volumen)
uploads/*
!uploads/.gitkeep
# Base de datos
*.sql
*.sqlite
database/migrations/
# IDE
.idea/
.vscode/
*.sublime-*
# Cache
.cache/
tmp/
vendor/

6
.env
View File

@@ -1,17 +1,17 @@
# Entorno de aplicación
APP_ENV=local
SITE_URL=http://10.10.4.3:82
SITE_URL=http://ibiza-test.casa
# Base de datos local/desarrollo
LOCAL_DB_HOST=10.10.4.17
LOCAL_DB_PORT=3390
LOCAL_DB_PORT=3391
LOCAL_DB_USER=nickpons666
LOCAL_DB_PASS=MiPo6425@@
LOCAL_DB_NAME=ibiza_db2
# Base de datos de producción
SERVER_DB_HOST=10.10.4.17
SERVER_DB_PORT=3390
SERVER_DB_PORT=3391
SERVER_DB_USER=nickpons666
SERVER_DB_PASS=MiPo6425@@
SERVER_DB_NAME=ibiza_db

208
build-and-push.sh Executable file
View File

@@ -0,0 +1,208 @@
#!/bin/bash
# Script interactivo para crear imagen Docker y subirla al registry
# Uso: ./build-and-push.sh
set -e
# Colores para output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuración por defecto
REGISTRY_URL="10.10.4.3:5000"
IMAGE_NAME="condominio_ibiza"
DOCKERFILE_PATH="docker/Dockerfile"
PROJECT_DIR="/var/www/html/ibiza"
# Función para imprimir mensajes
print_info() {
echo -e "${BLUE} $1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# Verificar que estamos en el directorio correcto
if [ ! -f "$DOCKERFILE_PATH" ]; then
print_error "No se encontró el Dockerfile en $DOCKERFILE_PATH"
print_info "Asegúrate de ejecutar este script desde el directorio raíz del proyecto"
exit 1
fi
# Mostrar banner
echo ""
echo "╔════════════════════════════════════════════════════════╗"
echo "║ 🐳 BUILD & PUSH - Condominio Ibiza Docker ║"
echo "╚════════════════════════════════════════════════════════╝"
echo ""
# Paso 1: Preguntar versión/tag
print_info "Paso 1: Versión de la imagen"
echo ""
read -p "Ingresa la versión/tag (ej: v2.1, latest, o deja en blanco para 'latest'): " VERSION
if [ -z "$VERSION" ]; then
VERSION="latest"
fi
print_info "Versión seleccionada: $VERSION"
echo ""
# Paso 2: Confirmar registry
print_info "Paso 2: Configuración del Registry"
echo ""
read -p "Registry URL [$REGISTRY_URL]: " INPUT_REGISTRY
REGISTRY_URL=${INPUT_REGISTRY:-$REGISTRY_URL}
print_info "Registry: $REGISTRY_URL"
echo ""
# Paso 3: Seleccionar modo de build
print_info "Paso 3: Modo de construcción"
echo ""
echo "Selecciona el modo de build:"
echo " 1) Con caché (más rápido, reutiliza capas anteriores)"
echo " 2) Sin caché (--no-cache, fuerza reconstrucción completa)"
echo ""
read -p "Opción [1]: " BUILD_OPTION
USE_CACHE="s"
if [ "$BUILD_OPTION" == "2" ]; then
USE_CACHE="n"
print_info "Modo seleccionado: Sin caché (build completo)"
else
print_info "Modo seleccionado: Con caché (build rápido)"
fi
echo ""
# Mostrar resumen antes de comenzar
echo ""
echo "╔════════════════════════════════════════════════════════╗"
echo "║ 📋 RESUMEN DE OPERACIÓN ║"
echo "╠════════════════════════════════════════════════════════╣"
echo "║ Imagen: $IMAGE_NAME:$VERSION"
echo "║ Registry: $REGISTRY_URL"
echo "║ Dockerfile: $DOCKERFILE_PATH"
if [ "$USE_CACHE" == "n" ]; then
echo "║ Modo: Sin caché (build completo)"
else
echo "║ Modo: Con caché (build rápido)"
fi
echo "╚════════════════════════════════════════════════════════╝"
echo ""
read -p "¿Deseas continuar? (s/n): " CONFIRM
if [[ $CONFIRM != "s" && $CONFIRM != "S" ]]; then
print_warning "Operación cancelada por el usuario"
exit 0
fi
echo ""
print_info "🚀 Iniciando proceso..."
echo ""
# Paso 4: Build de la imagen
print_info "Paso 4: Construyendo imagen Docker..."
if [ "$USE_CACHE" == "n" ]; then
echo " Comando: docker build --no-cache -t $IMAGE_NAME:$VERSION -f $DOCKERFILE_PATH ."
echo ""
if docker build --no-cache -t "$IMAGE_NAME:$VERSION" -f "$DOCKERFILE_PATH" .; then
print_success "Imagen construida exitosamente (sin caché): $IMAGE_NAME:$VERSION"
else
print_error "Error al construir la imagen"
exit 1
fi
else
echo " Comando: docker build -t $IMAGE_NAME:$VERSION -f $DOCKERFILE_PATH ."
echo ""
if docker build -t "$IMAGE_NAME:$VERSION" -f "$DOCKERFILE_PATH" .; then
print_success "Imagen construida exitosamente: $IMAGE_NAME:$VERSION"
else
print_error "Error al construir la imagen"
exit 1
fi
fi
echo ""
# Paso 5: Taggear para el registry
print_info "Paso 5: Taggeando imagen para registry..."
echo " Comando: docker tag $IMAGE_NAME:$VERSION $REGISTRY_URL/$IMAGE_NAME:$VERSION"
echo ""
if docker tag "$IMAGE_NAME:$VERSION" "$REGISTRY_URL/$IMAGE_NAME:$VERSION"; then
print_success "Imagen taggeada: $REGISTRY_URL/$IMAGE_NAME:$VERSION"
else
print_error "Error al taggear la imagen"
exit 1
fi
echo ""
# Paso 6: Subir al registry
read -p "¿Deseas subir la imagen al registry ahora? (s/n): " PUSH_CONFIRM
if [[ $PUSH_CONFIRM == "s" || $PUSH_CONFIRM == "S" ]]; then
print_info "Paso 6: Subiendo imagen al registry..."
echo " Comando: docker push $REGISTRY_URL/$IMAGE_NAME:$VERSION"
echo ""
if docker push "$REGISTRY_URL/$IMAGE_NAME:$VERSION"; then
print_success "Imagen subida exitosamente al registry"
else
print_error "Error al subir la imagen al registry"
print_warning "Verifica que el registry esté accesible en $REGISTRY_URL"
print_info "Puedes intentar subir manualmente con: docker push $REGISTRY_URL/$IMAGE_NAME:$VERSION"
exit 1
fi
else
print_warning "Imagen NO subida al registry"
print_info "Puedes subirla manualmente después con:"
echo " docker push $REGISTRY_URL/$IMAGE_NAME:$VERSION"
fi
echo ""
# Paso 7: Limpiar imágenes locales (opcional)
read -p "¿Deseas eliminar las imágenes locales para liberar espacio? (s/n): " CLEANUP_CONFIRM
if [[ $CLEANUP_CONFIRM == "s" || $CLEANUP_CONFIRM == "S" ]]; then
print_info "Limpiando imágenes locales..."
docker rmi "$IMAGE_NAME:$VERSION" "$REGISTRY_URL/$IMAGE_NAME:$VERSION" 2>/dev/null || true
print_success "Imágenes locales eliminadas"
fi
echo ""
echo "╔════════════════════════════════════════════════════════╗"
echo "║ ✅ PROCESO COMPLETADO ║"
echo "╠════════════════════════════════════════════════════════╣"
echo "║ Imagen: $REGISTRY_URL/$IMAGE_NAME:$VERSION"
echo "╚════════════════════════════════════════════════════════╝"
echo ""
print_info "Para usar esta imagen en ZimaOS/CasaOS:"
echo ""
echo "1. Actualiza tu archivo ibiza.yaml:"
echo " image: $REGISTRY_URL/$IMAGE_NAME:$VERSION"
echo ""
echo "2. Reinicia el contenedor en ZimaOS/CasaOS"
echo ""
echo "3. Verifica los logs:"
echo " docker logs condominio_ibiza"
echo ""
print_success "¡Listo!"
echo ""

View File

@@ -1,96 +1,121 @@
<?php
class Auth {
public static function check() {
class Auth
{
public static function check()
{
if (!isset($_SESSION['user_id'])) {
return false;
}
$timeout = defined('SESSION_TIMEOUT') ? SESSION_TIMEOUT : 28800;
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > $timeout)) {
session_destroy();
return false;
}
$_SESSION['last_activity'] = time();
return true;
}
public static function user() {
public static function user()
{
if (!self::check()) {
return null;
}
return $_SESSION;
}
public static function id() {
public static function id()
{
return $_SESSION['user_id'] ?? null;
}
public static function role() {
public static function role()
{
return $_SESSION['role'] ?? null;
}
public static function isAdmin() {
public static function isAdmin()
{
return self::role() === 'ADMIN';
}
public static function isCapturist() {
public static function isCapturist()
{
return self::role() === 'CAPTURIST' || self::isAdmin();
}
public static function isViewer() {
public static function isViewer()
{
return self::role() === 'VIEWER';
}
public static function isLector() {
public static function isLector()
{
return self::role() === 'LECTOR';
}
public static function getAccessibleHouseIds() {
public static function getAccessibleHouseIds()
{
$db = Database::getInstance();
if (self::isAdmin()) {
$result = $db->fetchAll("SELECT id FROM houses");
return array_column($result, 'id');
} elseif (self::isLector()) {
}
elseif (self::isLector()) {
$userId = self::id();
$result = $db->fetchAll(
"SELECT house_id FROM user_house_permissions WHERE user_id = ?",
[$userId]
[$userId]
);
return array_column($result, 'house_id');
} else {
}
else {
$result = $db->fetchAll("SELECT id FROM houses");
return array_column($result, 'id');
}
}
public static function requireAuth() {
public static function canViewHouse($houseId)
{
if (self::isAdmin()) {
return true;
}
$accessibleIds = self::getAccessibleHouseIds();
return in_array($houseId, $accessibleIds);
}
public static function requireAuth()
{
if (!self::check()) {
header('Location: /login.php');
exit;
}
}
public static function requireAdmin() {
public static function requireAdmin()
{
self::requireAuth();
if (!self::isAdmin()) {
header('Location: /dashboard.php');
exit;
}
}
public static function requireCapturist() {
public static function requireCapturist()
{
self::requireAuth();
if (!self::isCapturist()) {
header('Location: /dashboard.php');
exit;
}
}
public static function login($user) {
public static function login($user)
{
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
@@ -98,37 +123,39 @@ class Auth {
$_SESSION['first_name'] = $user['first_name'];
$_SESSION['last_name'] = $user['last_name'];
$_SESSION['last_activity'] = time();
$db = Database::getInstance();
$db->execute(
"UPDATE users SET last_login = NOW() WHERE id = ?",
[$user['id']]
[$user['id']]
);
self::logActivity('login', "Usuario {$user['username']} inició sesión");
}
public static function logout() {
public static function logout()
{
self::logActivity('logout', "Usuario {$_SESSION['username']} cerró sesión");
session_destroy();
header('Location: /login.php');
exit;
}
public static function logActivity($action, $details = '') {
public static function logActivity($action, $details = '')
{
if (!self::check()) {
return;
}
$db = Database::getInstance();
$db->execute(
"INSERT INTO activity_logs (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)",
[
self::id(),
$action,
$details,
$_SERVER['REMOTE_ADDR'] ?? null
]
[
self::id(),
$action,
$details,
$_SERVER['REMOTE_ADDR'] ?? null
]
);
}
}
}

View File

@@ -1,10 +1,12 @@
<?php
class Database {
class Database
{
private static $instance = null;
private $pdo;
private function __construct() {
private function __construct()
{
try {
$dsn = "mysql:host=" . DB_HOST . ";port=" . DB_PORT . ";dbname=" . DB_NAME . ";charset=utf8mb4";
$options = [
@@ -12,58 +14,74 @@ class Database {
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
];
$this->pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
} catch (PDOException $e) {
}
catch (PDOException $e) {
die("Error de conexión a base de datos: " . $e->getMessage());
}
}
public static function getInstance() {
public static function getInstance()
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function getConnection() {
public function getConnection()
{
return $this->pdo;
}
public function query($sql, $params = []) {
public function getPDO()
{
return $this->pdo;
}
public function query($sql, $params = [])
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
public function fetchAll($sql, $params = []) {
public function fetchAll($sql, $params = [])
{
$stmt = $this->query($sql, $params);
return $stmt->fetchAll();
}
public function fetchOne($sql, $params = []) {
public function fetchOne($sql, $params = [])
{
$stmt = $this->query($sql, $params);
return $stmt->fetch();
}
public function execute($sql, $params = []) {
public function execute($sql, $params = [])
{
$stmt = $this->query($sql, $params);
return $stmt->rowCount();
}
public function lastInsertId() {
public function lastInsertId()
{
return $this->pdo->lastInsertId();
}
public function beginTransaction() {
public function beginTransaction()
{
return $this->pdo->beginTransaction();
}
public function commit() {
public function commit()
{
return $this->pdo->commit();
}
public function rollback() {
public function rollback()
{
return $this->pdo->rollBack();
}
}
}

3
daemon.json Executable file
View File

@@ -0,0 +1,3 @@
{
"insecure-registries": ["10.10.4.17:5000"]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

52
docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Script de entrada para Docker
set -e
# Función para crear archivo .env desde variables de entorno
create_env_file() {
if [ ! -f /var/www/html/ibiza/.env ] || [ ! -s /var/www/html/ibiza/.env ]; then
echo "Creando archivo .env desde variables de entorno..."
cat > /var/www/html/ibiza/.env << EOF
# Entorno de aplicación
APP_ENV=${APP_ENV:-local}
SITE_URL=${SITE_URL:-http://localhost}
# Base de datos
DB_HOST=${DB_HOST:-localhost}
DB_PORT=${DB_PORT:-3306}
DB_USER=${DB_USER:-root}
DB_PASS=${DB_PASS:-}
DB_NAME=${DB_NAME:-ibiza_db}
# Base de datos local/desarrollo
LOCAL_DB_HOST=${DB_HOST:-localhost}
LOCAL_DB_PORT=${DB_PORT:-3306}
LOCAL_DB_USER=${DB_USER:-root}
LOCAL_DB_PASS=${DB_PASS:-}
LOCAL_DB_NAME=${DB_NAME:-ibiza_db}
# Base de datos de producción
SERVER_DB_HOST=${DB_HOST:-localhost}
SERVER_DB_PORT=${DB_PORT:-3306}
SERVER_DB_USER=${DB_USER:-root}
SERVER_DB_PASS=${DB_PASS:-}
SERVER_DB_NAME=${DB_NAME:-ibiza_db}
# Configuración de sesión
SESSION_TIMEOUT=${SESSION_TIMEOUT:-28800}
JWT_SECRET=${JWT_SECRET:-ibiza_jwt_secret_key_CHANGE_IN_PRODUCTION_2025!@#}
JWT_EXPIRATION=${JWT_EXPIRATION:-86400}
EOF
echo "Archivo .env creado exitosamente"
chown www-data:www-data /var/www/html/ibiza/.env
fi
}
# Crear archivo .env si no existe
create_env_file
# Iniciar Apache en primer plano
exec /usr/sbin/apache2ctl -D FOREGROUND

13
docker/.dockerignore Executable file
View File

@@ -0,0 +1,13 @@
# Archivos a excluir de Docker
.git
.gitignore
.env
.env.example
README.md
docs/
.DS_Store
Thumbs.db
*.log
*.tmp
uploads/*
!uploads/.gitkeep

85
docker/Dockerfile Executable file
View File

@@ -0,0 +1,85 @@
# Usar Ubuntu como base
FROM ubuntu:22.04
# Evitar interactividad durante instalación
ENV DEBIAN_FRONTEND=noninteractive
# Actualizar sistema e instalar dependencias
RUN apt-get update && apt-get install -y \
apache2 \
php8.1 \
php8.1-mysql \
php8.1-pdo \
php8.1-mbstring \
php8.1-xml \
php8.1-curl \
php8.1-gd \
php8.1-zip \
php8.1-tokenizer \
php8.1-bcmath \
php8.1-intl \
php8.1-ldap \
php8.1-soap \
php8.1-xsl \
php8.1-imagick \
libapache2-mod-php8.1 \
curl \
wget \
unzip \
git \
composer \
bash \
bash-completion \
readline-common \
nano \
less \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Habilitar módulos de Apache
RUN a2enmod rewrite \
&& a2enmod headers \
&& a2enmod expires
# Configurar Apache
RUN sed -i 's/AllowOverride None/AllowOverride All/g' /etc/apache2/apache2.conf \
&& sed -i 's/DocumentRoot \/var\/www\/html/DocumentRoot \/var\/www\/html\/ibiza/g' /etc/apache2/sites-available/000-default.conf
# Configurar PHP
RUN sed -i 's/upload_max_filesize = 2M/upload_max_filesize = 64M/g' /etc/php/8.1/apache2/php.ini \
&& sed -i 's/post_max_size = 8M/post_max_size = 64M/g' /etc/php/8.1/apache2/php.ini \
&& sed -i 's/max_execution_time = 30/max_execution_time = 300/g' /etc/php/8.1/apache2/php.ini \
&& sed -i 's/memory_limit = 128M/memory_limit = 512M/g' /etc/php/8.1/apache2/php.ini
# Crear directorio de la aplicación
WORKDIR /var/www/html/ibiza
# Copiar archivos de la aplicación con el owner correcto
COPY --chown=www-data:www-data . .
# Instalar dependencias de Composer
RUN composer install --no-dev --optimize-autoloader --no-interaction || true
# Crear directorios necesarios
RUN mkdir -p /var/www/html/ibiza/uploads
# Configurar permisos
RUN chown -R www-data:www-data /var/www/html/ibiza \
&& chmod -R 755 /var/www/html/ibiza \
&& chmod -R 777 /var/www/html/ibiza/uploads \
&& touch /var/www/html/ibiza/.env \
&& chown www-data:www-data /var/www/html/ibiza/.env
# Exponer puertos
EXPOSE 80 443
# Script de inicio
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Asegurar que Apache pueda correr como www-data
RUN sed -i 's/export APACHE_RUN_USER=www-data/export APACHE_RUN_USER=www-data/g' /etc/apache2/envvars \
&& sed -i 's/export APACHE_RUN_GROUP=www-data/export APACHE_RUN_GROUP=www-data/g' /etc/apache2/envvars
# Iniciar Apache
CMD ["docker-entrypoint.sh"]

102
docker/README.md Executable file
View File

@@ -0,0 +1,102 @@
# Docker - Archivos para crear imagen y registry
Este directorio contiene TODOS los archivos Docker organizados y centralizados.
## 🐳 Crear Imagen Docker
- `Dockerfile` - Configuración del contenedor Ubuntu + Apache + PHP
- `docker-entrypoint.sh` - Script de inicio para el contenedor
- `docker-compose.yml` - Para pruebas locales
- `.dockerignore` - Excluir archivos innecesarios
## 📦 Registry Docker Privado
- `daemon.json` - Configuración Docker para registry local
- Scripts para configuración en servidor remoto
## 🏠 CasaOS Installation
- `casaos-sin-env-completo.yml` - ✅ **RECOMENDADO** (sin errores)
- `casaos-sin-env.yml` - Versión simple sin .env
- `casaos-simple-final.yml` - Versión corregida
- `casaos-registry-corregido.yml` - Con metadatos completos
- Y otros archivos YAML para diferentes necesidades
## 🔧 Scripts y Configuración
- `corregir-forzado-registry.sh` - ✅ **DEFINITIVO** para error HTTP/HTTPS
- `diagnostico-registry.sh` - Script de diagnóstico completo
- `corregir-insecure-registry.sh` - Corrección rápida
- `configurar-servidor-remoto.sh` - Configuración básica
## 📖 Instrucciones
- `INSTRUCCIONES_SERVIDOR_REMOTO.txt` - Guía completa y actualizada
- `instrucciones_cortas.txt` - Versión rápida con comandos
## 📁 Estructura Completa
```
docker/
├── 🐳 Imagen Docker (4 archivos)
├── 🏠 CasaOS YAML (7 archivos)
├── 🔧 Scripts (4 archivos)
├── ⚙️ Configuración (1 archivo)
├── 📖 Documentación (2 archivos)
└── 📂 yamls/ (directorio extra)
```
**Total: 18 archivos organizados**
## 📁 Estructura
```
docker/
├── 🐳 Imagen Docker
│ ├── Dockerfile
│ ├── docker-entrypoint.sh
│ ├── docker-compose.yml
│ └── .dockerignore
├── 📦 Registry Privado
│ └── daemon.json
├── 🏠 CasaOS Installation
│ ├── casaos-simple-final.yml
│ ├── casaos-sin-env.yml
│ ├── casaos-sin-env-completo.yml
│ └── casaos-registry-corregido.yml
├── 🔧 Scripts y Configuración
│ ├── configurar-servidor-remoto.sh
│ ├── diagnostico-registry.sh
│ ├── corregir-insecure-registry.sh
│ └── corregir-forzado-registry.sh
└── 📖 Documentación
├── INSTRUCCIONES_SERVIDOR_REMOTO.txt
└── instrucciones_cortas.txt
```
## 🚀 Uso Rápido
### Crear imagen Docker localmente:
```bash
cd /var/www/html/ibiza/docker/
docker compose build
docker compose up -d
```
### Configurar registry en servidor remoto (10.10.4.17):
```bash
curl -s http://10.10.4.3:82/docker/corregir-forzado-registry.sh | bash
```
### Instalar en CasaOS (versión recomendada):
```bash
wget http://10.10.4.3:82/docker/casaos-sin-env-completo.yml
# Importar en CasaOS → Apps → Install App
```
### Acceder al sistema:
- Local: http://10.10.4.3:8080
- Remoto: http://10.10.4.17:8080
- Usuario: admin / Contraseña: admin123
---
*Todos los archivos centralizados y organizados en este directorio Docker.*

View File

@@ -0,0 +1,57 @@
# Comandos para crear y subir imagen a registry
# IMPORTANTE: Ejecutar desde /var/www/html/ibiza (directorio padre de docker/)
# ============================================================
# OPCIÓN 1: Usar tag :latest
# ============================================================
# 1. Hacer build de la imagen
docker build -t condominio_ibiza:latest -f docker/Dockerfile .
# 2. Taggear la imagen para el registry remoto
docker tag condominio_ibiza:latest 10.10.4.3:5000/condominio_ibiza:latest
# 3. Subir la imagen al registry
docker push 10.10.4.3:5000/condominio_ibiza:latest
# ============================================================
# OPCIÓN 2: Usar tag de versión (recomendado)
# ============================================================
# 1. Hacer build de la imagen
docker build -t condominio_ibiza:v2 -f docker/Dockerfile .
# 2. Taggear la imagen para el registry remoto
docker tag condominio_ibiza:v2 10.10.4.3:5000/condominio_ibiza:v2
# 3. Subir la imagen al registry
docker push 10.10.4.3:5000/condominio_ibiza:v2
# 4. (Opcional) Verificar que se subio correctamente
docker pull 10.10.4.3:5000/condominio_ibiza:v2
# ============================================================
# VARIABLES DE ENTORNO (configuradas en ibiza.yaml para CasaOS)
# ============================================================
# Las variables sensibles NO se incluyen en el .env copiado.
# Se pasan como environment variables en el contenedor:
#
# APP_ENV=production
# DB_HOST=10.10.4.17
# DB_NAME=ibiza_db
# DB_PASS=MiPo6425@@
# DB_PORT=3390
# DB_USER=nickpons666
# JWT_EXPIRATION=86400
# JWT_SECRET=ibiza_jwt_secret_key_CHANGE_IN_PRODUCTION_2025!@#
# SESSION_TIMEOUT=28800
# SITE_URL=https://condominioibiza.ddns.net
#
# El archivo .env se crea automáticamente en docker-entrypoint.sh
# usando estas variables de entorno.
# ============================================================
# Nota: Asegurese de que el daemon.json tenga configurado el registry como insecure
# Si no lo tiene, agregar "10.10.4.3:5000" a "insecure-registries" y reiniciar docker:
# sudo systemctl restart docker

View File

@@ -0,0 +1,16 @@
# Comandos para configurar Docker en el servidor remoto
# 1. Crear archivo de configuración
sudo mkdir -p /etc/docker
cat << 'EOF' | sudo tee /etc/docker/daemon.json
{
"insecure-registries": ["10.10.4.17:5000"]
}
EOF
# 2. Reiniciar Docker
sudo systemctl restart docker
# 3. Verificar configuración
sudo docker info | grep -A 10 "Insecure Registries"
EOF

View File

@@ -0,0 +1,24 @@
# Comandos para configurar Docker en el servidor remoto (10.10.4.17)
# 1. Crear archivo de configuración con IP correcta
sudo mkdir -p /etc/docker
cat << 'EOF' | sudo tee /etc/docker/daemon.json
{
"insecure-registries": ["10.10.4.3:5000"]
}
EOF
# 2. Reiniciar Docker
sudo systemctl restart docker
# 3. Verificar configuración
sudo docker info | grep -A 10 "Insecure Registries"
# 4. Probar conexión al registry
curl http://10.10.4.3:5000/v2/_catalog
# 5. Probar bajar la imagen
docker pull 10.10.4.3:5000/condominio_ibiza:latest
# 6. Listar imágenes para verificar
docker images | grep condominio_ibiza

View File

@@ -0,0 +1,104 @@
#!/bin/bash
# Script de corrección forzada para el problema de Docker registry
echo "=== CORRECCIÓN FORZADA DE DOCKER REGISTRY ==="
echo ""
echo "1. Eliminando configuración anterior..."
sudo rm -f /etc/docker/daemon.json
sudo rm -f /etc/docker/daemon.json.bak
echo ""
echo "2. Creando nueva configuración..."
sudo mkdir -p /etc/docker
# Método 1: Usando echo
echo '{"insecure-registries": ["10.10.4.3:5000"]}' | sudo tee /etc/docker/daemon.json
echo ""
echo "3. Verificando archivo creado..."
echo "Contenido de /etc/docker/daemon.json:"
sudo cat /etc/docker/daemon.json
echo ""
echo "4. Verificando sintaxis JSON..."
if python3 -c "import json; json.load(open('/etc/docker/daemon.json'))" 2>/dev/null; then
echo "✅ JSON válido"
else
echo "❌ JSON inválido"
fi
echo ""
echo "5. Reiniciando Docker..."
sudo systemctl restart docker
sudo systemctl status docker --no-pager -l
echo ""
echo "6. Esperando 10 segundos..."
sleep 10
echo ""
echo "7. Verificando configuración de Docker..."
sudo docker info | grep -A 15 "Insecure Registries"
echo ""
echo "8. Probando descarga de imagen..."
if sudo docker pull 10.10.4.3:5000/condominio_ibiza:latest; then
echo "✅ IMAGEN DESCARGADA EXITOSAMENTE"
echo ""
echo "Imágenes disponibles:"
sudo docker images | grep condominio
else
echo "❌ FALLÓ LA DESCARGA"
echo ""
echo "Intentando método alternativo..."
echo ""
# Método alternativo: Configuración directa
echo "9. Intentando método alternativo..."
sudo mkdir -p /etc/docker/systemd
sudo bash -c 'cat > /etc/docker/systemd/docker.service << "EOF"
[Unit]
Description=Docker Application Container Engine
Documentation=https://docs.docker.com
After=network.target docker.socket
Requires=docker.socket
[Service]
Type=notify
ExecStart=/usr/bin/dockerd --insecure-registry=10.10.4.3:5000
ExecReload=/bin/kill -s HUP $MAINPID
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
TimeoutStartSec=0
Delegate=yes
KillMode=process
Restart=on-failure
StartLimitBurst=3
StartLimitInterval=60s
[Install]
WantedBy=multi-user.target
EOF'
echo ""
echo "10. Recargando systemd y reiniciando Docker..."
sudo systemctl daemon-reload
sudo systemctl restart docker
sleep 5
echo ""
echo "11. Intentando descarga final..."
if sudo docker pull 10.10.4.3:5000/condominio_ibiza:latest; then
echo "✅ MÉTODO ALTERNATIVO FUNCIONÓ"
sudo docker images | grep condominio
else
echo "❌ TODOS LOS MÉTODOS FALLARON"
echo "Requiere configuración manual"
fi
fi
echo ""
echo "=== FIN DE CORRECCIÓN FORZADA ==="

View File

@@ -0,0 +1,21 @@
# En el servidor remoto (10.10.4.17)
# 1. Verificar configuración actual
cat /etc/docker/daemon.json
# 2. Si está incorrecto, corregirlo
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json << 'EOF'
{
"insecure-registries": ["10.10.4.3:5000"]
}
EOF
# 3. Reiniciar Docker (¡importante!)
sudo systemctl restart docker
# 4. Verificar que se aplicó
sudo docker info | grep -A 5 "Insecure Registries"
# 5. Probar de nuevo
docker pull 10.10.4.3:5000/condominio_ibiza:latest

3
docker/daemon.json Executable file
View File

@@ -0,0 +1,3 @@
{
"insecure-registries": ["10.10.4.17:5000"]
}

89
docker/diagnostico-registry.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
# Script completo para diagnosticar y corregir el problema del registry
echo "=== DIAGNÓSTICO DE REGISTRY DOCKER ==="
echo "Servidor actual: $(hostname)"
echo "IP del registry: 10.10.4.3:5000"
echo ""
echo "1. Verificando archivo daemon.json..."
if [ -f /etc/docker/daemon.json ]; then
echo "Contenido actual de /etc/docker/daemon.json:"
cat /etc/docker/daemon.json
echo ""
else
echo "❌ No existe /etc/docker/daemon.json"
fi
echo ""
echo "2. Verificando configuración de Docker..."
if sudo docker info 2>/dev/null | grep -q "Insecure Registries"; then
echo "✅ Insecure Registries configurados:"
sudo docker info | grep -A 5 "Insecure Registries"
else
echo "❌ No hay Insecure Registries configurados"
fi
echo ""
echo "3. Probando conexión al registry..."
if curl -s http://10.10.4.3:5000/v2/_catalog >/dev/null 2>&1; then
echo "✅ Registry accesible: $(curl -s http://10.10.4.3:5000/v2/_catalog)"
else
echo "❌ No se puede acceder al registry"
fi
echo ""
echo "4. Corrigiendo configuración..."
sudo mkdir -p /etc/docker
# Eliminar archivo existente para evitar conflictos
sudo rm -f /etc/docker/daemon.json
# Crear nuevo archivo con formato exacto
sudo bash -c 'cat > /etc/docker/daemon.json << "EOF"
{
"insecure-registries": ["10.10.4.3:5000"]
}
EOF'
echo "✅ Archivo daemon.json actualizado"
echo ""
echo "5. Reiniciando Docker..."
sudo systemctl restart docker
echo "⏳ Esperando 5 segundos..."
sleep 5
echo ""
echo "6. Verificando que el archivo fue creado correctamente..."
if [ -f /etc/docker/daemon.json ]; then
echo "✅ Archivo daemon.json creado:"
cat /etc/docker/daemon.json
echo ""
else
echo "❌ No se pudo crear /etc/docker/daemon.json"
fi
echo ""
echo "7. Verificando configuración de Docker..."
if sudo docker info 2>/dev/null | grep -q "10.10.4.3:5000"; then
echo "✅ Insecure Registry correctamente configurado:"
sudo docker info | grep -A 10 "Insecure Registries"
else
echo "❌ Falló la configuración del registry"
echo "Mostrando todos los Insecure Registries:"
sudo docker info | grep -A 10 "Insecure Registries"
fi
echo ""
echo "7. Probando descargar imagen..."
if sudo docker pull 10.10.4.3:5000/condominio_ibiza:latest; then
echo "✅ Imagen descargada exitosamente"
sudo docker images | grep condominio_ibiza
else
echo "❌ Falló la descarga de la imagen"
fi
echo ""
echo "=== FIN DEL DIAGNÓSTICO ==="

37
docker/docker-compose.yml Executable file
View File

@@ -0,0 +1,37 @@
version: '3.8'
services:
condominio_ibiza:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: condominio_ibiza
ports:
- "8080:80"
- "8443:443"
environment:
# Entorno de aplicación
- APP_ENV=local
- SITE_URL=http://localhost:8080
# Configuración de base de datos (modificar según tu configuración)
- DB_HOST=10.10.4.17
- DB_PORT=3391
- DB_USER=nickpons666
- DB_PASS=MiPo6425@@
- DB_NAME=ibiza_db2
# Configuración de sesión
- SESSION_TIMEOUT=28800
- JWT_SECRET=ibiza_jwt_secret_key_CHANGE_IN_PRODUCTION_2025!@#
- JWT_EXPIRATION=86400
volumes:
- ./uploads:/var/www/html/ibiza/uploads
- ./.env:/var/www/html/ibiza/.env
restart: unless-stopped
networks:
- ibiza_network
networks:
ibiza_network:
driver: bridge

65
docker/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
# Script de entrada para Docker
set -e
# Función para crear archivo .env desde variables de entorno
create_env_file() {
if [ ! -f /var/www/html/ibiza/.env ] || [ ! -s /var/www/html/ibiza/.env ]; then
echo "Creando archivo .env desde variables de entorno..."
cat > /var/www/html/ibiza/.env << EOF
# Entorno de aplicación
APP_ENV=${APP_ENV:-local}
SITE_URL=${SITE_URL:-http://localhost}
# Base de datos
DB_HOST=${DB_HOST:-localhost}
DB_PORT=${DB_PORT:-3306}
DB_USER=${DB_USER:-root}
DB_PASS=${DB_PASS:-}
DB_NAME=${DB_NAME:-ibiza_db}
# Base de datos local/desarrollo
LOCAL_DB_HOST=${DB_HOST:-localhost}
LOCAL_DB_PORT=${DB_PORT:-3306}
LOCAL_DB_USER=${DB_USER:-root}
LOCAL_DB_PASS=${DB_PASS:-}
LOCAL_DB_NAME=${DB_NAME:-ibiza_db}
# Base de datos de producción
SERVER_DB_HOST=${DB_HOST:-localhost}
SERVER_DB_PORT=${DB_PORT:-3306}
SERVER_DB_USER=${DB_USER:-root}
SERVER_DB_PASS=${DB_PASS:-}
SERVER_DB_NAME=${DB_NAME:-ibiza_db}
# Configuración de sesión
SESSION_TIMEOUT=${SESSION_TIMEOUT:-28800}
JWT_SECRET=${JWT_SECRET:-ibiza_jwt_secret_key_CHANGE_IN_PRODUCTION_2025!@#}
JWT_EXPIRATION=${JWT_EXPIRATION:-86400}
EOF
echo "Archivo .env creado exitosamente"
chown www-data:www-data /var/www/html/ibiza/.env
fi
}
# Crear archivo .env si no existe
create_env_file
# Corregir permisos de volúmenes montados (importante para ZimaOS/CasaOS)
echo "Corrigiendo permisos de volúmenes montados..."
chown -R www-data:www-data /var/www/html/ibiza || true
chmod -R 755 /var/www/html/ibiza || true
chmod -R 777 /var/www/html/ibiza/uploads 2>/dev/null || true
chmod 777 /var/www/html/ibiza/.env 2>/dev/null || true
# Configurar Apache para correr como www-data
export APACHE_RUN_USER=www-data
export APACHE_RUN_GROUP=www-data
echo "Permisos corregidos. Iniciando Apache..."
# Iniciar Apache en primer plano
exec /usr/sbin/apache2ctl -D FOREGROUND

59
docker/ibiza.yaml Executable file
View File

@@ -0,0 +1,59 @@
name: condominio-ibiza
services:
app:
cpu_shares: 90
command: []
container_name: condominio_ibiza
deploy:
resources:
limits:
memory: 16655581184
reservations:
devices: []
environment:
- APP_ENV=production
- DB_HOST=10.10.4.17
- DB_NAME=ibiza_db
- DB_PASS=MiPo6425@@
- DB_PORT=3390
- DB_USER=nickpons666
- JWT_EXPIRATION=86400
- JWT_SECRET=ibiza_jwt_secret_key_CHANGE_IN_PRODUCTION_2025!@#
- SESSION_TIMEOUT=28800
- SITE_URL=https://condominioibiza.ddns.net
hostname: condominio_ibiza
image: 10.10.4.3:5000/condominio_ibiza:latest
labels:
icon: https://www.punta-diamante.saredesarrollo.com.mx/storage/img/condominios/6/VBgcKjrcQCCQ7gIlGousPfH9EECJ4UluHwAThjJ2.png
ports:
- target: 80
published: "8085"
protocol: tcp
- target: 443
published: "8443"
protocol: tcp
restart: unless-stopped
volumes:
- type: bind
source: /media/sda/AppData/condominioibiza/uploads
target: /var/www/html/ibiza/uploads
- type: bind
source: /media/sda/AppData/condominioibiza/logs
target: /var/www/html/ibiza/logs
devices: []
cap_add: []
network_mode: bridge
privileged: false
x-casaos:
author: self
category: self
hostname: ""
icon: https://www.punta-diamante.saredesarrollo.com.mx/storage/img/condominios/6/VBgcKjrcQCCQ7gIlGousPfH9EECJ4UluHwAThjJ2.png
index: /
is_uncontrolled: false
port_map: "8085"
scheme: http
store_app_id: condominio-ibiza
title:
custom: ibiza
en_us: app

View File

@@ -0,0 +1,8 @@
# Probar conexión al registry desde servidor remoto
curl http://10.10.4.17:5000/v2/_catalog
# Probar bajar la imagen
docker pull 10.10.4.17:5000/condominio_ibiza:latest
# Listar imágenes para verificar
docker images | grep condominio_ibiza

View File

@@ -0,0 +1,121 @@
COMANDOS PARA ACTUALIZAR GRUB DESPUÉS DE ELIMINAR WINDOWS
========================================================
COMANDOS PARA EJECUTAR DESDE LIVE CD/UBUNTU LIVE
================================================
PASO 1: IDENTIFICAR PARTICIÓN DE UBUNTU
--------------------------------------
sudo fdisk -l
Busca tu partición Linux (probablemente sda5 con formato ext4)
PASO 2: MONTAR PARTICIÓN DE UBUNTU
----------------------------------
sudo mkdir /mnt/ubuntu
sudo mount /dev/sda5 /mnt/ubuntu
PASO 3: MONTAR PARTICIONES NECESARIAS PARA CHROOT
---------------------------------------------------
sudo mount /dev/sda1 /mnt/ubuntu/boot/efi # Partición EFI
sudo mount --bind /dev /mnt/ubuntu/dev
sudo mount --bind /proc /mnt/ubuntu/proc
sudo mount --bind /sys /mnt/ubuntu/sys
sudo mount --bind /run /mnt/ubuntu/run
PASO 4: ACTIVAR SWAP (si es necesario)
-------------------------------------
sudo swapon /dev/sda6
PASO 5: ENTRAR AL SISTEMA UBUNTU (CHROOT)
-----------------------------------------
sudo chroot /mnt/ubuntu
AHORA DENTRO DE TU SISTEMA UBUNTU (ya no estás en Live CD):
=========================================================
PASO 6: ACTUALIZAR CONFIGURACIÓN DE GRUB
---------------------------------------
update-grub
Este comando buscará sistemas operativos y eliminará las entradas de Windows.
PASO 7: REINSTALAR GRUB EN EL DISCO
------------------------------------
grub-install /dev/sda
Esto reinstala GRUB correctamente después de los cambios de partición.
PASO 8: VERIFICAR CONFIGURACIÓN
-------------------------------
grub-mkconfig -o /boot/grub/grub.cfg
PASO 9: SALIR DEL CHROOT
------------------------
exit
PASO 10: DESMONTAR TODO CORRECTAMENTE
-------------------------------------
sudo umount -R /mnt/ubuntu
sudo swapoff /dev/sda6
PASO 11: REINICIAR EL SISTEMA
-----------------------------
reboot
COMANDOS DE VERIFICACIÓN (OPCIONAL)
==================================
PARA VERIFICAR ANTES DE REINICIAR (desde Live CD):
-------------------------------------------------
1. Verificar particiones montadas:
mount | grep /mnt/ubuntu
2. Verificar que se montó todo correctamente:
ls -la /mnt/ubuntu/boot/efi
3. Verificar GRUB desde chroot:
(chroot) dpkg -l | grep grub
TROUBLESHOOTING DESDE LIVE CD
=============================
SI EL MONTAJE FALLA:
-------------------
- Intenta: sudo fsck -f /dev/sda5
- Verifica que /dev/sda5 sea la partición correcta con: sudo blkid
SI CHROOT NO FUNCIONA:
---------------------
- Verifica que todas las monturas estén correctas:
sudo mount | grep /mnt/ubuntu
- Intenta montar /dev/pts también:
sudo mount --bind /dev/pts /mnt/ubuntu/dev/pts
SI GRUB-INSTALL FALLA:
--------------------
- Intenta: grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=ubuntu /dev/sda
- O si usas BIOS: grub-install --target=i386-pc /dev/sda
SI UPDATE-GRUB NO DETECTA EL KERNEL:
-----------------------------------
- Desde chroot: apt update && apt install linux-image-generic
NOTAS CRÍTICAS PARA LIVE CD
===========================
- TODOS los comandos PASOS 1-5 se ejecutan en Live CD
- Los comandos PASOS 6-8 se ejecutan DENTRO del chroot
- Si no estás seguro de la partición, ejecuta: sudo lsblk -f
- La partición EFI siempre es /dev/sda1 (100MB)
- Tu partición Linux debería ser /dev/sda5 después de redimensionar
- Asegúrate de tener internet antes del chroot: sudo dhclient
VERIFICACIÓN FINAL
=================
Después de reiniciar en Ubuntu normal, ejecuta:
lsblk
df -h
grub-editenv list

View File

@@ -0,0 +1,112 @@
COMO SUBIR IMÁGENES A DOCKER REGISTRY
=====================================
1. ETIQUETAR LA IMAGEN
---------------------
Para subir una imagen al registry local, primero debes etiquetarla:
docker tag <nombre-imagen>:<tag> localhost:5000/<nombre-imagen>:<tag>
Ejemplo:
docker tag mi-app:latest localhost:5000/mi-app:latest
2. SUBIR LA IMAGEN AL REGISTRY
------------------------------
Una vez etiquetada, sube la imagen:
docker push localhost:5000/<nombre-imagen>:<tag>
Ejemplo:
docker push localhost:5000/mi-app:latest
3. VERIFICAR QUE LA IMAGEN ESTÁ EN EL REGISTRY
----------------------------------------------
Puedes verificar las imágenes en el registry usando:
curl -X GET http://localhost:5000/v2/_catalog
O accediendo a la UI web en: http://localhost:8081
4. DESCARGAR LA IMAGEN DESDE EL REGISTRY
----------------------------------------
Para descargar la imagen desde el registry:
docker pull localhost:5000/<nombre-imagen>:<tag>
Ejemplo:
docker pull localhost:5000/mi-app:latest
COMO SUBIR ACTUALIZACIONES DE LA IMAGEN
======================================
1. HACER CAMBIOS A TU IMAGEN
----------------------------
Realiza los cambios necesarios en tu Dockerfile o código fuente.
2. CONSTRUIR LA NUEVA VERSIÓN
------------------------------
Construye la nueva versión de la imagen:
docker build -t <nombre-imagen>:<nuevo-tag> .
Ejemplo con versión:
docker build -t mi-app:v2.0 .
O si quieres mantener el mismo tag:
docker build -t mi-app:latest .
3. ETIQUETAR PARA EL REGISTRY
------------------------------
Etiqueta la nueva versión para el registry:
docker tag <nombre-imagen>:<tag> localhost:5000/<nombre-imagen>:<tag>
Ejemplo:
docker tag mi-app:v2.0 localhost:5000/mi-app:v2.0
4. SUBIR LA ACTUALIZACIÓN
-------------------------
Sube la nueva versión al registry:
docker push localhost:5000/<nombre-imagen>:<tag>
Ejemplo:
docker push localhost:5000/mi-app:v2.0
5. VERIFICAR LA ACTUALIZACIÓN
-----------------------------
Verifica que la nueva versión esté disponible:
curl -X GET http://localhost:5000/v2/<nombre-imagen>/tags/list
Ejemplo:
curl -X GET http://localhost:5000/v2/mi-app/tags/list
NOTAS IMPORTANTES
================
- Siempre verifica que la imagen se construyó correctamente antes de subirla
- Puedes mantener múltiples versiones usando diferentes tags (v1.0, v1.1, v2.0, etc.)
- El tag 'latest' siempre apuntará a la última versión que subas con ese tag
- Para eliminar imágenes antiguas del registry, necesitarás usar la API de eliminación
- La UI web en http://localhost:8081 te permite ver todas las imágenes y sus tags
EJEMPLO COMPLETO DE WORKFLOW
============================
# 1. Construir imagen
docker build -t mi-app:1.0 .
# 2. Etiquetar para registry
docker tag mi-app:1.0 localhost:5000/mi-app:1.0
# 3. Subir a registry
docker push localhost:5000/mi-app:1.0
# 4. Para actualizar
docker build -t mi-app:1.1 .
docker tag mi-app:1.1 localhost:5000/mi-app:1.1
docker push localhost:5000/mi-app:1.1
# 5. Verificar
curl http://localhost:5000/v2/mi-app/tags/list

View File

@@ -0,0 +1,29 @@
version: '3.8'
services:
docker-registry:
image: registry:2.8.3
container_name: docker-registry
ports:
- "5000:5000"
environment:
- REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin=[http://localhost:8081]
- REGISTRY_HTTP_HEADERS_Access-Control-Allow-Methods=[HEAD,GET,OPTIONS,DELETE]
- REGISTRY_HTTP_HEADERS_Access-Control-Allow-Credentials=[true]
- REGISTRY_HTTP_HEADERS_Access-Control-Allow-Headers=[Authorization,Accept,Origin,Destination,Content-Type,Docker-Content-Digest]
- REGISTRY_HTTP_HEADERS_Access-Control-Expose-Headers=[Docker-Content-Digest]
volumes:
- ./data:/var/lib/registry
restart: unless-stopped
registry-ui:
image: joxit/docker-registry-ui:latest
container_name: registry-ui
ports:
- "8081:80"
environment:
- REGISTRY_URL=http://docker-registry:5000
depends_on:
- docker-registry
restart: unless-stopped

View File

@@ -0,0 +1,11 @@
version: '3.8'
services:
registry-ui:
image: joxit/docker-registry-ui:latest
container_name: registry-ui
ports:
- "8081:80"
environment:
- REGISTRY_URL=http://localhost:5000
restart: unless-stopped

View File

@@ -1,16 +1,87 @@
<?php
echo "=== IBIZA CEA - Script de Instalación ===\n\n";
echo "=== IBIZA CEA - Script de Instalación Docker ===\n\n";
$configFile = __DIR__ . '/.env';
if (!file_exists($configFile)) {
echo "Error: Archivo .env no encontrado. Por favor cree el archivo .env con la configuración de la base de datos.\n";
exit(1);
// Función para crear archivo .env interactivo
function createEnvFile() {
echo "Configuración de variables de entorno:\n\n";
// Entorno
$appEnv = readline("Entorno (local/production) [local]: ");
$appEnv = empty($appEnv) ? 'local' : $appEnv;
// URL del sitio
$siteUrl = readline("URL del sitio (ej: http://localhost/ibiza) [http://localhost]: ");
$siteUrl = empty($siteUrl) ? 'http://localhost' : $siteUrl;
// Configuración de base de datos
echo "\n--- Configuración de Base de Datos ---\n";
$dbHost = readline("Host de la base de datos [localhost]: ");
$dbHost = empty($dbHost) ? 'localhost' : $dbHost;
$dbPort = readline("Puerto de la base de datos [3306]: ");
$dbPort = empty($dbPort) ? '3306' : $dbPort;
$dbUser = readline("Usuario de la base de datos [root]: ");
$dbUser = empty($dbUser) ? 'root' : $dbUser;
$dbPass = readline("Contraseña de la base de datos: ");
$dbName = readline("Nombre de la base de datos [ibiza_db]: ");
$dbName = empty($dbName) ? 'ibiza_db' : $dbName;
// JWT Secret
$jwtSecret = readline("JWT Secret (presione Enter para generar automático): ");
if (empty($jwtSecret)) {
$jwtSecret = 'ibiza_jwt_' . bin2hex(random_bytes(16)) . '_' . date('Y');
}
// Crear archivo .env
$envContent = "# Entorno de aplicación\n";
$envContent .= "APP_ENV=$appEnv\n";
$envContent .= "SITE_URL=$siteUrl\n\n";
$envContent .= "# Base de datos local/desarrollo\n";
$envContent .= "LOCAL_DB_HOST=$dbHost\n";
$envContent .= "LOCAL_DB_PORT=$dbPort\n";
$envContent .= "LOCAL_DB_USER=$dbUser\n";
$envContent .= "LOCAL_DB_PASS=$dbPass\n";
$envContent .= "LOCAL_DB_NAME=$dbName\n\n";
$envContent .= "# Base de datos de producción\n";
$envContent .= "SERVER_DB_HOST=$dbHost\n";
$envContent .= "SERVER_DB_PORT=$dbPort\n";
$envContent .= "SERVER_DB_USER=$dbUser\n";
$envContent .= "SERVER_DB_PASS=$dbPass\n";
$envContent .= "SERVER_DB_NAME=$dbName\n\n";
$envContent .= "# Configuración de sesión\n";
$envContent .= "SESSION_TIMEOUT=28800\n";
$envContent .= "JWT_SECRET=$jwtSecret\n";
$envContent .= "JWT_EXPIRATION=86400\n";
file_put_contents(__DIR__ . '/.env', $envContent);
echo "\n✓ Archivo .env creado exitosamente\n\n";
}
// Verificar si existe .env
$configFile = __DIR__ . '/.env';
if (!file_exists($configFile) || empty(file_get_contents($configFile))) {
echo "Archivo .env no encontrado o vacío.\n";
$createEnv = readline("¿Desea crear archivo .env ahora? (s/n) [s]: ");
if (strtolower($createEnv) !== 'n') {
createEnvFile();
} else {
echo "Por favor cree el archivo .env manualmente antes de continuar.\n";
exit(1);
}
}
// Cargar configuración
require_once __DIR__ . '/config/config.php';
echo "Conectando a la base de datos...\n";
echo "Host: " . DB_HOST . "\n";
echo "Puerto: " . DB_PORT . "\n";
echo "Usuario: " . DB_USER . "\n";
echo "Base de datos: " . DB_NAME . "\n\n";
try {
$dsn = "mysql:host=" . DB_HOST . ";port=" . DB_PORT . ";charset=utf8mb4";
@@ -19,6 +90,17 @@ try {
echo "✓ Conexión exitosa\n\n";
} catch (PDOException $e) {
echo "✗ Error de conexión: " . $e->getMessage() . "\n";
echo "\nVerifique la configuración en el archivo .env\n";
exit(1);
}
// Verificar/crear base de datos
try {
$pdo->exec("CREATE DATABASE IF NOT EXISTS `" . DB_NAME . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$pdo->exec("USE `" . DB_NAME . "`");
echo "✓ Base de datos verificada/creada: " . DB_NAME . "\n\n";
} catch (PDOException $e) {
echo "✗ Error al crear base de datos: " . $e->getMessage() . "\n";
exit(1);
}
@@ -45,3 +127,4 @@ echo "Usuario por defecto:\n";
echo " Usuario: admin\n";
echo " Contraseña: admin123\n\n";
echo "Acceda al sistema en: " . SITE_URL . "/login.php\n";
echo "\nPara Docker, asegúrese de que las variables de entorno estén configuradas correctamente.\n";

View File

@@ -0,0 +1,25 @@
-- =====================================================
-- Optimización de Índices para Página de Pagos
-- Fecha: 2026-02-14
-- Propósito: Mejorar rendimiento de consultas en payments
-- =====================================================
-- Índice compuesto para búsquedas por año y mes
-- Mejora: SELECT ... WHERE year = ? AND month = ?
-- Si el índice ya existe, este comando generará un warning pero continuará
CREATE INDEX idx_payments_year_month ON payments(year, month);
-- Índice compuesto para búsquedas por casa y año
-- Mejora: SELECT ... WHERE house_id = ? AND year = ?
CREATE INDEX idx_payments_house_year ON payments(house_id, year);
-- Índice para ordenamiento de casas por número
-- Mejora: ORDER BY CAST(number AS UNSIGNED)
CREATE INDEX idx_houses_number ON houses(number);
-- Verificar índices creados
SELECT 'Índices de payments:' as 'Tabla';
SHOW INDEX FROM payments WHERE Key_name LIKE 'idx_payments%';
SELECT 'Índices de houses:' as 'Tabla';
SHOW INDEX FROM houses WHERE Key_name LIKE 'idx_houses%';

View File

@@ -0,0 +1,48 @@
-- =====================================================
-- Módulo de Pagos de Luz - Cámara
-- Fecha: 2026-02-14
-- Propósito: Crear tablas para gestión de pagos bimestrales de luz
-- =====================================================
-- Tabla de configuración bimestral de luz
CREATE TABLE IF NOT EXISTS electricity_bills (
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
period VARCHAR(20) NOT NULL COMMENT 'Ene-Feb, Mar-Abr, May-Jun, Jul-Ago, Sep-Oct, Nov-Dic',
total_amount DECIMAL(10,2) DEFAULT 0.00 COMMENT 'Monto total del recibo CFE',
amount_per_house DECIMAL(10,2) DEFAULT 0.00 COMMENT 'Monto sugerido por casa',
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_period (year, period),
INDEX idx_year (year)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Configuración bimestral de pagos de luz';
-- Tabla de pagos de luz por casa
CREATE TABLE IF NOT EXISTS electricity_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
house_id INT NOT NULL,
year INT NOT NULL,
period VARCHAR(20) NOT NULL COMMENT 'Ene-Feb, Mar-Abr, May-Jun, Jul-Ago, Sep-Oct, Nov-Dic',
amount DECIMAL(10,2) DEFAULT 0.00,
payment_date DATETIME,
notes TEXT,
created_by INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (house_id) REFERENCES houses(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY unique_payment (house_id, year, period)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Pagos de luz por casa';
-- Índices para optimización de queries
CREATE INDEX idx_electricity_payments_year_period ON electricity_payments(year, period);
CREATE INDEX idx_electricity_payments_house_year ON electricity_payments(house_id, year);
-- Verificar tablas creadas
SELECT 'Tablas creadas exitosamente:' as 'Status';
SHOW TABLES LIKE 'electricity%';
-- Verificar índices
SELECT 'Índices de electricity_payments:' as 'Verificación';
SHOW INDEX FROM electricity_payments WHERE Key_name LIKE 'idx_%';

93
models/ElectricityBill.php Executable file
View File

@@ -0,0 +1,93 @@
<?php
class ElectricityBill
{
/**
* Obtener configuraciones de todos los periodos de un año
*/
public static function getYear($year)
{
$db = Database::getInstance();
$bills = $db->fetchAll(
"SELECT * FROM electricity_bills WHERE year = ? ORDER BY FIELD(period, 'Ene-Feb', 'Mar-Abr', 'May-Jun', 'Jul-Ago', 'Sep-Oct', 'Nov-Dic')",
[$year]
);
// Organizar por periodo
$result = [];
foreach ($bills as $bill) {
$result[$bill['period']] = $bill;
}
return $result;
}
/**
* Guardar o actualizar configuración de un periodo
*/
public static function save($data, $userId)
{
$db = Database::getInstance();
$id = $data['id'] ?? null;
$year = $data['year'];
$period = $data['period'];
$totalAmount = $data['total_amount'] ?? 0;
$amountPerHouse = $data['amount_per_house'] ?? 0;
$notes = $data['notes'] ?? '';
if ($id) {
// Actualizar existente
$db->execute(
"UPDATE electricity_bills
SET total_amount = ?, amount_per_house = ?, notes = ?, updated_at = NOW()
WHERE id = ?",
[$totalAmount, $amountPerHouse, $notes, $id]
);
return $id;
}
else {
// Insertar nuevo o actualizar si existe
$db->execute(
"INSERT INTO electricity_bills (year, period, total_amount, amount_per_house, notes)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
total_amount = VALUES(total_amount),
amount_per_house = VALUES(amount_per_house),
notes = VALUES(notes),
updated_at = NOW()",
[$year, $period, $totalAmount, $amountPerHouse, $notes]
);
return $db->lastInsertId();
}
}
/**
* Obtener periodos bimestrales
*/
public static function getPeriods()
{
return [
'Ene-Feb',
'Mar-Abr',
'May-Jun',
'Jul-Ago',
'Sep-Oct',
'Nov-Dic'
];
}
/**
* Obtener configuración de un periodo específico
*/
public static function getByYearPeriod($year, $period)
{
$db = Database::getInstance();
return $db->fetchOne(
"SELECT * FROM electricity_bills WHERE year = ? AND period = ?",
[$year, $period]
);
}
}

191
models/ElectricityPayment.php Executable file
View File

@@ -0,0 +1,191 @@
<?php
class ElectricityPayment
{
/**
* Obtener matriz completa de pagos para un año
* OPTIMIZADO: Una sola query para todos los periodos
*/
public static function getMatrix($year)
{
$db = Database::getInstance();
$houses = $db->fetchAll(
"SELECT h.id, h.number, h.status, h.consumption_only, h.owner_name
FROM houses h
ORDER BY CAST(h.number AS UNSIGNED)"
);
$periods = ElectricityBill::getPeriods();
// OPTIMIZADO: Una sola query en lugar de 6 queries separadas
$allPayments = $db->fetchAll(
"SELECT house_id, period, amount, payment_date
FROM electricity_payments
WHERE year = ?
ORDER BY FIELD(period, 'Ene-Feb', 'Mar-Abr', 'May-Jun', 'Jul-Ago', 'Sep-Oct', 'Nov-Dic')",
[$year]
);
// Organizar pagos por periodo
$payments = [];
foreach ($periods as $period) {
$payments[$period] = [];
}
foreach ($allPayments as $p) {
$payments[$p['period']][$p['house_id']] = $p;
}
return ['houses' => $houses, 'payments' => $payments, 'periods' => $periods];
}
/**
* Actualizar un solo pago
*/
public static function update($houseId, $year, $period, $amount, $userId, $notes = null)
{
$db = Database::getInstance();
$existing = $db->fetchOne(
"SELECT id FROM electricity_payments WHERE house_id = ? AND year = ? AND period = ?",
[$houseId, $year, $period]
);
if ($amount == 0 && $existing) {
$db->execute(
"DELETE FROM electricity_payments WHERE id = ?",
[$existing['id']]
);
return ['success' => true, 'deleted' => true];
}
if ($existing) {
$db->execute(
"UPDATE electricity_payments SET amount = ?, payment_date = NOW(), notes = ?, created_by = ? WHERE id = ?",
[$amount, $notes, $userId, $existing['id']]
);
}
else {
$db->execute(
"INSERT INTO electricity_payments (house_id, year, period, amount, payment_date, notes, created_by)
VALUES (?, ?, ?, ?, NOW(), ?, ?)",
[$houseId, $year, $period, $amount, $notes, $userId]
);
}
return ['success' => true, 'deleted' => false];
}
/**
* Actualizar múltiples pagos en batch con transacción
* OPTIMIZADO: Similar a Payment::updateBatch()
*/
public static function updateBatch($changes, $userId)
{
$db = Database::getInstance();
$pdo = $db->getPDO();
try {
$pdo->beginTransaction();
$updateCount = 0;
$deleteCount = 0;
// Preparar statements una sola vez (reutilización)
$insertStmt = $pdo->prepare(
"INSERT INTO electricity_payments (house_id, year, period, amount, payment_date, created_by)
VALUES (?, ?, ?, ?, NOW(), ?)
ON DUPLICATE KEY UPDATE
amount = VALUES(amount),
payment_date = VALUES(payment_date),
created_by = VALUES(created_by)"
);
$deleteStmt = $pdo->prepare(
"DELETE FROM electricity_payments WHERE house_id = ? AND year = ? AND period = ?"
);
foreach ($changes as $change) {
// Validar que tenemos los datos mínimos
if (!isset($change['house_id'], $change['year'], $change['period'])) {
continue;
}
$amount = isset($change['amount']) ? (float)$change['amount'] : 0;
if ($amount == 0) {
// Eliminar si el monto es 0
$deleteStmt->execute([
$change['house_id'],
$change['year'],
$change['period']
]);
$deleteCount++;
}
else {
// Insertar o actualizar
$insertStmt->execute([
$change['house_id'],
$change['year'],
$change['period'],
$amount,
$userId
]);
$updateCount++;
}
}
$pdo->commit();
return [
'success' => true,
'count' => $updateCount + $deleteCount,
'updated' => $updateCount,
'deleted' => $deleteCount
];
}
catch (Exception $e) {
$pdo->rollback();
error_log("Error en ElectricityPayment::updateBatch: " . $e->getMessage());
return [
'success' => false,
'error' => $e->getMessage()
];
}
}
/**
* Obtener pagos de una casa específica
*/
public static function getByHouse($houseId, $year = null)
{
$db = Database::getInstance();
if ($year) {
return $db->fetchAll(
"SELECT * FROM electricity_payments WHERE house_id = ? AND year = ? ORDER BY FIELD(period, 'Ene-Feb', 'Mar-Abr', 'May-Jun', 'Jul-Ago', 'Sep-Oct', 'Nov-Dic')",
[$houseId, $year]
);
}
return $db->fetchAll(
"SELECT * FROM electricity_payments WHERE house_id = ? ORDER BY year DESC, FIELD(period, 'Ene-Feb', 'Mar-Abr', 'May-Jun', 'Jul-Ago', 'Sep-Oct', 'Nov-Dic') DESC",
[$houseId]
);
}
/**
* Obtener total de pagos de un año
*/
public static function getTotalByYear($year)
{
$db = Database::getInstance();
$result = $db->fetchOne(
"SELECT COALESCE(SUM(amount), 0) as total FROM electricity_payments WHERE year = ?",
[$year]
);
return $result['total'] ?? 0;
}
}

View File

@@ -1,42 +1,60 @@
<?php
class Payment {
public static function getMatrix($year) {
class Payment
{
public static function getMatrix($year)
{
$db = Database::getInstance();
$houses = $db->fetchAll(
"SELECT h.id, h.number, h.status, h.consumption_only, h.owner_name
FROM houses h
ORDER BY CAST(h.number AS UNSIGNED)"
);
$months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
$months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
// OPTIMIZADO: Una sola query en lugar de 12 queries separadas
$allPayments = $db->fetchAll(
"SELECT house_id, month, amount, payment_date
FROM payments
WHERE year = ?
ORDER BY FIELD(month, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre')",
[$year]
);
// Organizar pagos por mes
$payments = [];
foreach ($months as $month) {
$monthPayments = $db->fetchAll(
"SELECT house_id, amount, payment_date
FROM payments
WHERE year = ? AND month = ?",
[$year, $month]
);
$payments[$month] = [];
foreach ($monthPayments as $p) {
$payments[$month][$p['house_id']] = $p;
}
}
foreach ($allPayments as $p) {
$payments[$p['month']][$p['house_id']] = $p;
}
return ['houses' => $houses, 'payments' => $payments, 'months' => $months];
}
public static function getExpectedAmount($house, $year, $month) {
$db = Database::getInstance();
$bill = $db->fetchOne(
"SELECT * FROM monthly_bills WHERE year = ? AND month = ?",
/**
* Obtener monto esperado con caché de monthly_bills (OPTIMIZADO)
* Usar esta versión cuando monthly_bills ya está cargado
*/
public static function getExpectedAmount($house, $year, $month, $monthlyBills = null)
{
// Si no se pasa caché, usar query (backward compatibility)
if ($monthlyBills === null) {
$db = Database::getInstance();
$bill = $db->fetchOne(
"SELECT * FROM monthly_bills WHERE year = ? AND month = ?",
[$year, $month]
);
);
}
else {
$bill = $monthlyBills[$month] ?? null;
}
if (!$bill) {
return 0;
@@ -51,18 +69,46 @@ class Payment {
return round($monto_base, 2);
}
public static function update($houseId, $year, $month, $amount, $userId, $notes = null, $paymentMethod = null) {
/**
* Obtener monto esperado sin descuento con caché (OPTIMIZADO)
* Usar esta versión cuando monthly_bills ya está cargado
*/
public static function getExpectedAmountWithDiscount($house, $year, $month, $monthlyBills = null)
{
// Si no se pasa caché, usar query (backward compatibility)
if ($monthlyBills === null) {
$db = Database::getInstance();
$bill = $db->fetchOne(
"SELECT * FROM monthly_bills WHERE year = ? AND month = ?",
[$year, $month]
);
}
else {
$bill = $monthlyBills[$month] ?? null;
}
if (!$bill) {
return 0;
}
$monto_base = $bill['amount_per_house'];
return round($monto_base, 2);
}
public static function update($houseId, $year, $month, $amount, $userId, $notes = null, $paymentMethod = null)
{
$db = Database::getInstance();
$existing = $db->fetchOne(
"SELECT id FROM payments WHERE house_id = ? AND year = ? AND month = ?",
[$houseId, $year, $month]
[$houseId, $year, $month]
);
if ($amount == 0 && $existing) {
$db->execute(
"DELETE FROM payments WHERE id = ?",
[$existing['id']]
[$existing['id']]
);
return ['success' => true, 'deleted' => true];
}
@@ -70,41 +116,127 @@ class Payment {
if ($existing) {
$db->execute(
"UPDATE payments SET amount = ?, payment_date = NOW(), notes = ?, payment_method = ?, created_by = ? WHERE id = ?",
[$amount, $notes, $paymentMethod, $userId, $existing['id']]
[$amount, $notes, $paymentMethod, $userId, $existing['id']]
);
} else {
}
else {
$db->execute(
"INSERT INTO payments (house_id, year, month, amount, payment_date, notes, payment_method, created_by)
VALUES (?, ?, ?, ?, NOW(), ?, ?, ?)",
[$houseId, $year, $month, $amount, $notes, $paymentMethod, $userId]
[$houseId, $year, $month, $amount, $notes, $paymentMethod, $userId]
);
}
return ['success' => true, 'deleted' => false];
}
public static function getByHouse($houseId, $year = null) {
public static function getByHouse($houseId, $year = null)
{
$db = Database::getInstance();
if ($year) {
return $db->fetchAll(
"SELECT * FROM payments WHERE house_id = ? AND year = ? ORDER BY FIELD(month, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre')",
[$houseId, $year]
[$houseId, $year]
);
}
return $db->fetchAll(
"SELECT * FROM payments WHERE house_id = ? ORDER BY year DESC, FIELD(month, 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre') DESC",
[$houseId]
[$houseId]
);
}
public static function getTotalByYear($year) {
public static function getTotalByYear($year)
{
$db = Database::getInstance();
$result = $db->fetchOne(
"SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE year = ?",
[$year]
[$year]
);
return $result['total'] ?? 0;
}
}
/**
* Actualizar múltiples pagos en batch con transacción
* Mucho más rápido que llamar update() múltiples veces
*
* @param array $changes Array de cambios [{house_id, year, month, amount}, ...]
* @param int $userId ID del usuario que realiza los cambios
* @return array ['success' => bool, 'count' => int, 'error' => string]
*/
public static function updateBatch($changes, $userId)
{
$db = Database::getInstance();
$pdo = $db->getPDO();
try {
$pdo->beginTransaction();
$updateCount = 0;
$deleteCount = 0;
// Preparar statements una sola vez (reutilización)
$insertStmt = $pdo->prepare(
"INSERT INTO payments (house_id, year, month, amount, payment_date, created_by)
VALUES (?, ?, ?, ?, NOW(), ?)
ON DUPLICATE KEY UPDATE
amount = VALUES(amount),
payment_date = VALUES(payment_date),
created_by = VALUES(created_by)"
);
$deleteStmt = $pdo->prepare(
"DELETE FROM payments WHERE house_id = ? AND year = ? AND month = ?"
);
foreach ($changes as $change) {
// Validar que tenemos los datos mínimos
if (!isset($change['house_id'], $change['year'], $change['month'])) {
continue;
}
$amount = isset($change['amount']) ? (float)$change['amount'] : 0;
if ($amount == 0) {
// Eliminar si el monto es 0
$deleteStmt->execute([
$change['house_id'],
$change['year'],
$change['month']
]);
$deleteCount++;
}
else {
// Insertar o actualizar
$insertStmt->execute([
$change['house_id'],
$change['year'],
$change['month'],
$amount,
$userId
]);
$updateCount++;
}
}
$pdo->commit();
return [
'success' => true,
'count' => $updateCount + $deleteCount,
'updated' => $updateCount,
'deleted' => $deleteCount
];
}
catch (Exception $e) {
$pdo->rollback();
error_log("Error en Payment::updateBatch: " . $e->getMessage());
return [
'success' => false,
'error' => $e->getMessage()
];
}
}
}

View File

@@ -1,7 +1,9 @@
<?php
class Report {
public static function getGeneralBalance($startDate = null, $endDate = null) {
class Report
{
public static function getGeneralBalance($startDate = null, $endDate = null)
{
$db = Database::getInstance();
$whereClause = '';
@@ -19,7 +21,8 @@ class Report {
if ($whereClause) {
$whereClause .= " AND cp.house_id IN ($placeholders)";
} else {
}
else {
$whereClause = " WHERE cp.house_id IN ($placeholders)";
}
@@ -66,7 +69,8 @@ class Report {
];
}
public static function getConceptDetailsByYear($year = null) {
public static function getConceptDetailsByYear($year = null)
{
$db = Database::getInstance();
$concepts = $db->fetchAll(
"SELECT c.id, c.name, c.amount_per_house, c.concept_date, c.description
@@ -152,7 +156,8 @@ class Report {
];
}
public static function getHouseStatement($houseId, $year = null) {
public static function getHouseStatement($houseId, $year = null)
{
$db = Database::getInstance();
$house = House::findById($houseId);
@@ -171,7 +176,7 @@ class Report {
"SELECT 'Concepto' as type, c.name as description, cp.amount, cp.payment_date, cp.notes
FROM finance_collection_payments cp
JOIN finance_collection_concepts c ON cp.concept_id = c.id
WHERE cp.house_id = ?" .
WHERE cp.house_id = ?" .
($year ? " AND YEAR(cp.payment_date) = ?" : "") . "
ORDER BY cp.payment_date DESC",
$year ? [$houseId, $year] : [$houseId]
@@ -184,10 +189,11 @@ class Report {
];
}
public static function getPaymentsByYear($year) {
public static function getPaymentsByYear($year)
{
$db = Database::getInstance();
$months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
$months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
$data = [];
foreach ($months as $month) {
@@ -195,7 +201,7 @@ class Report {
"SELECT COALESCE(SUM(amount), 0) as total, COUNT(*) as count
FROM payments
WHERE year = ? AND month = ?",
[$year, $month]
[$year, $month]
);
$data[$month] = [
'total' => $result['total'] ?? 0,
@@ -206,7 +212,8 @@ class Report {
return $data;
}
public static function getExpensesByCategory($startDate = null, $endDate = null) {
public static function getExpensesByCategory($startDate = null, $endDate = null)
{
$db = Database::getInstance();
$sql = "SELECT category, COALESCE(SUM(amount), 0) as total
@@ -224,7 +231,8 @@ class Report {
return $db->fetchAll($sql, $params);
}
public static function getCollectionReport($conceptId) {
public static function getCollectionReport($conceptId)
{
$concept = CollectionConcept::findById($conceptId);
$status = CollectionConcept::getCollectionStatus($conceptId);
$payments = CollectionConcept::getPaymentsByConcept($conceptId);
@@ -236,7 +244,8 @@ class Report {
];
}
public static function getDashboardStats($year = null, $accessibleHouseIds = []) {
public static function getDashboardStats($year = null, $accessibleHouseIds = [])
{
$year = $year ?? date('Y');
$db = Database::getInstance();
@@ -263,7 +272,8 @@ class Report {
$totalExpenses = 0;
$balance = $conceptPayments;
} else {
}
else {
$totalHouses = House::countAll();
$activeHouses = House::countActive();
@@ -271,7 +281,7 @@ class Report {
"SELECT COALESCE(SUM(cp.amount), 0) as total
FROM finance_collection_payments cp
WHERE YEAR(cp.payment_date) = ?",
[$year]
[$year]
);
$conceptPayments = $conceptPayments['total'] ?? 0;
@@ -279,7 +289,7 @@ class Report {
"SELECT COALESCE(SUM(amount), 0) as total
FROM expenses
WHERE YEAR(expense_date) = ?",
[$year]
[$year]
);
$totalExpenses = $totalExpenses['total'] ?? 0;
@@ -304,10 +314,11 @@ class Report {
];
}
public static function getWaterDebtors($filters = []) {
public static function getWaterDebtors($filters = [])
{
$db = Database::getInstance();
$allMonths = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
$year = $filters['year'] ?? null;
$months = $filters['months'] ?? $allMonths;
@@ -338,7 +349,8 @@ class Report {
if ($year) {
$yearsToCheck = [$year];
} else {
}
else {
$years = $db->fetchAll("SELECT DISTINCT year FROM payments ORDER BY year");
$yearsToCheck = array_column($years, 'year');
}
@@ -357,7 +369,7 @@ class Report {
$expected = Payment::getExpectedAmount($house, $yr, $month);
$payment = $db->fetchOne(
"SELECT amount FROM payments WHERE house_id = ? AND year = ? AND month = ?",
[$house['id'], $yr, $month]
[$house['id'], $yr, $month]
);
$paid = $payment['amount'] ?? 0;
$due = $expected - $paid;
@@ -404,7 +416,8 @@ class Report {
];
}
public static function getConceptDebtors($accessibleHouseIds = []) {
public static function getConceptDebtors($accessibleHouseIds = [])
{
$db = Database::getInstance();
$concepts = $db->fetchAll(
"SELECT c.id, c.name, c.amount_per_house
@@ -441,7 +454,7 @@ class Report {
$payment = $db->fetchOne(
"SELECT amount FROM finance_collection_payments
WHERE concept_id = ? AND house_id = ?",
[$concept['id'], $house['id']]
[$concept['id'], $house['id']]
);
$paid = $payment['amount'] ?? 0;
@@ -483,7 +496,8 @@ class Report {
];
}
public static function getConceptDebtorsFiltered($houseIds, $conceptIds = null) {
public static function getConceptDebtorsFiltered($houseIds, $conceptIds = null)
{
$db = Database::getInstance();
$whereConditions = [];
@@ -578,4 +592,123 @@ class Report {
'total_due' => $grandTotal
];
}
}
public static function getElectricityDebtors($filters = [])
{
$db = Database::getInstance();
require_once __DIR__ . '/ElectricityBill.php';
require_once __DIR__ . '/ElectricityPayment.php';
$year = $filters['year'] ?? null;
$periods = $filters['periods'] ?? ElectricityBill::getPeriods();
$houseId = $filters['house_id'] ?? null;
$accessibleHouseIds = $filters['accessible_house_ids'] ?? [];
// 1. Obtener Casas Activas
$whereHouse = '';
$houseParams = [];
if ($houseId) {
$whereHouse = "AND h.id = ?";
$houseParams = [$houseId];
}
$sql = "SELECT h.id, h.number, h.owner_name, h.status
FROM houses h
WHERE h.status = 'activa' $whereHouse";
if (!empty($accessibleHouseIds) && !Auth::isAdmin()) {
$placeholders = str_repeat('?,', count($accessibleHouseIds) - 1) . '?';
$sql .= " AND h.id IN ($placeholders)";
$houseParams = array_merge($houseParams, $accessibleHouseIds);
}
$sql .= " ORDER BY CAST(h.number AS UNSIGNED)";
$houses = $db->fetchAll($sql, $houseParams);
// 2. Determinar Años a revisar
if ($year) {
$yearsToCheck = [$year];
}
else {
// Revisar años donde hay configuración de recibos
$years = $db->fetchAll("SELECT DISTINCT year FROM electricity_bills ORDER BY year DESC");
$yearsToCheck = array_column($years, 'year');
if (empty($yearsToCheck))
$yearsToCheck = [date('Y')];
}
// 3. Calcular Deudas
$debtors = [];
$grandTotalExpected = 0;
$grandTotalPaid = 0;
foreach ($houses as $house) {
$totalExpected = 0;
$totalPaid = 0;
$periodDetails = [];
foreach ($yearsToCheck as $yr) {
// Obtener configuración del año para optimizar (evitar queries por periodo)
$billsConfig = ElectricityBill::getYear($yr);
foreach ($periods as $period) {
$config = $billsConfig[$period] ?? null;
// Solo cobrar si hay configuración y monto > 0
if (!$config || $config['amount_per_house'] <= 0) {
continue;
}
$expected = $config['amount_per_house'];
// Obtener pago
$payment = $db->fetchOne(
"SELECT amount FROM electricity_payments WHERE house_id = ? AND year = ? AND period = ?",
[$house['id'], $yr, $period]
);
$paid = $payment['amount'] ?? 0;
$due = $expected - $paid;
$totalExpected += $expected;
$totalPaid += $paid;
if ($due > 0.01) { // Tolerancia pequeña a flotantes
$periodDetails[] = [
'year' => $yr,
'period' => $period,
'expected' => $expected,
'paid' => $paid,
'due' => $due
];
}
}
}
$houseTotalDue = $totalExpected - $totalPaid;
if ($houseTotalDue > 0.01) {
$debtors[] = [
'house_id' => $house['id'],
'house_number' => $house['number'],
'owner_name' => $house['owner_name'],
'periods_due' => $periodDetails,
'total_due' => $houseTotalDue
];
}
$grandTotalExpected += $totalExpected;
$grandTotalPaid += $totalPaid;
}
$grandTotalDue = $grandTotalExpected - $grandTotalPaid;
return [
'debtors' => $debtors,
'total_due' => $grandTotalDue,
'total_expected' => $grandTotalExpected,
'total_paid' => $grandTotalPaid,
'filters' => $filters
];
}
}

View File

600
views/electricity/index.php Executable file
View File

@@ -0,0 +1,600 @@
<?php
// views/electricity/index.php
$periods = ElectricityBill::getPeriods();
?>
<div class="row mb-4">
<div class="col-12">
<h2><i class="bi bi-lightbulb"></i> Pagos de Luz - Cámara</h2>
<p class="text-muted">Concentrado de pagos bimestrales por casa</p>
</div>
</div>
<div class="mb-4 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex gap-3 align-items-center flex-wrap">
<div>
<label for="yearSelect" class="form-label me-2">Año:</label>
<select id="yearSelect" class="form-select d-inline-block" style="width: auto;"
onchange="window.location.href='dashboard.php?page=luz_camara&year='+this.value">
<?php for ($y = 2024; $y <= date('Y') + 1; $y++): ?>
<option value="<?= $y?>" <?= $y == $year ? 'selected' : ''?>>
<?= $y?>
</option>
<?php
endfor; ?>
</select>
</div>
<div class="btn-group">
<button type="button" class="btn btn-outline-primary dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-download"></i> Exportar
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" onclick="exportPDF()">PDF</a></li>
<li><a class="dropdown-item" href="#" onclick="exportCSV()">CSV</a></li>
</ul>
</div>
</div>
<div>
<?php if (Auth::isCapturist()): ?>
<button onclick="saveChanges()" id="btnSaveTop" class="btn btn-warning position-relative" disabled>
<i class="bi bi-save"></i> Guardar Cambios
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"
id="changesBadge" style="display: none;">
0
<span class="visually-hidden">cambios pendientes</span>
</span>
</button>
<?php
endif; ?>
</div>
</div>
<!-- Configuración de Recibos Bimestrales -->
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title mb-3"><i class="bi bi-gear"></i> Configuración de Recibos (CFE)</h5>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label small text-muted">Periodo</label>
<select id="configPeriod" class="form-select form-select-sm" onchange="loadConfig()">
<?php foreach ($periods as $period): ?>
<option value="<?= $period?>">
<?= $period?>
</option>
<?php
endforeach; ?>
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">Total CFE</label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" id="configTotal" class="form-control" step="0.01" <?=!Auth::isCapturist()
? 'disabled' : ''?>>
</div>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">Por Casa</label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" id="configPerHouse" class="form-control" step="0.01" <?=!Auth::isCapturist()
? 'disabled' : ''?>>
</div>
</div>
<div class="col-md-3">
<label class="form-label small text-muted">Notas</label>
<input type="text" id="configNotes" class="form-control form-control-sm" placeholder="Opcional..."
<?=!Auth::isCapturist() ? 'disabled' : ''?>>
</div>
<?php if (Auth::isCapturist()): ?>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-success btn-sm w-100" onclick="saveConfig()">
<i class="bi bi-check2"></i> Guardar
</button>
</div>
<?php
endif; ?>
</div>
</div>
</div>
<!-- Tabla de Pagos -->
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-hover mb-0" id="electricityTable">
<thead class="table-dark text-center sticky-top">
<tr>
<th style="width: 80px;">Casa</th>
<th style="width: 100px;">Estado</th>
<?php foreach ($periods as $period):
$config = $electricityBills[$period] ?? [];
$amountPerHouse = $config['amount_per_house'] ?? 0;
?>
<th>
<?= $period?><br>
<small class="text-white" style="font-weight: normal;">$
<?= number_format($amountPerHouse, 2)?>
</small>
</th>
<?php
endforeach; ?>
<th>Total</th>
</tr>
</thead>
<tbody>
<?php
$periodTotals = array_fill_keys($periods, 0);
$grandTotal = 0;
foreach ($matrix['houses'] as $house):
// Filtrar visibilidad para usuarios normales
if (!Auth::canViewHouse($house['id']))
continue;
$houseTotal = 0;
?>
<tr data-house-id="<?= $house['id']?>" data-house-number="<?= $house['number']?>">
<td class="text-center fw-bold">
<?= $house['number']?>
</td>
<td class="text-center">
<span class="badge <?= $house['status'] == 'activa' ? 'paid' : 'inactive'?>">
<?= ucfirst($house['status'])?>
</span>
</td>
<?php foreach ($periods as $period):
$payment = $matrix['payments'][$period][$house['id']] ?? null;
$amount = $payment['amount'] ?? 0;
$periodTotals[$period] += $amount;
$houseTotal += $amount;
$config = $electricityBills[$period] ?? [];
$expected = $config['amount_per_house'] ?? 0;
$cellClass = '';
$cellText = '-';
if ($amount > 0) {
if ($expected > 0 && $amount >= $expected) {
$cellClass = 'paid'; // Paid
}
else {
$cellClass = 'partial'; // Partial
}
$cellText = '$' . number_format($amount, 2);
}
else {
if ($expected > 0) {
$cellClass = 'pending'; // Unpaid
$cellText = '-';
}
else {
$cellClass = ''; // No config
$cellText = '-';
}
// Si está deshabitada y no hay pago, gris
if ($house['status'] == 'deshabitada' && $amount == 0) {
$cellClass = 'inactive';
}
}
$isEditable = Auth::isCapturist() && $house['status'] == 'activa';
?>
<td class="payment-cell text-center <?= $cellClass?>" data-house-id="<?= $house['id']?>"
data-period="<?= $period?>" data-amount="<?= $amount?>" data-expected="<?= $expected?>"
<?= $isEditable ? 'contenteditable="true"' : ''?>>
<?= $cellText?>
</td>
<?php
endforeach; ?>
<td class="text-end fw-bold table-active">
$
<?= number_format($houseTotal, 2)?>
</td>
</tr>
<?php $grandTotal += $houseTotal;
endforeach; ?>
</tbody>
<tfoot class="fw-bold">
<tr>
<td colspan="2" class="text-end">Totales:</td>
<?php foreach ($periods as $period): ?>
<td class="text-center">$
<?= number_format($periodTotals[$period], 2)?>
</td>
<?php
endforeach; ?>
<td class="text-end">$
<?= number_format($grandTotal, 2)?>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<?php if (Auth::isCapturist()): ?>
<div class="d-flex justify-content-end mb-4 no-print mt-3">
<button onclick="saveChanges()" id="btnSaveBottom" class="btn btn-warning" disabled>
<i class="bi bi-save"></i> Guardar Cambios
</button>
</div>
<?php
endif; ?>
<div class="row mt-3 no-print">
<div class="col-md-6">
<div class="alert alert-info mb-0 py-2">
<strong><i class="bi bi-info-circle"></i> Instrucciones:</strong>
<ul class="mb-0 mt-1 list-inline">
<li class="list-inline-item"><span class="badge paid">Verde</span> = Pagado</li>
<li class="list-inline-item"><span class="badge partial">Amarillo</span> = Parcial</li>
<li class="list-inline-item"><span class="badge pending">Rojo</span> = Pendiente</li>
<li class="list-inline-item"><span class="badge inactive">Gris</span> = Inactivo/N.A.</li>
</ul>
</div>
</div>
</div>
<!-- Modal Export PDF -->
<div class="modal fade" id="exportPdfModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Exportar a PDF</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6>Selecciona los periodos a exportar:</h6>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAllPeriods" checked>
<label class="form-check-label" for="selectAllPeriods">Todos</label>
</div>
<hr>
<div class="row">
<?php foreach ($periods as $i => $period): ?>
<div class="col-6">
<div class="form-check">
<input class="form-check-input period-checkbox" type="checkbox" value="<?= $period?>"
id="period_<?= $i?>" checked>
<label class="form-check-label" for="period_<?= $i?>">
<?= $period?>
</label>
</div>
</div>
<?php
endforeach; ?>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-success" onclick="generatePDF()">
<i class="bi bi-file-earmark-pdf"></i> Generar PDF
</button>
</div>
</div>
</div>
</div>
<script>
// Datos de configuración iniciales
const electricityConfig = <?= json_encode($electricityBills)?>;
let pendingChanges = {};
// Cargar configuración al cambiar select
function loadConfig() {
const period = document.getElementById('configPeriod').value;
const data = electricityConfig[period] || {};
document.getElementById('configTotal').value = data.total_amount || '';
document.getElementById('configPerHouse').value = data.amount_per_house || '';
document.getElementById('configNotes').value = data.notes || '';
}
// Inicializar configuración
document.addEventListener('DOMContentLoaded', function () {
loadConfig();
setupEditableCells();
// Listener para modal
document.getElementById('selectAllPeriods').addEventListener('change', function () {
document.querySelectorAll('.period-checkbox').forEach(cb => {
cb.checked = this.checked;
});
});
});
function setupEditableCells() {
document.querySelectorAll('.payment-cell[contenteditable="true"]').forEach(cell => {
cell.addEventListener('focus', function () {
this.classList.add('editing');
const text = this.textContent.trim();
// Limpiar si es guión, moneda o 0 al enfocar para facilitar edición
if (text === '-' || text.startsWith('$') || parseAmount(text) === 0) {
this.textContent = '';
}
});
cell.addEventListener('blur', function () {
this.classList.remove('editing');
let newText = this.textContent.trim();
let newVal = parseAmount(newText);
// Formatear display
if (newVal === 0) {
const expected = parseFloat(this.dataset.expected);
if (expected > 0) {
this.textContent = '-'; // Pendiente
} else {
this.textContent = '-'; // Nada
}
} else {
this.textContent = '$' + newVal.toFixed(2);
}
trackChange(this, newVal);
});
cell.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
this.blur();
}
});
});
}
function parseAmount(text) {
if (text === '-' || !text) return 0;
return parseFloat(text.replace(/[^0-9.-]+/g, '')) || 0;
}
<?php if (Auth::isCapturist()): ?>
// Guardar configuración
async function saveConfig() {
const period = document.getElementById('configPeriod').value;
const total = document.getElementById('configTotal').value;
const perHouse = document.getElementById('configPerHouse').value;
const notes = document.getElementById('configNotes').value;
try {
const response = await fetch('dashboard.php?page=luz_camara_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
year: <?= $year?>,
period: period,
total_amount: total,
amount_per_house: perHouse,
notes: notes
})
});
const result = await response.json();
if (result.success) {
// Actualizar objeto local
electricityConfig[period] = {
year: <?= $year?>,
period: period,
total_amount: total,
amount_per_house: perHouse,
notes: notes
};
alert('Configuración guardada exitosamente');
location.reload();
} else {
alert('Error al guardar: ' + result.message);
}
} catch (error) {
console.error('Error:', error);
alert('Error de conexión al guardar configuración');
}
}
// Tracking de cambios
function trackChange(cell, newVal) {
const houseId = cell.dataset.houseId;
const period = cell.dataset.period;
const key = `${houseId}_${period}`;
const original = parseFloat(cell.dataset.amount);
// Si el valor cambió respecto al original cargado
if (newVal !== original) {
pendingChanges[key] = {
house_id: houseId,
year: <?= $year?>,
period: period,
amount: newVal
};
cell.classList.add('table-info');
cell.style.fontWeight = 'bold';
} else {
delete pendingChanges[key];
cell.classList.remove('table-info');
cell.style.fontWeight = 'normal';
}
updateSaveButton();
}
function updateSaveButton() {
const count = Object.keys(pendingChanges).length;
const btnTop = document.getElementById('btnSaveTop');
const btnBottom = document.getElementById('btnSaveBottom');
const badge = document.getElementById('changesBadge');
if (btnTop) {
btnTop.disabled = count === 0;
// Duplicar comportamiento: mostrar texto con conteo
if (count > 0) {
btnTop.innerHTML = `<i class="bi bi-save"></i> Guardar ${count} Cambios`;
btnTop.classList.remove('btn-outline-secondary');
btnTop.classList.add('btn-warning');
} else {
btnTop.innerHTML = `<i class="bi bi-save"></i> Guardar Cambios`;
}
}
if (btnBottom) {
btnBottom.disabled = count === 0;
btnBottom.innerHTML = `<i class="bi bi-save"></i> Guardar ${count} Cambios`;
}
// Alerta de navegación si hay cambios
if (count > 0) {
window.onbeforeunload = () => "Tienes cambios sin guardar. ¿Seguro que quieres salir?";
} else {
window.onbeforeunload = null;
}
}
async function saveChanges() {
if (Object.keys(pendingChanges).length === 0) return;
const changes = Object.values(pendingChanges);
const btnTop = document.getElementById('btnSaveTop');
const btnBottom = document.getElementById('btnSaveBottom');
const originalTextTop = btnTop ? btnTop.innerHTML : '';
const originalTextBottom = btnBottom ? btnBottom.innerHTML : '';
if (btnTop) {
btnTop.disabled = true;
btnTop.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Guardando...';
}
if (btnBottom) {
btnBottom.disabled = true;
btnBottom.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Guardando...';
}
try {
const response = await fetch('dashboard.php?page=luz_camara_actions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changes: changes })
});
const result = await response.json();
if (result.success) {
window.onbeforeunload = null;
alert('Cambios guardados exitosamente');
location.reload();
} else {
alert('Error al guardar: ' + (result.message || result.error));
if (btnTop) {
btnTop.disabled = false;
btnTop.innerHTML = originalTextTop;
}
if (btnBottom) {
btnBottom.disabled = false;
btnBottom.innerHTML = originalTextBottom;
}
}
} catch (error) {
console.error('Error:', error);
alert('Error de conexión');
if (btnTop) {
btnTop.disabled = false;
btnTop.innerHTML = originalTextTop;
}
if (btnBottom) {
btnBottom.disabled = false;
btnBottom.innerHTML = originalTextBottom;
}
}
}
<?php
endif; ?>
function exportPDF() {
const modal = new bootstrap.Modal(document.getElementById('exportPdfModal'));
modal.show();
}
function generatePDF() {
const checkboxes = document.querySelectorAll('.period-checkbox:checked');
const selectedPeriods = Array.from(checkboxes).map(cb => cb.value);
let url = 'dashboard.php?page=luz_camara&action=export_electricity_pdf&year=<?= $year?>';
selectedPeriods.forEach(period => {
url += `&periods[]=${encodeURIComponent(period)}`;
});
// Cerrar el modal antes de redirigir
const exportPdfModal = bootstrap.Modal.getInstance(document.getElementById('exportPdfModal'));
if (exportPdfModal) {
exportPdfModal.hide();
}
// Redirigir al usuario para iniciar la descarga del PDF
window.location.href = url;
}
function exportCSV() {
let csv = [];
const rows = document.querySelectorAll("#electricityTable tr");
for (const row of rows) {
const cols = row.querySelectorAll("td,th");
const rowData = [];
for (const col of cols) {
rowData.push('"' + col.innerText.replace(/(\r\n|\n|\r)/gm, " ").trim() + '"');
}
csv.push(rowData.join(","));
}
const csvFile = new Blob([csv.join("\n")], { type: "text/csv" });
const downloadLink = document.createElement("a");
downloadLink.download = "Pagos_Luz_Camara_<?= $year?>.csv";
downloadLink.href = window.URL.createObjectURL(csvFile);
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
}
</script>
<style>
.payment-cell {
cursor: pointer;
}
.payment-cell:focus {
outline: 2px solid #0d6efd;
background-color: #fff !important;
color: #000;
z-index: 5;
position: relative;
}
@media print {
.btn-group,
#btnSaveTop,
#btnSaveBottom,
.card-header,
.form-label,
.input-group,
.no-print {
display: none !important;
}
.card {
border: none !important;
shadow: none !important;
}
.badge {
border: 1px solid #000;
color: #000 !important;
}
}
</style>

View File

@@ -0,0 +1,153 @@
<style>
table {
width: 100%;
border-collapse: collapse;
font-size: 8pt;
}
th,
td {
border: 1px solid #000;
padding: 4px;
text-align: center;
}
th {
background-color: #eee;
}
.print-title {
text-align: center;
font-size: 14pt;
margin-bottom: 10px;
}
.print-date {
text-align: right;
font-size: 8pt;
margin-bottom: 10px;
}
.text-danger {
color: red;
}
.text-success {
color: green;
}
</style>
<div class="print-title">Concentrado de Pagos de Luz - Cámara -
<?= $year?>
</div>
<div class="print-date">Fecha de generación:
<?= date('d/m/Y H:i')?>
</div>
<table>
<thead>
<tr>
<th width="10%">Casa</th>
<th width="10%">Estado</th>
<?php foreach ($periods as $period):
$config = $electricityBills[$period] ?? [];
$amountPerHouse = $config['amount_per_house'] ?? 0;
?>
<th>
<?= $period?><br><small>$
<?= number_format($amountPerHouse, 2)?>
</small>
</th>
<?php
endforeach; ?>
<th width="15%">Total</th>
</tr>
</thead>
<tbody>
<?php
$grandTotal = 0;
$periodTotals = array_fill_keys($periods, 0);
foreach ($matrix['houses'] as $house):
// Filtrar solo casas permitidas (aunque el controlador ya debió filtrar)
if (!Auth::canViewHouse($house['id']))
continue;
$houseTotal = 0;
?>
<tr>
<td><strong>
<?= $house['number']?>
</strong></td>
<td>
<?= $house['status'] == 'activa' ? 'Activa' : 'Deshabitada'?>
</td>
<?php foreach ($periods as $period):
$payment = $matrix['payments'][$period][$house['id']] ?? null;
$amount = $payment['amount'] ?? 0;
$periodTotals[$period] += $amount;
$houseTotal += $amount;
$config = $electricityBills[$period] ?? [];
$expected = $config['amount_per_house'] ?? 0;
$bg_color = '#FFFFFF';
// Lógica de colores idéntica a la vista web para consistencia
if ($amount > 0) {
if ($expected > 0 && $amount >= $expected) {
$bg_color = '#d4edda'; // Verde (paid)
}
else {
$bg_color = '#fff3cd'; // Amarillo (partial)
}
}
else {
if ($expected > 0) {
$bg_color = '#f8d7da'; // Rojo (pending)
}
elseif ($house['status'] == 'deshabitada') {
$bg_color = '#e2e3e5'; // Gris (inactive)
}
}
?>
<td style="background-color: <?= $bg_color?>;">
<?= $amount > 0 ? '$' . number_format($amount, 2) : '-'?>
</td>
<?php
endforeach; ?>
<td><strong>$
<?= number_format($houseTotal, 2)?>
</strong></td>
</tr>
<?php
$grandTotal += $houseTotal;
endforeach;
?>
<tr style="background-color: #bee5eb;">
<td colspan="2" style="text-align: right; font-weight: bold;">TOTALES:</td>
<?php foreach ($periods as $period): ?>
<td style="text-align: center; font-weight: bold;">
$
<?= number_format($periodTotals[$period], 2)?>
</td>
<?php
endforeach; ?>
<td style="text-align: center; font-weight: bold;">$
<?= number_format($grandTotal, 2)?>
</td>
</tr>
</tbody>
</table>
<div style="margin-top: 20px; font-size: 8pt; page-break-inside: avoid;">
<strong>Leyenda:</strong>
<span style="background-color: #d4edda; padding: 2px 8px; margin: 2px; border: 1px solid #ccc;">Verde =
Pagado</span>
<span style="background-color: #fff3cd; padding: 2px 8px; margin: 2px; border: 1px solid #ccc;">Amarillo =
Parcial</span>
<span style="background-color: #f8d7da; padding: 2px 8px; margin: 2px; border: 1px solid #ccc;">Rojo =
Pendiente</span>
<span style="background-color: #e2e3e5; padding: 2px 8px; margin: 2px; border: 1px solid #ccc;">Gris =
Inactivo</span>
</div>

View File

@@ -70,6 +70,13 @@
<small>Esto actualizará todos los pagos a $0.00</small>
</div>
<?php endif; ?>
<?php if (Auth::isCapturist()): ?>
<div class="mb-3">
<button id="save-all-payments-btn-top" class="btn btn-success" onclick="saveAllConceptPayments()">
<i class="bi bi-check-all"></i> Guardar Todo
</button>
</div>
<?php endif; ?>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead>
@@ -78,9 +85,6 @@
<th>Propietario</th>
<th>Monto</th>
<th>Fecha de Pago</th>
<?php if (Auth::isCapturist()): ?>
<th>Acciones</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
@@ -104,18 +108,18 @@
<?= $payment['payment_date'] ? date('d/m/Y', strtotime($payment['payment_date'])) : '-' ?>
<?php endif; ?>
</td>
<?php if (Auth::isCapturist()): ?>
<td>
<button class="btn btn-sm btn-primary" onclick="saveConceptPayment(this)">
<i class="bi bi-check"></i>
</button>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if (Auth::isCapturist()): ?>
<div class="mt-3">
<button id="save-all-payments-btn-bottom" class="btn btn-success" onclick="saveAllConceptPayments()">
<i class="bi bi-check-all"></i> Guardar Todo
</button>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
@@ -394,12 +398,99 @@ window.saveConceptPayment = function(btn) {
Swal.fire('Error', data.message, 'error');
}
})
.catch(function(error) {
.catch(function(error) {
console.error('Save payment error:', error);
Swal.fire('Error', 'Ocurrió un error: ' + error.message, 'error');
});
};
window.saveAllConceptPayments = function() {
const rows = document.querySelectorAll('tbody tr[data-payment-id]');
const payments = [];
let hasChanges = false;
rows.forEach(function(row) {
const cell = row.querySelector('.payment-cell');
const dateInput = row.querySelector('.payment-date-input');
const houseId = row.dataset.houseId;
const originalAmount = parseFloat(cell.dataset.amount) || 0;
const value = cell.textContent.trim();
const amount = parseFloat(value.replace(/[^0-9.-]+/g, '')) || 0;
const paymentDate = dateInput ? dateInput.value : null;
const originalDate = cell.dataset.paymentDate || '';
// Solo incluir si hay cambios
if (amount !== originalAmount || paymentDate !== originalDate) {
hasChanges = true;
payments.push({
house_id: houseId,
amount: amount,
payment_date: paymentDate
});
}
});
if (!hasChanges) {
Swal.fire('Info', 'No hay cambios para guardar', 'info');
return;
}
// Deshabilitar botones mientras se guarda
const btnTop = document.getElementById('save-all-payments-btn-top');
const btnBottom = document.getElementById('save-all-payments-btn-bottom');
if (btnTop) {
btnTop.disabled = true;
btnTop.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Guardando...';
}
if (btnBottom) {
btnBottom.disabled = true;
btnBottom.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Guardando...';
}
fetch('/dashboard.php?page=concept_view_actions&action=save_all_concept_payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
concept_id: '<?= $concept["id"] ?>',
payments: payments
})
})
.then(function(r) {
return r.json();
})
.then(function(data) {
if (data.success) {
Swal.fire('Éxito', data.message || 'Pagos guardados correctamente', 'success').then(function() {
location.reload();
});
} else {
Swal.fire('Error', data.message, 'error');
// Rehabilitar botones
if (btnTop) {
btnTop.disabled = false;
btnTop.innerHTML = '<i class="bi bi-check-all"></i> Guardar Todo';
}
if (btnBottom) {
btnBottom.disabled = false;
btnBottom.innerHTML = '<i class="bi bi-check-all"></i> Guardar Todo';
}
}
})
.catch(function(error) {
console.error('Save all payments error:', error);
Swal.fire('Error', 'Ocurrió un error: ' + error.message, 'error');
// Rehabilitar botones
if (btnTop) {
btnTop.disabled = false;
btnTop.innerHTML = '<i class="bi bi-check-all"></i> Guardar Todo';
}
if (btnBottom) {
btnBottom.disabled = false;
btnBottom.innerHTML = '<i class="bi bi-check-all"></i> Guardar Todo';
}
});
};
// Attach event listener to button
const initButton = document.getElementById('init-payments-btn');
if (initButton) {

View File

@@ -1,17 +1,19 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IBIZA CEA - Sistema de Gestión</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🏠</text></svg>">
<link href="<?= SITE_URL ?>/assets/css/theme.css" rel="stylesheet">
<link rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🏠</text></svg>">
<link href="<?= SITE_URL?>/assets/css/theme.css" rel="stylesheet">
<script>
// Prevenir FOUC (Flash of Unstyled Content)
(function() {
(function () {
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark-mode');
@@ -19,8 +21,9 @@
})();
</script>
</head>
<body>
<?php if (Auth::check()): ?>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary sticky-top">
@@ -34,42 +37,54 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link <?= $page == 'dashboard' ? 'active' : '' ?>" href="/dashboard.php?page=dashboard">
<a class="nav-link <?= $page == 'dashboard' ? 'active' : ''?>"
href="/dashboard.php?page=dashboard">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $page == 'pagos' ? 'active' : '' ?>" href="/dashboard.php?page=pagos">
<a class="nav-link <?= $page == 'pagos' ? 'active' : ''?>" href="/dashboard.php?page=pagos">
<i class="bi bi-droplet-fill"></i> Pagos de Agua
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $page == 'casas' ? 'active' : '' ?>" href="/dashboard.php?page=casas">
<a class="nav-link <?= $page == 'luz_camara' ? 'active' : ''?>"
href="/dashboard.php?page=luz_camara">
<i class="bi bi-lightbulb-fill"></i> Luz Cámara
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $page == 'casas' ? 'active' : ''?>" href="/dashboard.php?page=casas">
<i class="bi bi-building"></i> Casas
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $page == 'finanzas' ? 'active' : '' ?>" href="/dashboard.php?page=finanzas">
<a class="nav-link <?= $page == 'finanzas' ? 'active' : ''?>"
href="/dashboard.php?page=finanzas">
<i class="bi bi-cash-coin"></i> Finanzas
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $page == 'graficos' ? 'active' : '' ?>" href="/dashboard.php?page=graficos">
<i class="bi bi-bar-chart-line-fill"></i> Gráficos
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $page == 'reportes' ? 'active' : '' ?>" href="/dashboard.php?page=reportes">
<i class="bi bi-file-earmark-bar-graph"></i> Reportes
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $page == 'graficos' ? 'active' : ''?>"
href="/dashboard.php?page=graficos">
<i class="bi bi-bar-chart-line-fill"></i> Gráficos
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $page == 'reportes' ? 'active' : ''?>"
href="/dashboard.php?page=reportes">
<i class="bi bi-file-earmark-bar-graph"></i> Reportes
</a>
</li>
<?php if (Auth::isAdmin()): ?>
<li class="nav-item">
<a class="nav-link <?= $page == 'importar' ? 'active' : '' ?>" href="/dashboard.php?page=importar">
<a class="nav-link <?= $page == 'importar' ? 'active' : ''?>"
href="/dashboard.php?page=importar">
<i class="bi bi-file-earmark-arrow-up"></i> Importar
</a>
</li>
<?php endif; ?>
<?php
endif; ?>
</ul>
<ul class="navbar-nav">
<li class="nav-item d-flex align-items-center">
@@ -78,54 +93,80 @@
</button>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i>
<?= htmlspecialchars(Auth::user()['first_name'] ?? 'Usuario') ?>
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button"
data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i>
<?= htmlspecialchars(Auth::user()['first_name'] ?? 'Usuario')?>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<?php if (Auth::isAdmin()): ?>
<li><a class="dropdown-item" href="/dashboard.php?page=usuarios"><i class="bi bi-people"></i> Usuarios</a></li>
<li><a class="dropdown-item" href="/dashboard.php?page=configurar"><i class="bi bi-gear"></i> Configurar</a></li>
<li><hr class="dropdown-divider"></li>
<?php endif; ?>
<li><a class="dropdown-item" href="/dashboard.php?page=usuarios"><i
class="bi bi-people"></i> Usuarios</a></li>
<li><a class="dropdown-item" href="/dashboard.php?page=configurar"><i
class="bi bi-gear"></i> Configurar</a></li>
<li>
<hr class="dropdown-divider">
</li>
<?php
endif; ?>
<?php if (Auth::isAdmin()): ?>
<li><span class="dropdown-item text-muted small"><i class="bi bi-database"></i> DB: <?= DB_NAME ?></span></li>
<li><hr class="dropdown-divider"></li>
<?php endif; ?>
<li><a class="dropdown-item" href="/dashboard.php?page=profile"><i class="bi bi-person"></i> Perfil</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="/logout.php"><i class="bi bi-box-arrow-right"></i> Cerrar Sesión</a></li>
<li><span class="dropdown-item text-muted small"><i class="bi bi-server"></i>
<?= DB_HOST?>:
<?= DB_PORT?>
</span></li>
<li><span class="dropdown-item text-muted small"><i class="bi bi-database"></i> DB:
<?= DB_NAME?>
</span></li>
<li>
<hr class="dropdown-divider">
</li>
<?php
endif; ?>
<li><a class="dropdown-item" href="/dashboard.php?page=profile"><i class="bi bi-person"></i>
Perfil</a></li>
<li>
<hr class="dropdown-divider">
</li>
<li><a class="dropdown-item text-danger" href="/logout.php"><i
class="bi bi-box-arrow-right"></i> Cerrar Sesión</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<?php endif; ?>
<?php
endif; ?>
<div class="container-fluid py-4">
<?php
$viewPath = __DIR__ . '/../' . $view . '.php';
if (isset($view) && file_exists($viewPath)):
?>
<?php include $viewPath; ?>
<?php else: ?>
<div class="alert alert-danger">
Vista no encontrada: <?= htmlspecialchars($view ?? '') ?><br>
Ruta: <?= htmlspecialchars($viewPath ?? '') ?><br>
Existe: <?= isset($view) && file_exists($viewPath) ? 'Sí' : 'No' ?>
</div>
<?php endif; ?>
<?php
$viewPath = __DIR__ . '/../' . $view . '.php';
if (isset($view) && file_exists($viewPath)):
?>
<?php include $viewPath; ?>
<?php
else: ?>
<div class="alert alert-danger">
Vista no encontrada:
<?= htmlspecialchars($view ?? '')?><br>
Ruta:
<?= htmlspecialchars($viewPath ?? '')?><br>
Existe:
<?= isset($view) && file_exists($viewPath) ? 'Sí' : 'No'?>
</div>
<?php
endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="<?= SITE_URL ?>/assets/js/theme.js"></script>
<script src="<?= SITE_URL?>/assets/js/theme.js"></script>
<footer class="footer mt-auto py-3">
<div class="container-fluid text-center">
<span class="text-muted">Condominio IBIZA - Derechos reservados Miguel Pons casa 11</span>
</div>
</footer>
</body>
</html>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,7 @@
foreach ($houses as $house):
$total = 0;
$totalExpected = 0;
$totalExpectedOriginal = 0;
?>
<tr>
<td><strong><?= $house['number'] ?></strong></td>
@@ -68,8 +69,10 @@
$monthTotals[$month] += $amount;
$expected = Payment::getExpectedAmount($house, $year, $month);
$expectedOriginal = Payment::getExpectedAmountWithDiscount($house, $year, $month);
$total += $amount;
$totalExpected += $expected;
$totalExpectedOriginal += $expectedOriginal;
$bg_color = '#FFFFFF'; // Default white
if ($house['status'] == 'deshabitada') {
@@ -89,7 +92,7 @@
</td>
<?php endforeach; ?>
<?php
$difference = $total - $totalExpected;
$difference = $total - $totalExpectedOriginal;
$diffColor = $difference < 0 ? 'red' : 'green';
$diffText = $difference == 0 ? '$0.00' : '$' . number_format($difference, 2);
$grandTotal += $total;

View File

@@ -19,6 +19,9 @@
<a href="/dashboard.php?page=reportes&type=concepts" class="btn btn-outline-info <?= ($_GET['type'] ?? '') == 'concepts' ? 'active' : '' ?>">
<i class="bi bi-collection"></i> Conceptos Especiales
</a>
<a href="/dashboard.php?page=reportes&type=electricity-debtors" class="btn btn-outline-warning <?= ($_GET['type'] ?? '') == 'electricity-debtors' ? 'active' : '' ?>">
<i class="bi bi-lightbulb-fill"></i> Deudores Luz
</a>
</div>
</div>
@@ -423,7 +426,7 @@ function exportConceptDebtorsPDF() {
});
}
console.log('DEBUG - Final URL:', url);
window.open(url, '_blank');
}
@@ -745,6 +748,233 @@ function exportConceptsCSV() {
}
</script>
</script>
<?php elseif ($reportType == 'electricity-debtors' && isset($electricityDebtors)): ?>
<?php
$hasFilters = !empty($electricityDebtors['filters']['year']) || !empty($electricityDebtors['filters']['periods']) || !empty($electricityDebtors['filters']['house_id']);
$filterText = [];
if (!empty($electricityDebtors['filters']['year'])) {
$filterText[] = "Año: " . $electricityDebtors['filters']['year'];
}
if (!empty($electricityDebtors['filters']['periods'])) {
$filterText[] = "Periodos: " . implode(', ', $electricityDebtors['filters']['periods']);
}
if (!empty($electricityDebtors['filters']['house_id'])) {
require_once __DIR__ . '/../../models/House.php';
$house = House::findById($electricityDebtors['filters']['house_id']);
$filterText[] = "Casa: " . ($house['number'] ?? 'N/A');
}
?>
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-funnel"></i> Filtros de Deudores de Luz
<?php if ($hasFilters): ?>
<span class="badge bg-warning text-dark ms-2"><?= implode(' | ', $filterText) ?></span>
<?php endif; ?>
</h5>
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="collapse" data-bs-target="#filtersCollapse">
<i class="bi bi-chevron-down"></i>
</button>
</div>
<div class="collapse <?php echo $hasFilters ? '' : 'show'; ?>" id="filtersCollapse">
<div class="card-body">
<form id="electricityDebtorsFilter">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Año</label>
<select name="filter_year" class="form-select">
<option value="">Todos los años</option>
<?php for ($y = 2024; $y <= date('Y') + 1; $y++): ?>
<option value="<?= $y ?>" <?= ($_GET['filter_year'] ?? '') == $y ? 'selected' : '' ?>><?= $y ?></option>
<?php endfor; ?>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Casa</label>
<select name="filter_house" class="form-select">
<option value="">Todas las casas</option>
<?php
require_once __DIR__ . '/../../models/House.php';
$allHouses = House::getAccessible();
foreach ($allHouses as $h): ?>
<option value="<?= $h['id'] ?>" <?= ($_GET['filter_house'] ?? '') == $h['id'] ? 'selected' : '' ?>><?= $h['number'] ?> - <?= htmlspecialchars($h['owner_name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Periodos</label>
<div class="d-flex flex-wrap gap-2">
<?php
require_once __DIR__ . '/../../models/ElectricityBill.php';
$allPeriods = ElectricityBill::getPeriods();
$selectedPeriods = explode(',', $_GET['filter_periods'] ?? '');
foreach ($allPeriods as $p): ?>
<div class="form-check">
<input type="checkbox" name="filter_periods[]" value="<?= $p ?>"
class="form-check-input period-checkbox"
id="period_<?= str_replace(' ', '', $p) ?>"
<?= in_array($p, $selectedPeriods) ? 'checked' : '' ?>>
<label class="form-check-label" for="period_<?= str_replace(' ', '', $p) ?>"><?= $p ?></label>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i> Aplicar Filtros
</button>
<a href="/dashboard.php?page=reportes&type=electricity-debtors" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Limpiar Filtros
</a>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
document.getElementById('electricityDebtorsFilter').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const params = new URLSearchParams();
if (formData.get('filter_year')) {
params.append('filter_year', formData.get('filter_year'));
}
if (formData.get('filter_house')) {
params.append('filter_house', formData.get('filter_house'));
}
const selectedPeriods = formData.getAll('filter_periods[]');
if (selectedPeriods.length > 0) {
params.append('filter_periods', selectedPeriods.join(','));
}
window.location.href = '/dashboard.php?page=reportes&type=electricity-debtors&' + params.toString();
});
</script>
<div class="row g-4 mb-4">
<div class="col-md-4">
<div class="card border-warning">
<div class="card-body">
<h6 class="text-muted">Total Adeudado (Luz)</h6>
<h3 class="text-warning">$<?= number_format($electricityDebtors['total_due'], 2) ?></h3>
<small class="text-muted">Total general de deudas</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-info">
<div class="card-body">
<h6 class="text-muted">Total Esperado</h6>
<h3 class="text-info">$<?= number_format($electricityDebtors['total_expected'], 2) ?></h3>
<small class="text-muted">Total configurado</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-success">
<div class="card-body">
<h6 class="text-muted">Total Pagado</h6>
<h3 class="text-success">$<?= number_format($electricityDebtors['total_paid'], 2) ?></h3>
<small class="text-muted">Total recaudado</small>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0"><i class="bi bi-exclamation-triangle"></i> Deudores de Luz - Cámara</h5>
<button onclick="exportElectricityDebtorsPDF()" class="btn btn-outline-warning btn-sm">
<i class="bi bi-file-earmark-pdf"></i> Exportar PDF
</button>
</div>
<div class="card-body">
<?php if (empty($electricityDebtors['debtors'])): ?>
<p class="text-muted">No hay deudores registrados</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-warning">
<tr>
<th>Casa</th>
<th>Propietario</th>
<th>Periodos Adeudados</th>
<th>Total Debe</th>
</tr>
</thead>
<tbody>
<?php foreach ($electricityDebtors['debtors'] as $debtor): ?>
<tr>
<td><strong><?= $debtor['house_number'] ?></strong></td>
<td><?= htmlspecialchars($debtor['owner_name'] ?? '-') ?></td>
<td>
<table class="table table-sm mb-0">
<?php foreach ($debtor['periods_due'] as $period): ?>
<tr>
<td><?= $period['year'] ?> - <?= $period['period'] ?></td>
<td class="text-end">$<?= number_format($period['due'], 2) ?></td>
</tr>
<?php endforeach; ?>
</table>
</td>
<td class="text-end fw-bold text-danger">$<?= number_format($debtor['total_due'], 2) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot class="table-dark">
<tr>
<th colspan="3" class="text-end">TOTAL GENERAL:</th>
<th class="text-end">$<?= number_format($electricityDebtors['total_due'], 2) ?></th>
</tr>
</tfoot>
</table>
</div>
<?php endif; ?>
</div>
</div>
<script>
function exportElectricityDebtorsPDF() {
const form = document.getElementById('electricityDebtorsFilter');
const params = new URLSearchParams();
params.append('action', 'export_pdf_report');
params.append('type', 'electricity-debtors');
if (form) {
const formData = new FormData(form);
if (formData.get('filter_year')) {
params.append('filter_year', formData.get('filter_year'));
}
if (formData.get('filter_house')) {
params.append('filter_house', formData.get('filter_house'));
}
const selectedPeriods = formData.getAll('filter_periods[]');
if (selectedPeriods.length > 0) {
params.append('filter_periods', selectedPeriods.join(','));
}
} else {
// Fallback si no hay formulario (por ejemplo si se ocultó), usar parámetros de URL actual
const currentParams = new URLSearchParams(window.location.search);
if (currentParams.get('filter_year')) params.append('filter_year', currentParams.get('filter_year'));
if (currentParams.get('filter_house')) params.append('filter_house', currentParams.get('filter_house'));
if (currentParams.get('filter_periods')) params.append('filter_periods', currentParams.get('filter_periods'));
}
window.open('/dashboard.php?page=reportes_actions&' + params.toString(), '_blank');
}
</script>
<?php else: ?>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">

View File

@@ -0,0 +1,123 @@
<style>
table {
width: 100%;
border-collapse: collapse;
font-size: 8pt;
}
th,
td {
border: 1px solid #000;
padding: 4px;
text-align: center;
}
th {
background-color: #eee;
}
.print-title {
text-align: center;
font-size: 14pt;
margin-bottom: 10px;
}
.print-date {
text-align: right;
font-size: 8pt;
margin-bottom: 10px;
}
.text-danger {
color: red;
}
.text-warning {
color: orange;
}
</style>
<div class="print-title">Condominio IBIZA-Cto Sierra Morena 152 - Reporte de Deudores de Luz - Energía Cámara</div>
<div class="print-date">Fecha de generación:
<?= date('d/m/Y H:i')?>
</div>
<?php
$hasFilters = !empty($electricityDebtors['filters']['year']) || !empty($electricityDebtors['filters']['periods']) || !empty($electricityDebtors['filters']['house_id']);
$filterText = [];
if (!empty($electricityDebtors['filters']['year'])) {
$filterText[] = "Año: " . $electricityDebtors['filters']['year'];
}
if (!empty($electricityDebtors['filters']['periods'])) {
$filterText[] = "Periodos: " . implode(', ', $electricityDebtors['filters']['periods']);
}
if (!empty($electricityDebtors['filters']['house_id'])) {
require_once __DIR__ . '/../../models/House.php';
$house = House::findById($electricityDebtors['filters']['house_id']);
$filterText[] = "Casa: " . ($house['number'] ?? 'N/A');
}
if ($hasFilters):
?>
<div style="font-size: 9pt; margin-bottom: 10px;">
<strong>Filtros aplicados:</strong>
<?= implode(' | ', $filterText)?>
</div>
<?php
endif; ?>
<?php if (empty($electricityDebtors['debtors'])): ?>
<p>No hay deudores de luz registrados con los filtros actuales.</p>
<?php
else: ?>
<table>
<thead>
<tr style="background-color: #ffc107;">
<th>Casa</th>
<th>Propietario</th>
<th>Periodos Adeudados</th>
<th>Total Debe</th>
</tr>
</thead>
<tbody>
<?php foreach ($electricityDebtors['debtors'] as $debtor): ?>
<tr>
<td><strong>
<?= $debtor['house_number']?>
</strong></td>
<td>
<?= htmlspecialchars($debtor['owner_name'] ?? '-')?>
</td>
<td>
<table style="width:100%; border: none;">
<?php foreach ($debtor['periods_due'] as $period): ?>
<tr>
<td style="border: none; text-align: left;">
<?= $period['year']?> -
<?= $period['period']?>
</td>
<td style="border: none; text-align: right;">$
<?= number_format($period['due'], 2)?>
</td>
</tr>
<?php
endforeach; ?>
</table>
</td>
<td class="text-end fw-bold text-danger">$
<?= number_format($debtor['total_due'], 2)?>
</td>
</tr>
<?php
endforeach; ?>
</tbody>
<tfoot>
<tr style="background-color: #343a40; color: #fff;">
<th colspan="3" style="text-align: right;">TOTAL GENERAL:</th>
<th style="text-align: right;">$
<?= number_format($electricityDebtors['total_due'], 2)?>
</th>
</tr>
</tfoot>
</table>
<?php
endif; ?>