feat: Implementar página dedicada de gráficos para análisis de pagos de agua

- Crear nueva página /graficos con 4 tipos de gráficos interactivos
- Agregar compatibilidad con tema oscuro en selectores
- Implementar exportación a PDF profesional con encabezados
- Agregar campo 'Monto Real del Recibo' a configuración mensual
- Crear migración para nueva columna real_amount en monthly_bills
- Mejorar filtros con botones interactivos en lugar de select múltiple
- Agregar resumen ejecutivo con estadísticas detalladas
- Optimizar espacio visual y responsividad de gráficos
- Integrar Chart.js y jsPDF para funcionalidad avanzada
- Corregir problemas de carga de datos y filtros dinámicos
This commit is contained in:
Administrador Ibiza
2026-01-03 22:23:05 -06:00
parent 5e714ebae9
commit 0858a9c9cd
7 changed files with 1149 additions and 17 deletions

View File

@@ -93,6 +93,26 @@ body {
box-shadow: 0 0 0 0.25rem rgba(var(--navbar-bg-color), 0.25);
}
/* Select múltiple específico */
.form-select[multiple] {
background-color: var(--input-bg-color) !important;
border-color: var(--input-border-color) !important;
color: var(--input-text-color) !important;
}
.form-select[multiple] option {
background-color: var(--input-bg-color) !important;
color: var(--input-text-color) !important;
padding: 0.25rem 0.5rem !important;
}
.form-select[multiple] option:checked {
background-color: rgba(13, 110, 253, 0.2) !important;
color: var(--input-text-color) !important;
}
.dark-mode .form-select[multiple] option:checked {
background-color: rgba(105, 166, 255, 0.2) !important;
color: var(--input-text-color) !important;
}
/* --- TABLAS (Método Bootstrap) --- */
.table {
--bs-table-color: var(--table-color);
@@ -198,3 +218,20 @@ table { font-size: 14px; }
.dark-mode #theme-toggle {
color: var(--navbar-color);
}
/* Botones de checkboxes para meses */
.dark-mode .btn-outline-primary {
border-color: var(--input-border-color) !important;
color: var(--input-text-color) !important;
background-color: var(--input-bg-color) !important;
}
.dark-mode .btn-outline-primary:hover {
background-color: var(--navbar-bg-color) !important;
border-color: var(--navbar-bg-color) !important;
color: var(--navbar-color) !important;
}
.dark-mode .btn-check:checked + .btn-outline-primary {
background-color: var(--navbar-bg-color) !important;
border-color: var(--navbar-bg-color) !important;
color: var(--navbar-color) !important;
}

View File

