feat: Add client portal with project selection, progress overview, gallery, and change order approval

This commit is contained in:
2026-05-25 15:57:06 +02:00
parent 4f5569a156
commit 8ca8dfbccc
5 changed files with 636 additions and 1 deletions
+137
View File
@@ -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');
}
}
+128
View File
@@ -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>
+129
View File
@@ -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>
+7
View File
@@ -90,6 +90,13 @@ Route::get('/reports/dashboard', ReportsDashboard::class)->name('reports.dashboa
// Rutas para el LayerManager: // Rutas para el LayerManager:
Route::get('/projects/{project}/phases/{phase}/layers/manage', \App\Livewire\LayerManager::class)->name('layers.manage'); 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 // Admin: gestión de usuarios y roles
Route::middleware(['can:manage all'])->prefix('admin')->name('admin.')->group(function () { Route::middleware(['can:manage all'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/users', function () { Route::get('/users', function () {