Dashboard con stats, LayerUpload funcional, PhaseProgress eager-loading, README actualizado

This commit is contained in:
2026-05-09 21:17:36 +02:00
parent 7461bd9124
commit 2a300241f9
6 changed files with 335 additions and 64 deletions
+36 -50
View File
@@ -1,59 +1,45 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p> # ConstruProgress
<p align="center"> Sistema de gestión de proyectos de construcción con mapas interactivos, control de progreso, inspecciones y soporte offline.
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel ## Características
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: - **Mapas interactivos** — Visualización de proyectos sobre mapa con capas (GeoJSON/KML) y elementos editables
- **Gestión de fases** — Proyectos organizados en fases con progreso porcentual y seguimiento histórico
- **Capas y elementos** — Subida de archivos GeoJSON/KML, capas vacías editables con color personalizado
- **Inspecciones** — Plantillas de inspección por proyecto, asignables a elementos del mapa
- **Progreso** — Seguimiento visual del progreso por fase y global del proyecto
- **Sincronización offline** — Endpoints para trabajadores en campo, sincronización diferida
- **Permisos** — Roles y permisos granulares (Spatie Permission)
- **Dashboard** — Estadísticas globales, proyectos recientes, inspecciones
- [Simple, fast routing engine](https://laravel.com/docs/routing). ## Requisitos
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications. - PHP 8.2+
- MySQL/MariaDB
- Composer
- Node.js + NPM
## Learning Laravel ## Instalación
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. ```bash
git clone <repo-url> construprogress
cd construprogress
composer install
npm install && npm run build
cp .env.example .env
# Editar .env con credenciales de base de datos
php artisan key:generate
php artisan migrate
php artisan db:seed --class=RolePermissionSeeder # si existe
php artisan serve
```
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. ## Stack técnico
## Laravel Sponsors - **Framework:** Laravel 11
- **Frontend:** Tailwind CSS + DaisyUI + Leaflet.js
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). - **Mapas:** Leaflet + Leaflet Draw (editor gráfico)
- **Componentes:** Livewire 3
### Premium Partners - **Base de datos:** MySQL/MariaDB
- **Autenticación:** Laravel Breeze
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
+112 -1
View File
@@ -3,11 +3,122 @@
namespace App\Livewire; namespace App\Livewire;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads;
use App\Models\Project;
use App\Models\Phase;
use App\Models\Layer;
use App\Models\Feature;
use App\Services\SpatialFileConverter;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class LayerUpload extends Component class LayerUpload extends Component
{ {
use WithFileUploads;
public $projectId;
public $phaseId;
public $uploadFile = null;
public $layerName = '';
public $layerColor = '#3b82f6';
protected $rules = [
'uploadFile' => 'required|file|max:51200',
'layerName' => 'required|string|max:255',
'layerColor' => 'nullable|string|size:7',
];
public function mount($projectId = null, $phaseId = null)
{
$this->projectId = $projectId;
$this->phaseId = $phaseId;
}
public function upload()
{
$user = Auth::user();
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
session()->flash('error', 'Sin permisos.');
return;
}
$this->validate();
if (!$this->projectId || !$this->phaseId) {
session()->flash('error', 'Faltan datos del proyecto/fase.');
return;
}
$project = Project::findOrFail($this->projectId);
$phase = Phase::findOrFail($this->phaseId);
$extension = strtolower($this->uploadFile->getClientOriginalExtension());
$mime = $this->uploadFile->getMimeType();
$allowedExtensions = ['geojson', 'kmz', 'kml', 'shp', 'dwg', 'zip'];
$allowedMimes = [
'application/vnd.google-earth.kml+xml',
'application/vnd.google-earth.kmz',
'application/zip',
'application/x-zip-compressed',
'application/x-shapefile',
'image/vnd.dwg',
'application/acad',
'application/geo+json',
'text/xml',
'application/xml',
];
if (!in_array($extension, $allowedExtensions) && !in_array($mime, $allowedMimes)) {
session()->flash('error', 'Tipo de archivo no permitido.');
return;
}
$projectDir = "uploads/projects/{$project->id}/layers";
$originalPath = $this->uploadFile->store($projectDir, 'public');
$geojson = SpatialFileConverter::convertToGeoJson($this->uploadFile);
if (!$geojson) {
session()->flash('error', 'Conversión fallida.');
return;
}
$layerColor = $this->layerColor ?: '#3b82f6';
$geojson['style'] = ['color' => $layerColor];
$layer = Layer::create([
'project_id' => $project->id,
'phase_id' => $phase->id,
'name' => $this->layerName,
'color' => $layerColor,
'original_file' => $originalPath,
'uploaded_by' => $user->id,
]);
if (isset($geojson['features'])) {
foreach ($geojson['features'] as $featureData) {
Feature::create([
'layer_id' => $layer->id,
'name' => $featureData['properties']['name'] ?? null,
'geometry' => $featureData['geometry'],
'properties' => $featureData['properties'] ?? [],
'template_id' => $featureData['properties']['template_id'] ?? null,
'progress' => $featureData['properties']['progress'] ?? 0,
'responsible' => $featureData['properties']['responsible'] ?? null,
]);
}
}
$this->reset(['uploadFile', 'layerName']);
session()->flash('message', "Capa '{$layer->name}' importada correctamente con " . count($geojson['features'] ?? []) . ' elementos.');
$this->dispatch('layerUploaded', projectId: $project->id);
}
public function render() public function render()
{ {
return view('livewire.layer-upload'); $projects = Project::accessibleBy(Auth::user())->get();
$phases = $this->projectId ? Phase::where('project_id', $this->projectId)->orderBy('order')->get() : collect();
return view('livewire.layer-upload', compact('projects', 'phases'));
} }
} }
+1 -1
View File
@@ -13,7 +13,7 @@ class PhaseProgress extends Component
public function mount(Phase $phase) public function mount(Phase $phase)
{ {
$this->phase = $phase; $this->phase = $phase->load('progressUpdates');
$this->progress = $phase->progress_percent; $this->progress = $phase->progress_percent;
} }
+105 -3
View File
@@ -7,11 +7,113 @@
<div class="py-12"> <div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> {{-- Stats cards --}}
<div class="p-6 text-gray-900"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{{ __("You're logged in!") }} <div class="bg-white rounded-lg shadow p-6">
<div class="text-sm text-gray-500 uppercase tracking-wide">Proyectos activos</div>
<div class="text-3xl font-bold mt-1">{{ $stats['active_projects'] }}</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm text-gray-500 uppercase tracking-wide">Proyectos totales</div>
<div class="text-3xl font-bold mt-1">{{ $stats['total_projects'] }}</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm text-gray-500 uppercase tracking-wide">Fases totales</div>
<div class="text-3xl font-bold mt-1">{{ $stats['total_phases'] }}</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="text-sm text-gray-500 uppercase tracking-wide">Elementos (features)</div>
<div class="text-3xl font-bold mt-1">{{ $stats['total_features'] }}</div>
</div> </div>
</div> </div>
{{-- Barra de progreso global --}}
<div class="bg-white rounded-lg shadow p-6 mb-8">
<h3 class="text-lg font-semibold mb-2">Progreso global</h3>
<div class="w-full bg-gray-200 rounded-full h-4">
<div class="bg-primary h-4 rounded-full transition-all" style="width: {{ $stats['global_progress'] }}%"></div>
</div>
<p class="text-right text-sm text-gray-500 mt-1">{{ $stats['global_progress'] }}%</p>
</div>
{{-- Proyectos recientes --}}
<div class="bg-white rounded-lg shadow p-6 mb-8">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Proyectos recientes</h3>
<a href="{{ route('projects.list') }}" class="text-sm text-primary hover:underline">Ver todos</a>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Nombre</th>
<th>Estado</th>
<th>Fases</th>
<th>Progreso</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($recentProjects as $project)
<tr>
<td class="font-medium">{{ $project->name }}</td>
<td>
@php
$badgeClass = match($project->status) {
'planning' => 'badge-ghost',
'in_progress' => 'badge-primary',
'paused' => 'badge-warning',
'completed' => 'badge-success',
default => 'badge-ghost'
};
@endphp
<span class="badge {{ $badgeClass }}">{{ match($project->status) {
'planning' => 'Planificación',
'in_progress' => 'En obra',
'paused' => 'Pausado',
'completed' => 'Finalizado',
default => $project->status
} }}</span>
</td>
<td>{{ $project->phases_count }}</td>
<td>
@php $avg = $project->phases->avg('progress_percent'); @endphp
<div class="flex items-center gap-2">
<div class="w-24 bg-gray-200 rounded-full h-2.5">
<div class="bg-primary h-2.5 rounded-full" style="width: {{ $avg }}%"></div>
</div>
<span class="text-xs">{{ round($avg) }}%</span>
</div>
</td>
<td>
<a href="{{ route('projects.map', $project) }}" class="btn btn-xs btn-outline">Mapa</a>
</td>
</tr>
@empty
<tr><td colspan="5" class="text-center text-gray-400 py-4">No hay proyectos aún</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- Inspecciones recientes --}}
@if($recentInspections->isNotEmpty())
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold mb-4">Inspecciones recientes</h3>
<div class="space-y-2">
@foreach($recentInspections as $inspection)
<div class="border rounded p-3 flex justify-between items-center">
<div>
<span class="font-medium">{{ $inspection->template?->name ?? 'Inspección' }}</span>
<span class="text-sm text-gray-500 ml-2">{{ $inspection->feature?->name }}</span>
</div>
<span class="text-xs text-gray-400">{{ $inspection->created_at->diffForHumans() }}</span>
</div>
@endforeach
</div>
</div>
@endif
</div> </div>
</div> </div>
</x-app-layout> </x-app-layout>
@@ -2,17 +2,55 @@
@if(session()->has('message')) @if(session()->has('message'))
<div class="alert alert-success mb-2">{{ session('message') }}</div> <div class="alert alert-success mb-2">{{ session('message') }}</div>
@endif @endif
@if(session()->has('error'))
<div class="alert alert-error mb-2">{{ session('error') }}</div>
@endif
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">Subir capa a proyecto</h2> <h2 class="card-title">Subir capa</h2>
<form wire:submit.prevent="upload">
<form wire:submit.prevent="upload" class="space-y-4">
<div class="form-control"> <div class="form-control">
<label class="label">Archivo GeoJSON / KML</label> <label class="label">Proyecto</label>
<input type="file" wire:model="file" class="file-input file-input-bordered" accept=".geojson,.kml,.kmz,.zip" /> <select wire:model.live="projectId" class="select select-bordered" required>
@error('file') <span class="text-error">{{ $message }}</span> @enderror <option value="">Seleccionar proyecto...</option>
@foreach($projects as $p)
<option value="{{ $p->id }}">{{ $p->name }}</option>
@endforeach
</select>
</div> </div>
<button type="submit" class="btn btn-primary mt-4">Subir</button>
<div class="form-control">
<label class="label">Fase</label>
<select wire:model.live="phaseId" class="select select-bordered" required @if(!$projectId) disabled @endif>
<option value="">Seleccionar fase...</option>
@foreach($phases as $ph)
<option value="{{ $ph->id }}">{{ $ph->name }}</option>
@endforeach
</select>
</div>
<div class="form-control">
<label class="label">Nombre de la capa</label>
<input type="text" wire:model="layerName" class="input input-bordered" placeholder="Ej: Cimentación" required />
@error('layerName') <span class="text-error text-sm">{{ $message }}</span> @enderror
</div>
<div class="form-control">
<label class="label">Color</label>
<input type="color" wire:model="layerColor" class="input input-bordered w-20" />
</div>
<div class="form-control">
<label class="label">Archivo (GeoJSON, KML, KMZ, Shapefile .zip, DWG)</label>
<input type="file" wire:model="uploadFile" class="file-input file-input-bordered" accept=".geojson,.kml,.kmz,.zip,.shp,.dwg" />
@error('uploadFile') <span class="text-error text-sm">{{ $message }}</span> @enderror
</div>
<button type="submit" class="btn btn-primary w-full">
Subir capa
</button>
</form> </form>
</div> </div>
</div> </div>
+35 -1
View File
@@ -34,7 +34,41 @@ Route::middleware(['auth'])->group(function () {
// Dashboard principal (vista con estadísticas y lista de proyectos) // Dashboard principal (vista con estadísticas y lista de proyectos)
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
return view('dashboard'); $user = \Illuminate\Support\Facades\Auth::user();
$projects = \App\Models\Project::accessibleBy($user)
->withCount('phases')
->with('phases')
->latest()
->take(5)
->get();
$allProjects = \App\Models\Project::accessibleBy($user);
$activeProjects = (clone $allProjects)->where('status', 'in_progress');
$totalPhases = \App\Models\Phase::whereIn('project_id', (clone $allProjects)->pluck('id'))->count();
$totalFeatures = \App\Models\Feature::whereIn('layer_id', function($q) use ($allProjects) {
$q->select('id')->from('layers')->whereIn('project_id', (clone $allProjects)->pluck('id'));
})->count();
$globalProgress = \App\Models\Phase::whereIn('project_id', (clone $allProjects)->pluck('id'))->avg('progress_percent') ?? 0;
$inspections = \App\Models\Inspection::whereIn('project_id', (clone $allProjects)->pluck('id'))
->with(['template', 'feature'])
->latest()
->take(5)
->get();
return view('dashboard', [
'stats' => [
'active_projects' => $activeProjects->count(),
'total_projects' => $allProjects->count(),
'total_phases' => $totalPhases,
'total_features' => $totalFeatures,
'global_progress' => round($globalProgress),
],
'recentProjects' => $projects,
'recentInspections' => $inspections,
]);
})->name('dashboard'); })->name('dashboard');
// ------------------------------------------------------------ // ------------------------------------------------------------