Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd166edbc6 | |||
| 8ca8dfbccc | |||
| 4f5569a156 |
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Client;
|
||||
|
||||
use Livewire\Component;
|
||||
use App\Models\Project;
|
||||
use App\Models\Phase;
|
||||
use App\Models\Inspection;
|
||||
use App\Models\Feature;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ClientProjects extends Component
|
||||
{
|
||||
public $projects = [];
|
||||
public $selectedProject = null;
|
||||
public $projectDetails = [];
|
||||
public $galleryImages = [];
|
||||
public $changeOrders = [];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->loadProjects();
|
||||
}
|
||||
|
||||
public function loadProjects()
|
||||
{
|
||||
// Get projects where the user has the 'client' role
|
||||
$user = auth()->user();
|
||||
$this->projects = $user->projects()
|
||||
->wherePivot('role_in_project', 'client')
|
||||
->with(['phases' => function($query) {
|
||||
$query->select('id', 'project_id', 'name', 'progress_percent');
|
||||
}])
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function selectProject($projectId)
|
||||
{
|
||||
$this->selectedProject = $projectId;
|
||||
$this->loadProjectDetails();
|
||||
}
|
||||
|
||||
public function loadProjectDetails()
|
||||
{
|
||||
if (!$this->selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project = Project::with([
|
||||
'phases.features',
|
||||
'inspections.template'
|
||||
])->find($this->selectedProject);
|
||||
|
||||
if (!$project) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->projectDetails = [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'description' => $project->description,
|
||||
'start_date' => $project->start_date,
|
||||
'end_date' => $project->end_date,
|
||||
'status' => $project->status,
|
||||
'progress' => $project->phases->avg('progress_percent') ?? 0,
|
||||
];
|
||||
|
||||
// Get recent images (simulated for now)
|
||||
$this->galleryImages = [
|
||||
[
|
||||
'url' => 'https://via.placeholder.com/400x300?text=Avance+1',
|
||||
'title' => 'Avance inicial',
|
||||
'date' => now()->subDays(30)->format('d/m/Y')
|
||||
],
|
||||
[
|
||||
'url' => 'https://via.placeholder.com/400x300?text=Avance+2',
|
||||
'title' => 'Estructura levantada',
|
||||
'date' => now()->subDays(15)->format('d/m/Y')
|
||||
],
|
||||
[
|
||||
'url' => 'https://via.placeholder.com/400x300?text=Avance+3',
|
||||
'title' => 'Instalaciones',
|
||||
'date' => now()->subDays(5)->format('d/m/Y')
|
||||
]
|
||||
];
|
||||
|
||||
// Get change orders (simulated for now)
|
||||
$this->changeOrders = [
|
||||
[
|
||||
'id' => 124,
|
||||
'title' => 'Ampliación de zona de almacenamiento',
|
||||
'description' => 'Solicitud de ampliación de zona de almacenamiento debido a cambios logísticos.',
|
||||
'status' => 'pending',
|
||||
'requested_at' => now()->subDays(10)->format('d/m/Y'),
|
||||
'amount' => 1500.00
|
||||
],
|
||||
[
|
||||
'id' => 125,
|
||||
'title' => 'Cambio de material en acabados interiores',
|
||||
'description' => 'Cambio de cerámica estándar a porcelanato en baños y cocinas.',
|
||||
'status' => 'pending',
|
||||
'requested_at' => now()->subDays(5)->format('d/m/Y'),
|
||||
'amount' => 3200.00
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function approveChangeOrder($orderId)
|
||||
{
|
||||
// In a real app, this would update the database
|
||||
foreach ($this->changeOrders as &$order) {
|
||||
if ($order['id'] == $orderId) {
|
||||
$order['status'] = 'approved';
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'approved']);
|
||||
}
|
||||
|
||||
public function rejectChangeOrder($orderId)
|
||||
{
|
||||
// In a real app, this would update the database
|
||||
foreach ($this->changeOrders as &$order) {
|
||||
if ($order['id'] == $orderId) {
|
||||
$order['status'] = 'rejected';
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->dispatch('changeOrderUpdated', ['id' => $orderId, 'status' => 'rejected']);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.client.client-projects');
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
"short_name": "ConstruProg",
|
||||
"name": "Avante",
|
||||
"short_name": "Avante",
|
||||
"description": "App para gestión de proyectos de construcción",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
|
||||
+163
-11
@@ -1,30 +1,182 @@
|
||||
const CACHE_NAME = 'construprogress-cache-v1';
|
||||
const urlsToCache = [
|
||||
const CACHE_NAME = 'avante-cache-v2';
|
||||
const DATA_CACHE_NAME = 'avante-data-cache-v1';
|
||||
|
||||
// Files to cache for offline functionality
|
||||
const FILES_TO_CACHE = [
|
||||
'/',
|
||||
'/dashboard',
|
||||
'/projects',
|
||||
'/projects-list',
|
||||
'/projects/templates',
|
||||
'/reports/dashboard',
|
||||
'/client',
|
||||
'/client/projects',
|
||||
'/manifest.json',
|
||||
'/css/app.css',
|
||||
'/js/app.js',
|
||||
// Add other assets as needed
|
||||
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
|
||||
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js',
|
||||
'https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap',
|
||||
'/icons/icon-72x72.png',
|
||||
'/icons/icon-96x96.png',
|
||||
'/icons/icon-128x128.png',
|
||||
'/icons/icon-144x144.png',
|
||||
'/icons/icon-152x152.png',
|
||||
'/icons/icon-192x192.png',
|
||||
'/icons/icon-384x384.png',
|
||||
'/icons/icon-512x512.png'
|
||||
];
|
||||
|
||||
// Install the service worker and cache the app shell
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[ServiceWorker] Install');
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(urlsToCache))
|
||||
.then((cache) => {
|
||||
console.log('[ServiceWorker] Caching app shell');
|
||||
return cache.addAll(FILES_TO_CACHE);
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate the service worker and clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[ServiceWorker] Activate');
|
||||
event.waitUntil(
|
||||
caches.keys().then((keyList) => {
|
||||
return Promise.all(
|
||||
keyList.map((key) => {
|
||||
if (key !== CACHE_NAME && key !== DATA_CACHE_NAME) {
|
||||
console.log('[ServiceWorker] Removing old cache', key);
|
||||
return caches.delete(key);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch strategy: Network first, falling back to cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Handle API requests differently - cache first, then network
|
||||
if (event.request.url.includes('/api/') || event.request.url.includes('/offline/')) {
|
||||
event.respondWith(
|
||||
caches.open(DATA_CACHE_NAME)
|
||||
.then((cache) => {
|
||||
return fetch(event.request)
|
||||
.then((response) => {
|
||||
// Clone the response to put in cache and return original
|
||||
if (response.status === 200) {
|
||||
cache.put(event.request.url, response.clone());
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// If network fails, try to get from cache
|
||||
return caches.match(event.request);
|
||||
})
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For everything else, use cache first with network fallback
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
return response;
|
||||
.then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// If not in cache, fetch from network and cache it
|
||||
return caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
return fetch(event.request)
|
||||
.then((networkResponse) => {
|
||||
// Don't cache non-successful responses
|
||||
if (networkResponse && networkResponse.status === 200 && networkResponse.type === 'basic') {
|
||||
cache.put(event.request, networkResponse.clone());
|
||||
}
|
||||
return networkResponse;
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// If both cache and network fail, show offline fallback
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('/');
|
||||
}
|
||||
return fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Handle background sync for offline actions
|
||||
self.addEventListener('sync', (event) => {
|
||||
console.log('[ServiceWorker] Background syncing', event.tag);
|
||||
if (event.tag === 'offline-sync') {
|
||||
event.waitUntil(syncOfflineActions());
|
||||
}
|
||||
});
|
||||
|
||||
// Handle push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[ServiceWorker] Push received');
|
||||
const options = {
|
||||
body: event.data ? event.data.text() : 'Tienes una nueva notificación',
|
||||
icon: '/icons/icon-192x192.png',
|
||||
badge: '/icons/icon-72x72.png',
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
dateOfArrival: Date.now(),
|
||||
primaryKey: 1
|
||||
}
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('Avante', options)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('[ServiceWorker] Notification click: ', event.notification);
|
||||
event.notification.close();
|
||||
|
||||
// Determine what to open based on notification data
|
||||
const urlToOpen = '/client'; // Default to client portal
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true
|
||||
}).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url === urlToOpen && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Helper function to sync offline actions
|
||||
async function syncOfflineActions() {
|
||||
console.log('[ServiceWorker] Syncing offline actions');
|
||||
// This would typically make a request to your backend to sync pending actions
|
||||
// For now, we'll just log it
|
||||
try {
|
||||
const response = await fetch('/offline/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error('[ServiceWorker] Sync failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<x-guest-layout>
|
||||
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">
|
||||
Bienvenido, {{ auth()->user()->name }}
|
||||
</h1>
|
||||
|
||||
<div class="grid gap-6 mb-8">
|
||||
<div class="lg:col-span-3">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Mis Proyectos Activos
|
||||
</h2>
|
||||
|
||||
<livewire:client-projects />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-1">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Notificaciones
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-600" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.483l1.227.614a1 1 0 001.216-.483l1.227-.614a1 1 0 00.483-1.216l-.614-1.227a1 1 0 00-.483-1.216l-.614-1.227a1 1 0 00-1.216-.483l-1.227.614a1 1 0 00-.483 1.216l.614 1.227zm1.11-5.656a1 1 0 10-1.414 1.414l1.293 1.293a1 1 0 001.414 0l1.293-1.293a1 1 0 00-1.414-1.414l-1.293-1.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-medium text-gray-900">
|
||||
Proyecto actualizado
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Se han añadido nuevas fotos al proyecto "Centro Comercial Norte"
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Hace 2 horas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-medium text-gray-900">
|
||||
Orden de cambio aprobada
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
La orden de cambio #123 ha sido aprobada
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Hace 1 día
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6">
|
||||
<div class="lg:col-span-6">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Galería de Progreso
|
||||
</h2>
|
||||
|
||||
<div class="gallery-grid">
|
||||
<!-- Placeholder for gallery items -->
|
||||
<div class="gallery-item bg-gray-100 flex items-center justify-center h-48">
|
||||
<span class="text-gray-500">Próximamente: Fotos del avance</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-6">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Órdenes de Cambio Pendientes
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="change-order-card change-order-pending">
|
||||
<h3 class="text-sm font-medium text-gray-900 mb-2">
|
||||
Orden de cambio #124
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mb-2">
|
||||
Solicitud de ampliación de zona de almacenamiento
|
||||
</p>
|
||||
<div class="flex items-center space-x-3 mt-2">
|
||||
<button class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
||||
Aprobar
|
||||
</button>
|
||||
<button class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700">
|
||||
Rechazar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="change-order-card change-order-pending">
|
||||
<h3 class="text-sm font-medium text-gray-900 mb-2">
|
||||
Orden de cambio #125
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mb-2">
|
||||
Cambio de material en acabados interiores
|
||||
</p>
|
||||
<div class="flex items-center space-x-3 mt-2">
|
||||
<button class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
|
||||
Aprobar
|
||||
</button>
|
||||
<button class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700">
|
||||
Rechazar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ config('app.name', 'Avante') }} - Portal Cliente</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Scripts -->
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""/>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="{{ mix('manifest.json') }}">
|
||||
|
||||
<style>
|
||||
.client-portal {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.project-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.gallery-item {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.change-order-card {
|
||||
border-left: 4px solid;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
.change-order-pending { border-left-color: #f59e0b; background-color: #fffbeb; }
|
||||
.change-order-approved { border-left-color: #10b981; background-color: #ecfdf5; }
|
||||
.change-order-rejected { border-left-color: #ef4444; background-color: #fef2f2; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-gray-50">
|
||||
<div class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<img src="{{ asset('logo.png') }}" alt="Avante" class="h-8 w-auto" onerror="this.onerror=null;this.src='https://via.placeholder.com/150x40?text=Avante'; this.alt='Avante Logo'">
|
||||
</div>
|
||||
<div class="hidden md:ml-6 md:flex md:space-x-4">
|
||||
<a href="{{ url('/client/projects') }}" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-50">Mis Proyectos</a>
|
||||
<a href="{{ url('/client/profile') }}" class="px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-50">Perfil</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<div class="ml-4 flex items-center md:ml-6">
|
||||
<livewire:user-nav />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<nav class="md:hidden" id="mobile-menu">
|
||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||
<a href="{{ url('/client/projects') }}" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50">Mis Proyectos</a>
|
||||
<a href="{{ url('/client/profile') }}" class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50">Perfil</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="mt-16">
|
||||
<div class="client-portal">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@stack('scripts')
|
||||
@livewireScripts
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').then(function(registration) {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
}).catch(function(err) {
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile menu toggle
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const menuBtn = document.querySelector('[data-dropdown-toggle]');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
if (menuBtn && mobileMenu) {
|
||||
menuBtn.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,234 @@
|
||||
<div>
|
||||
@if(!$selectedProject)
|
||||
<!-- Project Selection -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Seleccione un proyecto para ver detalles</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
@foreach($projects as $project)
|
||||
<div class="project-card cursor-pointer hover:shadow-lg transition-shadow"
|
||||
wire:click="selectProject({{ $project['id'] }})">
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ $project['name'] }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-2">
|
||||
{{ $project['description'] ?? 'Sin descripción disponible' }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
{{ ucfirst($project['pivot']['role_in_project']) }}
|
||||
</span>
|
||||
<span class="text-sm font-medium">
|
||||
@php
|
||||
$progress = collect($project['phases'])->avg('progress_percent') ?? 0;
|
||||
@endphp
|
||||
{{ number_format($progress, 1) }}% completado
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<!-- Project Details -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold">{{ $projectDetails['name'] }}</h2>
|
||||
<button wire:click="selectedProject = null"
|
||||
class="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300">
|
||||
← Volver a proyectos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">Estado</h3>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
@php
|
||||
$statuses = [
|
||||
'planning' => 'Planificación',
|
||||
'in_progress' => 'En progreso',
|
||||
'on_hold' => 'En espera',
|
||||
'completed' => 'Completado',
|
||||
'cancelled' => 'Cancelado'
|
||||
];
|
||||
echo $statuses[$projectDetails['status']] ?? ucfirst($projectDetails['status']);
|
||||
@endphp
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">Fecha de inicio</h3>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{ $projectDetails['start_date'] ?? 'No definida' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">Fecha estimada</h3>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{ $projectDetails['end_date'] ?? 'No definida' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">Descripción</h3>
|
||||
<p class="text-gray-700">
|
||||
{{ $projectDetails['description'] ?? 'No hay descripción disponible' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Overview -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Resumen de Progreso</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-medium">Progreso General</h3>
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
{{ number_format($projectDetails['progress'] ?? 0, 1) }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 mb-4">
|
||||
<div class="bg-green-600 h-2.5 rounded-full"
|
||||
style="width: {{ min(max($projectDetails['progress'] ?? 0, 0), 100) }}%"></div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ $projectDetails['progress'] ?? 0 }}% completado
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phases Progress -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Progreso por Fase</h2>
|
||||
|
||||
@php
|
||||
$project = \App\Models\Project::find($selectedProject);
|
||||
$phases = $project->phases ?? collect();
|
||||
@endphp
|
||||
|
||||
@if($phases->isNotEmpty())
|
||||
<div class="space-y-4">
|
||||
@foreach($phases as $phase)
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-lg font-medium">{{ $phase->name }}</h3>
|
||||
<span class="px-2 py-1 bg-indigo-100 text-indigo-800 text-xs rounded-full">
|
||||
Fase {{ $phase->id }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div class="bg-indigo-600 h-2.5 rounded-full"
|
||||
style="width: {{ min(max($phase->progress_percent ?? 0, 0), 100) }}%"></div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
{{ $phase->progress_percent ?? 0 }}% completado
|
||||
</div>
|
||||
|
||||
@if($phase->features->isNotEmpty())
|
||||
<div class="mt-3 pt-2 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-600 mb-2">Características:</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
@foreach($phase->features as $feature)
|
||||
<div class="flex items-start">
|
||||
<span class="flex-shrink-0">•</span>
|
||||
<span class="ml-2">
|
||||
{{ $feature->name }}:
|
||||
<span class="font-medium">{{ $feature->completion_status ?? 'Pendiente' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="bg-gray-50 p-6 text-center rounded-lg">
|
||||
<p class="text-gray-500">No hay fases definidas para este proyecto</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Gallery -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Galería de Progreso</h2>
|
||||
|
||||
<div class="gallery-grid">
|
||||
@foreach($galleryImages as $image)
|
||||
<div class="gallery-item">
|
||||
<img src="{{ $image['url'] }}"
|
||||
alt="{{ $image['title'] }}"
|
||||
class="w-full h-48 object-cover">
|
||||
<div class="p-3">
|
||||
<h4 class="text-sm font-medium">{{ $image['title'] }}</h4>
|
||||
<p class="text-xs text-gray-500">{{ $image['date'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Orders -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Órdenes de Cambio</h2>
|
||||
|
||||
@if($changeOrders->isNotEmpty())
|
||||
<div class="space-y-4">
|
||||
@foreach($changeOrders as $order)
|
||||
<div class="change-order-card change-order-{{ strtolower($order['status']) }} p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-lg font-medium">{{ $order['title'] }}</h3>
|
||||
<span class="px-2 py-1 text-xs rounded-full
|
||||
@if($order['status'] == 'pending') bg-yellow-100 text-yellow-800
|
||||
@elseif($order['status'] == 'approved') bg-green-100 text-green-800
|
||||
@elseif($order['status'] == 'rejected') bg-red-100 text-red-800
|
||||
@endif">
|
||||
{{ ucfirst($order['status']) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 mb-2">{{ $order['description'] }}</p>
|
||||
|
||||
<div class="flex items-center space-x-3 text-sm">
|
||||
<span class="mr-4">
|
||||
<span class="font-medium">Solicitado:</span> {{ $order['requested_at'] }}
|
||||
</span>
|
||||
<span class="mr-4">
|
||||
<span class="font-medium">Monto:</span> ${{ number_format($order['amount'], 2) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if($order['status'] == 'pending')
|
||||
<div class="mt-3 pt-2 border-t border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<button wire:click="approveChangeOrder({{ $order['id'] }})"
|
||||
class="flex-1 px-3 py-2 bg-green-600 text-white text-sm rounded hover:bg-green-700 disabled:opacity-50">
|
||||
Aprobar
|
||||
</button>
|
||||
<button wire:click="rejectChangeOrder({{ $order['id'] }})"
|
||||
class="flex-1 px-3 py-2 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:opacity-50">
|
||||
Rechazar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="bg-gray-50 p-6 text-center rounded-lg">
|
||||
<p class="text-gray-500">No hay órdenes de cambio pendientes</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -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>
|
||||
+10
-1
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use AppLivewireReportsReportsDashboard;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\Controllers\OfflineSyncController;
|
||||
use App\Livewire\ProjectMap;
|
||||
@@ -70,6 +71,7 @@ Route::middleware(['auth'])->group(function () {
|
||||
'recentInspections' => $inspections,
|
||||
]);
|
||||
})->name('dashboard');
|
||||
Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboard');
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Gestión de proyectos (CRUD completo)
|
||||
@@ -88,6 +90,13 @@ Route::middleware(['auth'])->group(function () {
|
||||
// Rutas para el LayerManager:
|
||||
Route::get('/projects/{project}/phases/{phase}/layers/manage', \App\Livewire\LayerManager::class)->name('layers.manage');
|
||||
|
||||
// Cliente: portal cliente
|
||||
Route::middleware(['auth', 'role:client'])->prefix('client')->name('client.')->group(function () {
|
||||
Route::get('/', function () {
|
||||
return view('client.dashboard');
|
||||
})->name('dashboard');
|
||||
});
|
||||
|
||||
// Admin: gestión de usuarios y roles
|
||||
Route::middleware(['can:manage all'])->prefix('admin')->name('admin.')->group(function () {
|
||||
Route::get('/users', function () {
|
||||
@@ -119,4 +128,4 @@ Route::middleware(['auth'])->group(function () {
|
||||
});
|
||||
|
||||
// Incluir rutas de autenticación (login, registro, recuperación de contraseña, logout)
|
||||
require __DIR__ . '/auth.php';
|
||||
require __DIR__ . '/auth.php';
|
||||
|
||||
Reference in New Issue
Block a user