feat: Add reports dashboard with Chart.js analytics and PWA improvements (Avante)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# ConstruProgress
|
# Avante
|
||||||
|
|
||||||
Sistema de gestión de proyectos de construcción con mapas interactivos, control de progreso, inspecciones y soporte offline.
|
Sistema de gestión de proyectos de construcción con mapas interactivos, control de progreso, inspecciones y soporte offline.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Reports;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Phase;
|
||||||
|
use App\Models\Inspection;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class ReportsDashboard extends Component
|
||||||
|
{
|
||||||
|
public $dateRange = 'month'; // week, month, quarter, year
|
||||||
|
public $chartData = [];
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->loadChartData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadChartData()
|
||||||
|
{
|
||||||
|
// Project progress over time (last 6 months)
|
||||||
|
$projects = Project::with(['phases' => function($query) {
|
||||||
|
$query->select('project_id', 'progress_percent', 'updated_at');
|
||||||
|
}])->get();
|
||||||
|
|
||||||
|
// Simulate monthly progress data (since we don't have historical stored)
|
||||||
|
// In a real app, we'd have a progress_history table or similar
|
||||||
|
$months = [];
|
||||||
|
$current = Carbon::now();
|
||||||
|
for ($i = 5; $i >= 0; $i--) {
|
||||||
|
$month = $current->copy()->subMonths($i);
|
||||||
|
$months[] = $month->format('M Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectProgress = [];
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$progressData = [];
|
||||||
|
foreach ($months as $month) {
|
||||||
|
// For demo, we'll use current progress with some variation
|
||||||
|
$avgProgress = $project->phases->avg('progress_percent') ?? 0;
|
||||||
|
// Add some random variation for demo purposes
|
||||||
|
$variation = rand(-10, 10);
|
||||||
|
$progress = max(0, min(100, $avgProgress + $variation));
|
||||||
|
$progressData[] = round($progress);
|
||||||
|
}
|
||||||
|
$projectProgress[] = [
|
||||||
|
'name' => $project->name,
|
||||||
|
'data' => $progressData
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspections by type (last 6 months)
|
||||||
|
$inspections = Inspection::with(['template', 'feature'])
|
||||||
|
->whereDate('created_at', '>=', Carbon::now()->subMonths(6))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$inspectionTypes = $inspections->groupBy(function($inspection) {
|
||||||
|
return $inspection->template ? $inspection->template->name : 'Sin plantilla';
|
||||||
|
})->map(function($group) {
|
||||||
|
return $group->count();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Projects by status
|
||||||
|
$projectsByStatus = Project::selectRaw('status, count(*) as count')
|
||||||
|
->groupBy('status')
|
||||||
|
->pluck('count', 'status')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Average phase progress by project
|
||||||
|
$projectPhaseProgress = Project::with(['phases'])
|
||||||
|
->get()
|
||||||
|
->map(function($project) {
|
||||||
|
return [
|
||||||
|
'name' => $project->name,
|
||||||
|
'progress' => $project->phases->avg('progress_percent') ?? 0
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->chartData = [
|
||||||
|
'months' => $months,
|
||||||
|
'projectProgress' => $projectProgress,
|
||||||
|
'inspectionTypes' => [
|
||||||
|
'labels' => $inspectionTypes->keys()->toArray(),
|
||||||
|
'data' => $inspectionTypes->values()->toArray()
|
||||||
|
],
|
||||||
|
'projectsByStatus' => [
|
||||||
|
'labels' => array_map(function($status) {
|
||||||
|
return ucfirst(str_replace('_', ' ', $status));
|
||||||
|
}, array_keys($projectsByStatus)),
|
||||||
|
'data' => array_values($projectsByStatus)
|
||||||
|
],
|
||||||
|
'projectPhaseProgress' => $projectPhaseProgress
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.reports.reports-dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ConstruProgress",
|
"name": "Avante",
|
||||||
"short_name": "ConstruProg",
|
"short_name": "Avante",
|
||||||
"description": "App para gestión de proyectos de construcción",
|
"description": "App para gestión de proyectos de construcción",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Reportes y Analítica</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 mb-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium">Rango de tiempo:</span>
|
||||||
|
<select wire:model="dateRange" class="border border-gray-300 rounded px-3 py-1">
|
||||||
|
<option value="week">Esta semana</option>
|
||||||
|
<option value="month" selected>Este mes</option>
|
||||||
|
<option value="quarter">Este trimestre</option>
|
||||||
|
<option value="year">Este año</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button wire:click="loadChartData"
|
||||||
|
class="btn btn-primary btn-sm">
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(isset($chartData['months']))
|
||||||
|
<div class="grid gap-6 mb-8">
|
||||||
|
{{-- Gráfico de progreso de proyectos --}}
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Progreso de Proyectos (últimos 6 meses)</h3>
|
||||||
|
<div style="position: relative; height: 300px;">
|
||||||
|
<canvas id="projectProgressChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Gráfico de inspecciones por tipo --}}
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Inspecciones por Tipo</h3>
|
||||||
|
<div style="position: relative; height: 300px;">
|
||||||
|
<canvas id="inspectionTypesChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Gráfico de proyectos por estado --}}
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Distribución de Proyectos por Estado</h3>
|
||||||
|
<div style="position: relative; height: 300px;">
|
||||||
|
<canvas id="projectsByStatusChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Gráfico de progreso promedio por proyecto --}}
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Progreso Promedio por Proyecto</h3>
|
||||||
|
<div style="position: relative; height: 300px;">
|
||||||
|
<canvas id="projectPhaseProgressChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tarjetas de métricas clave --}}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<div class="text-sm text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
Total Proyectos Activos
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{{ \App\Models\Project::where('status', 'in_progress')->count() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<div class="text-sm text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
Inspecciones Este Mes
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{{ \App\Models\Inspection::whereDate('created_at', '>=', now()->startOfMonth())->count() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<div class="text-sm text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
Promedio de Progreso
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
@php
|
||||||
|
$avgProgress = \App\Models\Phase::whereNotNull('progress_percent')
|
||||||
|
->avg('progress_percent') ?? 0;
|
||||||
|
@endphp
|
||||||
|
{{ number_format($avgProgress, 1) }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
|
<div class="text-sm text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
Proyectos Completados
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{{ \App\Models\Project::where('status', 'completed')->count() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="bg-white rounded-lg shadow p-6 text-center">
|
||||||
|
<p class="text-gray-500">Cargando datos...</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Wait for Alpine to initialize
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
// Initialize charts when data is available
|
||||||
|
window.addEventListener('livewire:load', function() {
|
||||||
|
initializeCharts();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('livewire:updated', function() {
|
||||||
|
initializeCharts();
|
||||||
|
});
|
||||||
|
|
||||||
|
function initializeCharts() {
|
||||||
|
if (typeof Chart === 'undefined') {
|
||||||
|
console.warn('Chart.js not loaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy existing charts if they exist
|
||||||
|
const chartIds = ['projectProgressChart', 'inspectionTypesChart', 'projectsByStatusChart', 'projectPhaseProgressChart'];
|
||||||
|
chartIds.forEach(id => {
|
||||||
|
const ctx = document.getElementById(id);
|
||||||
|
if (ctx) {
|
||||||
|
// Check if chart instance exists and destroy it
|
||||||
|
if (ctx.chart instanceof Chart) {
|
||||||
|
ctx.chart.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Project Progress Chart (Line)
|
||||||
|
const projectProgressCtx = document.getElementById('projectProgressChart');
|
||||||
|
if (projectProgressCtx) {
|
||||||
|
new Chart(projectProgressCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: @json($chartData['months'] ?? []),
|
||||||
|
datasets: @json($chartData['projectProgress'] ?? [])
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 100,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Progreso (%)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspection Types Chart (Bar)
|
||||||
|
const inspectionTypesCtx = document.getElementById('inspectionTypesChart');
|
||||||
|
if (inspectionTypesCtx) {
|
||||||
|
new Chart(inspectionTypesCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: @json($chartData['inspectionTypes']['labels'] ?? []),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Cantidad de inspecciones',
|
||||||
|
data: @json($chartData['inspectionTypes']['data'] ?? []),
|
||||||
|
backgroundColor: 'rgba(54, 162, 235, 0.5)',
|
||||||
|
borderColor: 'rgba(54, 162, 235, 1)',
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Cantidad'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects by Status Chart (Pie/Doughnut)
|
||||||
|
const projectsByStatusCtx = document.getElementById('projectsByStatusChart');
|
||||||
|
if (projectsByStatusCtx) {
|
||||||
|
new Chart(projectsByStatusCtx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: @json($chartData['projectsByStatus']['labels'] ?? []),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Proyectos por estado',
|
||||||
|
data: @json($chartData['projectsByStatus']['data'] ?? []),
|
||||||
|
backgroundColor: [
|
||||||
|
'rgba(255, 99, 132, 0.5)',
|
||||||
|
'rgba(54, 162, 235, 0.5)',
|
||||||
|
'rgba(255, 206, 86, 0.5)',
|
||||||
|
'rgba(75, 192, 192, 0.5)',
|
||||||
|
'rgba(153, 102, 255, 0.5)',
|
||||||
|
'rgba(255, 159, 64, 0.5)'
|
||||||
|
],
|
||||||
|
borderColor: [
|
||||||
|
'rgba(255, 99, 132, 1)',
|
||||||
|
'rgba(54, 162, 235, 1)',
|
||||||
|
'rgba(255, 206, 86, 1)',
|
||||||
|
'rgba(75, 192, 192, 1)',
|
||||||
|
'rgba(153, 102, 255, 1)',
|
||||||
|
'rgba(255, 159, 64, 1)'
|
||||||
|
],
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
position: 'bottom'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project Phase Progress Chart (Bar - Horizontal)
|
||||||
|
const projectPhaseProgressCtx = document.getElementById('projectPhaseProgressChart');
|
||||||
|
if (projectPhaseProgressCtx) {
|
||||||
|
// Sort by progress descending
|
||||||
|
const sortedData = (@json($chartData['projectPhaseProgress'] ?? [])).sort((a, b) => b.progress - a.progress);
|
||||||
|
|
||||||
|
new Chart(projectPhaseProgressCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: sortedData.map(item => item.name),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Progreso promedio (%)',
|
||||||
|
data: sortedData.map(item => item.progress),
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.5)',
|
||||||
|
borderColor: 'rgba(75, 192, 192, 1)',
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
indexAxis: 'y', // Horizontal bars
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 100,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Progreso (%)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use AppLivewireReportsReportsDashboard;
|
||||||
use App\Http\Controllers\ProjectController;
|
use App\Http\Controllers\ProjectController;
|
||||||
use App\Http\Controllers\OfflineSyncController;
|
use App\Http\Controllers\OfflineSyncController;
|
||||||
use App\Livewire\ProjectMap;
|
use App\Livewire\ProjectMap;
|
||||||
@@ -70,6 +71,7 @@ Route::middleware(['auth'])->group(function () {
|
|||||||
'recentInspections' => $inspections,
|
'recentInspections' => $inspections,
|
||||||
]);
|
]);
|
||||||
})->name('dashboard');
|
})->name('dashboard');
|
||||||
|
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Gestión de proyectos (CRUD completo)
|
// Gestión de proyectos (CRUD completo)
|
||||||
|
|||||||
Reference in New Issue
Block a user