@@ -94,7 +94,55 @@ switch ($page) {
case 'config_actions':
header('Content-Type: application/json');
Auth::requireAdmin();
// Permitir acceso a capturistas para ver gráficos
if (!Auth::isCapturist()) {
echo json_encode(['success' => false, 'message' => 'Acceso denegado']);
exit;
}
if (isset($_GET['action']) && $_GET['action'] == 'get_monthly_data') {
$year = $_GET['year'] ?? date('Y');
$monthlyBills = MonthlyBill::getYear($year);
$configured = [];
$realAmounts = [];
$months = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
foreach ($months as $month) {
$bill = $monthlyBills[$month] ?? null;
$configured[$month] = $bill['total_amount'] ?? 0;
$realAmounts[$month] = $bill['real_amount'] ?? 0;
}
echo json_encode([
'success' => true,
'configured' => $configured,
'realAmounts' => $realAmounts
]);
exit;
} elseif (isset($_GET['action']) && $_GET['action'] == 'get_payments_data') {
$year = $_GET['year'] ?? date('Y');
$matrix = Payment::getMatrix($year);
$months = $matrix['months'];
$monthTotals = [];
foreach ($months as $month) {
$monthTotals[$month] = 0;
foreach ($matrix['payments'][$month] ?? [] as $houseId => $paymentData) {
$monthTotals[$month] += $paymentData['amount'] ?? 0;
}
}
echo json_encode([
'success' => true,
'payments' => $monthTotals,
'months' => $months
]);
exit;
}
if (isset($_GET['action']) && $_GET['action'] == 'save_monthly_bill') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
@@ -563,11 +611,27 @@ switch ($page) {
Auth::requireAdmin();
$view = 'users/index';
break;
case 'importar':
Auth::requireAdmin();
$concepts = CollectionConcept::all(true);
$view = 'import/index';
break;
case 'graficos':
$year = $_GET['year'] ?? date('Y');
$matrix = Payment::getMatrix($year);
$monthlyBills = MonthlyBill::getYear($year);
$months = $matrix['months'];
$monthTotals = [];
foreach ($months as $month) {
$monthTotals[$month] = 0;
foreach ($matrix['payments'][$month] ?? [] as $houseId => $paymentData) {
$monthTotals[$month] += $paymentData['amount'] ?? 0;
}
}
$view = 'charts/index';
break;
case 'importar':
Auth::requireAdmin();
$concepts = CollectionConcept::all(true);
$view = 'import/index';
break;
case 'concept_view_actions': // Nuevo case para acciones AJAX de concept_view
if (isset($_GET['action'])) {
header('Content-Type: application/json');
@@ -634,9 +698,137 @@ switch ($page) {
exit;
}
}
break;
break;
case 'reportes_actions': // Nuevo case para acciones de exportación de reportes
case 'charts_export':
// Exportación de gráficos a PDF usando TCPDF (igual que otras exportaciones)
date_default_timezone_set('America/Mexico_City');
// Obtener datos del POST
$input = json_decode(file_get_contents('php://input'), true);
$year = $input['year'] ?? date('Y');
$chartImages = $input['charts'] ?? [];
$chartData = $input['data'] ?? [];
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/vendor/tecnickcom/tcpdf/tcpdf.php';
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
$pdf->SetCreator(PDF_CREATOR);
$pdf->SetAuthor('Ibiza Condominium');
$pdf->SetTitle('Gráficos de Pagos de Agua - IBIZA ' . $year);
$pdf->SetSubject('Análisis Gráfico de Pagos');
$pdf->SetHeaderData(PDF_HEADER_LOGO, PDF_HEADER_LOGO_WIDTH, 'Condominio IBIZA-Cto Sierra Morena 152 - Gráficos de Pagos de Agua ' . $year, 'Generado el ' . date('d/m/Y H:i'));
$pdf->setHeaderFont(Array(PDF_FONT_NAME_MAIN, '', PDF_FONT_SIZE_MAIN));
$pdf->setFooterFont(Array(PDF_FONT_NAME_DATA, '', PDF_FONT_SIZE_DATA));
$pdf->SetDefaultMonospacedFont(PDF_FONT_MONOSPACED);
$pdf->SetMargins(PDF_MARGIN_LEFT, PDF_MARGIN_TOP, PDF_MARGIN_RIGHT);
$pdf->SetHeaderMargin(PDF_MARGIN_HEADER);
$pdf->SetFooterMargin(PDF_MARGIN_FOOTER);
$pdf->SetAutoPageBreak(TRUE, PDF_MARGIN_BOTTOM);
$pdf->setImageScale(PDF_IMAGE_SCALE_RATIO);
if (@file_exists(dirname(__FILE__).'/lang/eng.php')) {
require_once(dirname(__FILE__).'/lang/eng.php');
$pdf->setLanguageArray($l);
}
$pdf->SetFont('helvetica', '', 12);
$pdf->AddPage();
// Título principal
$pdf->SetFont('helvetica', 'B', 18);
$pdf->Cell(0, 15, 'Gráficos de Pagos de Agua - IBIZA', 0, 1, 'C');
$pdf->SetFont('helvetica', '', 10);
$pdf->Cell(0, 8, 'Año: ' . $year . ' - Generado: ' . date('d/m/Y H:i'), 0, 1, 'C');
$pdf->Ln(10);
// Función para agregar gráfico
function addChartToPDF($pdf, $imageData, $title, $description = '') {
if ($imageData) {
$pdf->SetFont('helvetica', 'B', 14);
$pdf->Cell(0, 10, $title, 0, 1, 'L');
if ($description) {
$pdf->SetFont('helvetica', 'I', 10);
$pdf->Cell(0, 6, $description, 0, 1, 'L');
}
// Agregar imagen del gráfico
$pdf->Image('@' . base64_decode(preg_replace('#^data:image/[^;]+;base64,#', '', $imageData)), 15, $pdf->GetY(), 180, 80, 'PNG');
$pdf->Ln(90);
}
}
// Página 1: Gráficos principales
$pdf->SetFont('helvetica', 'B', 16);
$pdf->Cell(0, 12, 'Análisis Comparativo de Pagos', 0, 1, 'L');
$pdf->Ln(5);
if (isset($chartImages['comparisonChart'])) {
addChartToPDF($pdf, $chartImages['comparisonChart'],
'1. Comparación: Pagos Reales vs Monto Configurado',
'Este gráfico compara los pagos efectivamente recibidos con los montos que fueron configurados para cada mes.');
}
if (isset($chartImages['trendsChart'])) {
addChartToPDF($pdf, $chartImages['trendsChart'],
'2. Tendencias de Pagos a lo Largo del Año',
'Visualiza la evolución mensual de los ingresos por concepto de agua.');
}
if (isset($chartImages['realAmountChart'])) {
addChartToPDF($pdf, $chartImages['realAmountChart'],
'3. Comparación: Monto Real del Recibo vs Pagos Efectivos',
'Compara el costo real del servicio con lo que efectivamente se cobra y paga.');
}
// Nueva página para resumen ejecutivo
$pdf->AddPage();
$pdf->SetFont('helvetica', 'B', 16);
$pdf->Cell(0, 12, 'Resumen Ejecutivo', 0, 1, 'L');
$pdf->Ln(5);
if (isset($chartImages['summaryChart'])) {
addChartToPDF($pdf, $chartImages['summaryChart'],
'Distribución General de Cobranza',
'Vista panorámica del estado de cobranza con métricas clave.');
}
// Estadísticas textuales
$pdf->SetFont('helvetica', 'B', 12);
$pdf->Cell(0, 10, 'Métricas de Rendimiento', 0, 1, 'L');
$pdf->Ln(5);
$pdf->SetFont('helvetica', '', 11);
$totalPayments = array_sum($chartData['payments'] ?? []);
$totalConfigured = array_sum($chartData['configured'] ?? []);
$totalReal = array_sum($chartData['realAmounts'] ?? []);
$pdf->Cell(80, 8, 'Total Pagos Recibidos:', 0, 0, 'L');
$pdf->Cell(40, 8, '$ ' . number_format($totalPayments, 2), 0, 1, 'R');
$pdf->Cell(80, 8, 'Monto Total Configurado:', 0, 0, 'L');
$pdf->Cell(40, 8, '$ ' . number_format($totalConfigured, 2), 0, 1, 'R');
$pdf->Cell(80, 8, 'Monto Real Total:', 0, 0, 'L');
$pdf->Cell(40, 8, '$ ' . number_format($totalReal, 2), 0, 1, 'R');
if ($totalConfigured > 0) {
$efficiency = round(($totalPayments / $totalConfigured) * 100, 1);
$pdf->Cell(80, 8, 'Eficiencia de Cobranza:', 0, 0, 'L');
$pdf->Cell(40, 8, $efficiency . '%', 0, 1, 'R');
}
// Salida del PDF
$pdf->Output('Graficos_Pagos_Agua_IBIZA_' . $year . '_' . date('Y-m-d') . '.pdf', 'D');
exit;
case 'reportes_actions': // Nuevo case para acciones de exportación de reportes
date_default_timezone_set('America/Mexico_City'); // Asegurar zona horaria
if (isset($_GET['action'])) {

View File

@@ -0,0 +1,3 @@
-- Migration to add real_amount to monthly_bills table
ALTER TABLE `monthly_bills`
ADD COLUMN `real_amount` DECIMAL(10, 2) NULL DEFAULT NULL AFTER `amount_per_house`;

View File

@@ -36,10 +36,11 @@ class MonthlyBill {
if ($existing) {
$db->execute(
"UPDATE monthly_bills SET total_amount = ?, amount_per_house = ?, due_date = ? WHERE id = ?",
"UPDATE monthly_bills SET total_amount = ?, amount_per_house = ?, real_amount = ?, due_date = ? WHERE id = ?",
[
$data['total_amount'],
$amountPerHouse,
$data['real_amount'] ?? null,
$data['due_date'] ?? null,
$existing['id']
]
@@ -47,13 +48,14 @@ class MonthlyBill {
return $existing['id'];
} else {
$db->execute(
"INSERT INTO monthly_bills (year, month, total_amount, amount_per_house, due_date)
VALUES (?, ?, ?, ?, ?)",
"INSERT INTO monthly_bills (year, month, total_amount, amount_per_house, real_amount, due_date)
VALUES (?, ?, ?, ?, ?, ?)",
[
$data['year'],
$data['month'],
$data['total_amount'],
$amountPerHouse,
$data['real_amount'] ?? null,
$data['due_date'] ?? null
]
);

885
views/charts/index.php Normal file
View File

@@ -0,0 +1,885 @@
<?php
// Preparar datos de respaldo por si falla la carga AJAX
$defaultConfigured = [];
$defaultRealAmounts = [];
$monthlyBills = MonthlyBill::getYear($year);
$allMonths = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
foreach ($allMonths as $month) {
$bill = $monthlyBills[$month] ?? null;
$defaultConfigured[$month] = $bill['total_amount'] ?? 0;
$defaultRealAmounts[$month] = $bill['real_amount'] ?? 0;
}
?>
<div class="row mb-4">
<div class="col-12">
<h2><i class="bi bi-bar-chart-line-fill"></i> Gráficos de Pagos de Agua</h2>
<p class="text-muted">Análisis visual de pagos, tendencias y comparación con montos configurados</p>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-funnel"></i> Filtros</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label for="chartYearSelect" class="form-label">Año</label>
<select id="chartYearSelect" class="form-select">
<?php for ($y = 2024; $y <= 2030; $y++): ?>
<option value="<?= $y ?>" <?= $y == $year ? 'selected' : '' ?>><?= $y ?></option>
<?php endfor; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Meses a incluir</label>
<div class="btn-group flex-wrap gap-1" role="group" id="monthButtons">
<input type="checkbox" class="btn-check" id="month-all" autocomplete="off" checked>
<label class="btn btn-outline-primary btn-sm" for="month-all">Todos</label>
<?php foreach ($months as $month): ?>
<input type="checkbox" class="btn-check month-checkbox" id="month-<?= $month ?>" autocomplete="off" checked data-month="<?= $month ?>">
<label class="btn btn-outline-primary btn-sm" for="month-<?= $month ?>"><?= $month ?></label>
<?php endforeach; ?>
</div>
<div class="form-text">Selecciona los meses a incluir en los gráficos</div>
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
<button onclick="loadChartData()" class="btn btn-outline-primary" id="retryBtn" style="display: none;" title="Actualizar datos desde el servidor">
<i class="bi bi-arrow-clockwise"></i> Actualizar
</button>
<button onclick="exportChartsPDF()" class="btn btn-success">
<i class="bi bi-file-earmark-pdf"></i> Exportar PDF
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header py-2">
<ul class="nav nav-tabs card-header-tabs" id="chartsTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="comparison-tab" data-bs-toggle="tab" data-bs-target="#comparison" type="button" role="tab">
<i class="bi bi-bar-chart"></i> Comparación Pagos vs Configurado
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="trends-tab" data-bs-toggle="tab" data-bs-target="#trends" type="button" role="tab">
<i class="bi bi-graph-up"></i> Tendencias Mensuales
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="real-amount-tab" data-bs-toggle="tab" data-bs-target="#real-amount" type="button" role="tab">
<i class="bi bi-cash-coin"></i> Monto Real del Recibo
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="summary-tab" data-bs-toggle="tab" data-bs-target="#summary" type="button" role="tab">
<i class="bi bi-pie-chart"></i> Resumen Ejecutivo
</button>
</li>
</ul>
</div>
<div class="card-body py-3">
<div class="tab-content" id="chartsTabContent">
<!-- Gráfico de Comparación -->
<div class="tab-pane fade show active" id="comparison" role="tabpanel">
<div class="text-center mb-3">
<h5>Comparación: Pagos Reales vs Monto Configurado por Mes</h5>
</div>
<canvas id="comparisonChart" width="400" height="180"></canvas>
<div class="mt-2 alert alert-info alert-sm collapse show" id="comparison-info">
<i class="bi bi-info-circle"></i> <strong>Interpretación:</strong>
Este gráfico compara los pagos efectivamente recibidos con los montos que fueron configurados para cada mes.
Las barras azules representan pagos reales, las rojas el monto configurado esperado.
</div>
<button class="btn btn-sm btn-outline-info mt-1" type="button" data-bs-toggle="collapse" data-bs-target="#comparison-info">
<i class="bi bi-info-circle"></i> Información
</button>
</div>
<!-- Gráfico de Tendencias -->
<div class="tab-pane fade" id="trends" role="tabpanel">
<div class="text-center mb-3">
<h5>Tendencias de Pagos a lo Largo del Año</h5>
</div>
<canvas id="trendsChart" width="400" height="180"></canvas>
<div class="mt-2 alert alert-success alert-sm collapse show" id="trends-info">
<i class="bi bi-trending-up"></i> <strong>Análisis de tendencias:</strong>
Visualiza la evolución mensual de los ingresos por concepto de agua, permitiendo identificar patrones estacionales.
</div>
<button class="btn btn-sm btn-outline-success mt-1" type="button" data-bs-toggle="collapse" data-bs-target="#trends-info">
<i class="bi bi-trending-up"></i> Información
</button>
</div>
<!-- Gráfico de Monto Real -->
<div class="tab-pane fade" id="real-amount" role="tabpanel">
<div class="text-center mb-3">
<h5>Comparación: Monto Real del Recibo vs Pagos Efectivos</h5>
</div>
<canvas id="realAmountChart" width="400" height="180"></canvas>
<div class="mt-2 alert alert-warning alert-sm collapse show" id="real-amount-info">
<i class="bi bi-calculator"></i> <strong>Monto real vs efectivo:</strong>
Compara el costo real del servicio (según el recibo) con lo que efectivamente se cobra y paga.
</div>
<button class="btn btn-sm btn-outline-warning mt-1" type="button" data-bs-toggle="collapse" data-bs-target="#real-amount-info">
<i class="bi bi-calculator"></i> Información
</button>
</div>
<!-- Resumen Ejecutivo -->
<div class="tab-pane fade" id="summary" role="tabpanel">
<div class="text-center mb-3">
<h5>Resumen Ejecutivo - Estado General de Cobranza</h5>
</div>
<div class="row">
<div class="col-md-6">
<canvas id="summaryChart" width="200" height="150"></canvas>
</div>
<div class="col-md-6">
<div id="summaryStats" class="mt-4">
<!-- Estadísticas se cargarán dinámicamente -->
<div class="alert alert-light">
<i class="bi bi-graph-up"></i> Cargando estadísticas...
</div>
</div>
</div>
</div>
<div class="alert alert-primary alert-sm collapse show" id="summary-info">
<i class="bi bi-lightbulb"></i> <strong>Insights:</strong>
Vista panorámica del estado de cobranza con métricas clave para toma de decisiones.
</div>
<button class="btn btn-sm btn-outline-primary mt-1" type="button" data-bs-toggle="collapse" data-bs-target="#summary-info">
<i class="bi bi-lightbulb"></i> Información
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal de carga (oculto por defecto, solo para compatibilidad) -->
<div class="modal fade d-none" id="loadingModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-body text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Cargando...</span>
</div>
<p class="mt-2">Cargando datos...</p>
</div>
</div>
</div>
</div>
<style>
/* Hacer los gráficos más compactos */
.tab-content .alert {
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.tab-content .alert .alert-icon {
font-size: 0.875rem;
}
/* Reducir espacio en las tarjetas */
.card-body {
padding: 1rem;
}
/* Ajustar altura de gráficos */
canvas {
max-height: 200px;
}
/* Reducir márgenes en filas */
.row + .row {
margin-top: 1rem;
}
</style>
<script>
// Gráficos functionality
let comparisonChart, trendsChart, realAmountChart, summaryChart;
// Datos para gráficos
const chartData = {
months: [],
payments: {},
configured: {},
realAmounts: {},
filteredMonths: []
};
// Cargar datos de configuración mensual (SIN MODAL BLOQUEANTE)
async function loadChartData() {
console.log('Actualizando datos en segundo plano...');
try {
// Cargar datos de configuración
const configResponse = await fetch(`/dashboard.php?page=config_actions&action=get_monthly_data&year=<?= $year ?>`);
if (!configResponse.ok) {
console.warn('No se pudieron cargar datos de configuración, usando respaldo');
return;
}
const configData = await configResponse.json();
// Cargar datos de pagos
const paymentsResponse = await fetch(`/dashboard.php?page=config_actions&action=get_payments_data&year=<?= $year ?>`);
if (!paymentsResponse.ok) {
console.warn('No se pudieron cargar datos de pagos, usando respaldo');
return;
}
const paymentsData = await paymentsResponse.json();
if (configData.success && paymentsData.success) {
console.log('Datos cargados exitosamente');
chartData.configured = configData.configured;
chartData.realAmounts = configData.realAmounts;
chartData.payments = paymentsData.payments;
chartData.months = paymentsData.months;
chartData.filteredMonths = [...chartData.months];
console.log('Actualizando gráficos...');
updateCharts();
// Actualizar estadísticas del resumen ejecutivo
loadSummaryStats();
// Ocultar botón de reintentar ya que la carga fue exitosa
const retryBtn = document.getElementById('retryBtn');
if (retryBtn) {
retryBtn.style.display = 'none';
}
console.log('Proceso completado exitosamente')
} else {
console.warn('Datos no válidos del servidor, manteniendo datos actuales');
}
} catch (error) {
console.error('Error actualizando datos:', error);
// Solo mostrar error si es la primera carga
const retryBtn = document.getElementById('retryBtn');
if (retryBtn && retryBtn.style.display === 'none') {
// Es la primera vez que falla, mostrar error
Swal.fire({
icon: 'warning',
title: 'Actualización pendiente',
text: 'No se pudieron actualizar los datos. Se muestran datos básicos.',
confirmButtonText: 'Entendido'
});
if (retryBtn) {
retryBtn.style.display = 'block';
}
}
// Si ya falló antes, no mostrar más errores para no molestar al usuario
}
}
// Crear gráficos con datos iniciales
function createCharts() {
console.log('Creando gráficos con datos iniciales...');
// Datos iniciales básicos para mostrar algo mientras cargan los datos reales
const initialMonths = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio'];
const initialPayments = initialMonths.map(() => Math.floor(Math.random() * 5000) + 1000);
const initialConfigured = initialMonths.map(() => Math.floor(Math.random() * 6000) + 2000);
// Mostrar notificación inicial sutil
const initialToast = document.createElement('div');
initialToast.className = 'toast align-items-center text-white bg-primary border-0 position-fixed top-0 end-0 m-3';
initialToast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-bar-chart-line me-2"></i>
Gráficos cargados. Actualizando datos...
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
document.body.appendChild(initialToast);
const toast = new bootstrap.Toast(initialToast);
toast.show();
// Remover toast después de 2 segundos
setTimeout(() => {
toast.dispose();
initialToast.remove();
}, 2000);
// Gráfico de comparación
const ctx1 = document.getElementById('comparisonChart').getContext('2d');
comparisonChart = new Chart(ctx1, {
type: 'bar',
data: {
labels: initialMonths,
datasets: [{
label: 'Pagos Reales',
data: initialPayments,
backgroundColor: 'rgba(54, 162, 235, 0.8)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 2,
borderRadius: 4,
borderSkipped: false,
}, {
label: 'Monto Configurado',
data: initialConfigured,
backgroundColor: 'rgba(255, 99, 132, 0.8)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 2,
borderRadius: 4,
borderSkipped: false,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
tooltip: {
callbacks: {
label: function(context) {
return context.dataset.label + ': $' + context.parsed.y.toLocaleString();
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '$' + value.toLocaleString();
}
},
grid: {
color: 'rgba(0,0,0,0.1)'
}
},
x: {
grid: {
color: 'rgba(0,0,0,0.1)'
}
}
}
}
});
// Gráfico de tendencias
const ctx2 = document.getElementById('trendsChart').getContext('2d');
trendsChart = new Chart(ctx2, {
type: 'line',
data: {
labels: initialMonths,
datasets: [{
label: 'Pagos Reales',
data: initialPayments,
borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
borderWidth: 3,
fill: true,
tension: 0.4,
pointBackgroundColor: 'rgba(75, 192, 192, 1)',
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointRadius: 6,
pointHoverRadius: 8
}, {
label: 'Monto Configurado',
data: initialConfigured,
borderColor: 'rgba(153, 102, 255, 1)',
backgroundColor: 'rgba(153, 102, 255, 0.2)',
borderWidth: 3,
fill: true,
tension: 0.4,
pointBackgroundColor: 'rgba(153, 102, 255, 1)',
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointRadius: 6,
pointHoverRadius: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
tooltip: {
callbacks: {
label: function(context) {
return context.dataset.label + ': $' + context.parsed.y.toLocaleString();
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '$' + value.toLocaleString();
}
},
grid: {
color: 'rgba(0,0,0,0.1)'
}
},
x: {
grid: {
color: 'rgba(0,0,0,0.1)'
}
}
}
}
});
// Gráfico de monto real
const ctx3 = document.getElementById('realAmountChart').getContext('2d');
const initialRealAmounts = initialMonths.map(() => Math.floor(Math.random() * 7000) + 3000);
realAmountChart = new Chart(ctx3, {
type: 'bar',
data: {
labels: initialMonths,
datasets: [{
label: 'Monto Real del Recibo',
data: initialRealAmounts,
backgroundColor: 'rgba(255, 159, 64, 0.8)',
borderColor: 'rgba(255, 159, 64, 1)',
borderWidth: 2,
borderRadius: 4,
borderSkipped: false,
}, {
label: 'Pagos Reales',
data: initialPayments,
backgroundColor: 'rgba(54, 162, 235, 0.8)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 2,
borderRadius: 4,
borderSkipped: false,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
tooltip: {
callbacks: {
label: function(context) {
return context.dataset.label + ': $' + context.parsed.y.toLocaleString();
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '$' + value.toLocaleString();
}
},
grid: {
color: 'rgba(0,0,0,0.1)'
}
},
x: {
grid: {
color: 'rgba(0,0,0,0.1)'
}
}
}
}
});
// Gráfico resumen ejecutivo
const ctx4 = document.getElementById('summaryChart').getContext('2d');
summaryChart = new Chart(ctx4, {
type: 'doughnut',
data: {
labels: ['Pagos Completos', 'Pagos Parciales', 'Sin Pago'],
datasets: [{
data: [65, 25, 10], // Datos de ejemplo iniciales
backgroundColor: [
'rgba(40, 167, 69, 0.8)',
'rgba(255, 193, 7, 0.8)',
'rgba(220, 53, 69, 0.8)'
],
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
},
tooltip: {
callbacks: {
label: function(context) {
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = Math.round((context.parsed / total) * 100);
return context.label + ': ' + percentage + '%';
}
}
}
}
}
});
// Cargar estadísticas del resumen con datos iniciales
// Usar datos de respaldo si no hay datos reales
if (!chartData.payments || Object.keys(chartData.payments).length === 0) {
chartData.payments = <?= json_encode($monthTotals ?? []) ?>;
chartData.configured = <?= json_encode($defaultConfigured ?? []) ?>;
chartData.realAmounts = <?= json_encode($defaultRealAmounts ?? []) ?>;
}
loadSummaryStats();
}
function updateCharts() {
if (comparisonChart) {
comparisonChart.data.labels = chartData.filteredMonths;
comparisonChart.data.datasets[0].data = chartData.filteredMonths.map(month => chartData.payments[month] || 0);
comparisonChart.data.datasets[1].data = chartData.filteredMonths.map(month => chartData.configured[month] || 0);
comparisonChart.update();
}
if (trendsChart) {
trendsChart.data.labels = chartData.filteredMonths;
trendsChart.data.datasets[0].data = chartData.filteredMonths.map(month => chartData.payments[month] || 0);
trendsChart.data.datasets[1].data = chartData.filteredMonths.map(month => chartData.configured[month] || 0);
trendsChart.update();
}
if (realAmountChart) {
realAmountChart.data.labels = chartData.filteredMonths;
realAmountChart.data.datasets[0].data = chartData.filteredMonths.map(month => chartData.realAmounts[month] || 0);
realAmountChart.data.datasets[1].data = chartData.filteredMonths.map(month => chartData.payments[month] || 0);
realAmountChart.update();
}
if (summaryChart) {
// Usar los mismos meses filtrados que las estadísticas
const monthsToUse = chartData.filteredMonths && chartData.filteredMonths.length > 0
? chartData.filteredMonths
: Object.keys(chartData.payments);
// Calcular porcentajes para el resumen usando meses filtrados
const totalPayments = monthsToUse.reduce((total, month) => {
const value = chartData.payments[month];
const numValue = typeof value === 'number' ? value : parseFloat(value) || 0;
return total + numValue;
}, 0);
const totalConfigured = monthsToUse.reduce((total, month) => {
const value = chartData.configured[month];
const numValue = typeof value === 'number' ? value : parseFloat(value) || 0;
return total + numValue;
}, 0);
// Calcular porcentajes basados en pagos por casa y mes
let completeHouses = 0;
let partialHouses = 0;
let noPaymentHouses = 0;
let totalHouses = 0;
// Contar casas por mes y estado de pago
monthsToUse.forEach(month => {
const payment = chartData.payments[month] || 0;
const configured = chartData.configured[month] || 0;
// Simplificación: asumir casas basadas en montos (esto podría mejorarse con datos reales de casas)
if (configured > 0) {
totalHouses++;
if (payment >= configured) {
completeHouses++;
} else if (payment > 0) {
partialHouses++;
} else {
noPaymentHouses++;
}
}
});
// Calcular porcentajes
const completePercentage = totalHouses > 0 ? Math.round((completeHouses / totalHouses) * 100) : 0;
const partialPercentage = totalHouses > 0 ? Math.round((partialHouses / totalHouses) * 100) : 0;
const noPaymentPercentage = 100 - completePercentage - partialPercentage;
summaryChart.data.datasets[0].data = [completePercentage, partialPercentage, noPaymentPercentage];
summaryChart.update();
}
}
function loadSummaryStats() {
// Verificar que hay datos disponibles
if (!chartData.payments || Object.keys(chartData.payments).length === 0) {
console.log('No hay datos de pagos disponibles para estadísticas');
return;
}
console.log('Calculando estadísticas con datos:', {
payments: chartData.payments,
configured: chartData.configured,
realAmounts: chartData.realAmounts
});
// Usar solo los meses filtrados para las estadísticas (igual que los gráficos)
const monthsToUse = chartData.filteredMonths && chartData.filteredMonths.length > 0
? chartData.filteredMonths
: Object.keys(chartData.payments);
console.log('Meses a usar para estadísticas:', monthsToUse);
// Asegurar conversión a números y suma correcta usando solo meses filtrados
const totalPayments = monthsToUse.reduce((total, month) => {
const value = chartData.payments[month];
const numValue = typeof value === 'number' ? value : parseFloat(value) || 0;
return total + numValue;
}, 0);
const totalConfigured = monthsToUse.reduce((total, month) => {
const value = chartData.configured[month];
const numValue = typeof value === 'number' ? value : parseFloat(value) || 0;
return total + numValue;
}, 0);
const totalReal = monthsToUse.reduce((total, month) => {
const value = chartData.realAmounts[month];
const numValue = typeof value === 'number' ? value : parseFloat(value) || 0;
return total + numValue;
}, 0);
console.log('Totales calculados:', { totalPayments, totalConfigured, totalReal });
const statsHtml = `
<div class="row g-3">
<div class="col-12">
<div class="card border-success">
<div class="card-body text-center">
<h6 class="card-title text-success">Total Pagos Recibidos</h6>
<h4 class="mb-0">$ ${totalPayments.toLocaleString()}</h4>
</div>
</div>
</div>
<div class="col-6">
<div class="card border-primary">
<div class="card-body text-center">
<h6 class="card-title text-primary">Monto Configurado</h6>
<h5 class="mb-0">$ ${totalConfigured.toLocaleString()}</h5>
</div>
</div>
</div>
<div class="col-6">
<div class="card border-warning">
<div class="card-body text-center">
<h6 class="card-title text-warning">Monto Real</h6>
<h5 class="mb-0">$ ${totalReal.toLocaleString()}</h5>
</div>
</div>
</div>
<div class="col-12">
<div class="card ${totalPayments >= totalConfigured ? 'border-success' : 'border-danger'}">
<div class="card-body text-center">
<h6 class="card-title ${totalPayments >= totalConfigured ? 'text-success' : 'text-danger'}">Estado General</h6>
<h5 class="mb-0">${totalPayments >= totalConfigured ? '✅ Meta Alcanzada' : '⚠️ Déficit'}</h5>
</div>
</div>
</div>
</div>
`;
document.getElementById('summaryStats').innerHTML = statsHtml;
}
// Exportar gráficos a PDF (usando el método del servidor como las otras exportaciones)
function exportChartsPDF() {
console.log('Iniciando exportación a PDF desde servidor...');
// Mostrar indicador de carga
Swal.fire({
title: 'Generando PDF...',
text: 'Procesando gráficos y estadísticas',
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
Swal.showLoading();
}
});
try {
// Obtener las imágenes de los gráficos
const charts = ['comparisonChart', 'trendsChart', 'realAmountChart', 'summaryChart'];
const chartImages = {};
for (const chartId of charts) {
const canvas = document.getElementById(chartId);
if (canvas) {
chartImages[chartId] = canvas.toDataURL('image/png', 1.0);
} else {
console.warn(`Canvas ${chartId} no encontrado`);
chartImages[chartId] = null;
}
}
// Preparar datos para enviar al servidor
const exportData = {
year: <?= $year ?>,
charts: chartImages,
data: {
payments: chartData.payments,
configured: chartData.configured,
realAmounts: chartData.realAmounts,
months: chartData.months
}
};
// Enviar al servidor para generar PDF
fetch('/dashboard.php?page=charts_export', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(exportData)
})
.then(response => {
if (!response.ok) {
throw new Error(`Error del servidor: ${response.status}`);
}
return response.blob();
})
.then(blob => {
// Crear URL para descarga
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `Graficos_Pagos_Agua_IBIZA_<?= $year ?>_${new Date().toISOString().split('T')[0]}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Cerrar loading y mostrar éxito
Swal.close();
Swal.fire({
icon: 'success',
title: 'PDF Generado',
text: 'Los gráficos han sido exportados exitosamente',
timer: 2000,
showConfirmButton: false
});
console.log('Exportación a PDF completada exitosamente');
})
.catch(error => {
console.error('Error en exportación a PDF:', error);
Swal.close();
Swal.fire({
icon: 'error',
title: 'Error en exportación',
text: 'Ocurrió un error al generar el PDF: ' + error.message,
confirmButtonText: 'Entendido'
});
});
} catch (error) {
console.error('Error preparando datos para PDF:', error);
Swal.close();
Swal.fire({
icon: 'error',
title: 'Error de preparación',
text: 'No se pudieron preparar los datos para exportación',
confirmButtonText: 'Entendido'
});
}
}
// Event listeners para filtros
document.getElementById('chartYearSelect').addEventListener('change', function() {
const newYear = this.value;
window.location.href = `/dashboard.php?page=graficos&year=${newYear}`;
});
// Event listeners para checkboxes de meses
document.getElementById('month-all').addEventListener('change', function() {
const isChecked = this.checked;
document.querySelectorAll('.month-checkbox').forEach(checkbox => {
checkbox.checked = isChecked;
});
updateFilteredMonths();
});
document.querySelectorAll('.month-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', function() {
const allChecked = document.querySelectorAll('.month-checkbox:checked').length === document.querySelectorAll('.month-checkbox').length;
document.getElementById('month-all').checked = allChecked;
// Si se desmarca algún mes individual, desmarcar "Todos"
if (!this.checked) {
document.getElementById('month-all').checked = false;
}
updateFilteredMonths();
});
});
function updateFilteredMonths() {
const checkedBoxes = document.querySelectorAll('.month-checkbox:checked');
const selectedMonths = Array.from(checkedBoxes).map(cb => cb.dataset.month);
if (selectedMonths.length === 0) {
// Si no hay meses seleccionados, mostrar todos
chartData.filteredMonths = [...chartData.months];
} else {
chartData.filteredMonths = selectedMonths;
}
updateCharts();
loadSummaryStats(); // Actualizar estadísticas cuando cambian los filtros
}
// Inicializar gráficos cuando se carga la página
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM cargado, inicializando gráficos...');
// Crear gráficos inmediatamente con datos disponibles
createCharts();
// Intentar actualizar con datos reales en segundo plano (sin bloquear UI)
setTimeout(() => {
loadChartData();
}, 1000);
});
</script>

View File

@@ -15,7 +15,7 @@
</div>
<div class="row">
<?php foreach ($months as $month):
<?php foreach ($months as $month):
$bill = $monthlyBills[$month] ?? null;
?>
<div class="col-md-4 mb-3">
@@ -37,6 +37,11 @@
<input type="number" step="0.01" class="form-control form-control-sm"
id="per_house_<?= $month ?>" value="<?= $bill['amount_per_house'] ?? '' ?>">
</div>
<div class="mb-2">
<small class="text-muted">Monto Real del Recibo</small>
<input type="number" step="0.01" class="form-control form-control-sm"
id="real_<?= $month ?>" value="<?= $bill['real_amount'] ?? '' ?>">
</div>
<div class="mb-2">
<small class="text-muted">Fecha de Vencimiento</small>
<input type="date" class="form-control form-control-sm"
@@ -69,6 +74,7 @@ document.getElementById('yearSelect').addEventListener('change', function() {
function saveMonth(month) {
const total = document.getElementById('total_' + month).value;
const perHouse = document.getElementById('per_house_' + month).value;
const realAmount = document.getElementById('real_' + month).value;
const dueDate = document.getElementById('due_' + month).value;
const year = <?= $year ?>;
@@ -85,6 +91,7 @@ function saveMonth(month) {
month: month,
total_amount: parseFloat(total),
amount_per_house: parseFloat(perHouse),
real_amount: realAmount ? parseFloat(realAmount) : null,
due_date: dueDate || null
})
})

View File

@@ -53,11 +53,16 @@
<i class="bi bi-cash-coin"></i> Finanzas
</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">
@@ -115,6 +120,7 @@
<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>
<footer class="footer mt-auto py-3">
<div class="container-fluid text-center">