Dashboard con stats, LayerUpload funcional, PhaseProgress eager-loading, README actualizado
This commit is contained in:
@@ -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">
|
||||
<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>
|
||||
Sistema de gestión de proyectos de construcción con mapas interactivos, control de progreso, inspecciones y soporte offline.
|
||||
|
||||
## 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).
|
||||
- [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).
|
||||
## Requisitos
|
||||
|
||||
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
|
||||
|
||||
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).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[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).
|
||||
- **Framework:** Laravel 11
|
||||
- **Frontend:** Tailwind CSS + DaisyUI + Leaflet.js
|
||||
- **Mapas:** Leaflet + Leaflet Draw (editor gráfico)
|
||||
- **Componentes:** Livewire 3
|
||||
- **Base de datos:** MySQL/MariaDB
|
||||
- **Autenticación:** Laravel Breeze
|
||||
@@ -3,11 +3,122 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
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
|
||||
{
|
||||
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()
|
||||
{
|
||||
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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class PhaseProgress extends Component
|
||||
|
||||
public function mount(Phase $phase)
|
||||
{
|
||||
$this->phase = $phase;
|
||||
$this->phase = $phase->load('progressUpdates');
|
||||
$this->progress = $phase->progress_percent;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,113 @@
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">
|
||||
{{ __("You're logged in!") }}
|
||||
{{-- Stats cards --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<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>
|
||||
|
||||
{{-- 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>
|
||||
</x-app-layout>
|
||||
</x-app-layout>
|
||||
@@ -2,17 +2,55 @@
|
||||
@if(session()->has('message'))
|
||||
<div class="alert alert-success mb-2">{{ session('message') }}</div>
|
||||
@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-body">
|
||||
<h2 class="card-title">Subir capa a proyecto</h2>
|
||||
<form wire:submit.prevent="upload">
|
||||
<h2 class="card-title">Subir capa</h2>
|
||||
|
||||
<form wire:submit.prevent="upload" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">Archivo GeoJSON / KML</label>
|
||||
<input type="file" wire:model="file" class="file-input file-input-bordered" accept=".geojson,.kml,.kmz,.zip" />
|
||||
@error('file') <span class="text-error">{{ $message }}</span> @enderror
|
||||
<label class="label">Proyecto</label>
|
||||
<select wire:model.live="projectId" class="select select-bordered" required>
|
||||
<option value="">Seleccionar proyecto...</option>
|
||||
@foreach($projects as $p)
|
||||
<option value="{{ $p->id }}">{{ $p->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+35
-1
@@ -34,7 +34,41 @@ Route::middleware(['auth'])->group(function () {
|
||||
|
||||
// Dashboard principal (vista con estadísticas y lista de proyectos)
|
||||
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');
|
||||
|
||||
// ------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user