feat: profile_photo en contratos activos, notificaciones bilingues en reportes y foto en panel usuarios

- API contratos activos (cliente y proveedor): agrega campo profile_photo (técnico asignado o supplier, null si no tiene)
- Notificaciones push bilingues en comentarios de reporte: "Nueva actividad en tu reporte" / "New activity on your report"
- Moderador en web panel notifica a cliente y proveedor al comentar, con prefijo Moderador/Moderator
- Panel usuarios: columna de foto de perfil entre ID y Nombre

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 17:17:14 -06:00
parent 38da1b56ce
commit 0ed98c96ee
40 changed files with 1390 additions and 651 deletions

View File

@@ -1,47 +0,0 @@
@extends('layouts.app')
@section('content')
<div class="container">
<div class="col-md-8 offset-md-2">
<h1>{{isset($bank)?'Editar':'Nuevo'}} Banco</h1>
<hr>
@if(isset($bank))
{!! Form::model($bank,['method'=>'put','id'=>'frm']) !!}
@else
{!! Form::open(['id'=>'frm']) !!}
@endif
<div class="form-group row required">
{!! Form::label("code","Código",["class"=>"col-form-label col-md-3 col-lg-2"]) !!}
<div class="col-md-8">
{!! Form::number("code",null,["class"=>"form-control".($errors->has('code')?" is-invalid":""),"autofocus",'placeholder'=>'Código del banco']) !!}
<span id="error-name" class="invalid-feedback"></span>
</div>
</div>
<div class="form-group row required">
{!! Form::label("name","Banco",["class"=>"col-form-label col-md-3 col-lg-2"]) !!}
<div class="col-md-8">
{!! Form::text("name",null,["class"=>"form-control".($errors->has('name')?" is-invalid":""),"autofocus",'placeholder'=>'Nombre del banco']) !!}
<span id="error-name" class="invalid-feedback"></span>
</div>
</div>
@if ($errors->any())
<div class="alert alert-danger">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="form-group row">
<div class="col-md-3 col-lg-2"></div>
<div class="col-md-4">
<a href="{{url('banks')}}" class="btn btn-danger btn-xs">
Atrás</a>
{!! Form::button("Guardar",["type" => "submit","class"=>"btn btn-primary btn-xs"])!!}
</div>
</div>
{!! Form::close() !!}
</div>
</div>
@endsection

View File

@@ -1,72 +0,0 @@
@if (Auth::user()->role_id >= 5)
<div class="container-fluid" style="height:100%">
<div>
@else
<div class="container" style="margin:0 1em">
@endif
<div class="row">
<div class="col-sm-7">
<h3>Tarjetas</h3>
</div>
<div class="col-sm-5">
<div class="pull-right">
{!! Form::open(['method'=>'GET','url'=>'cards','class'=>'navbar-form navbar-left','role'=>'search']) !!}
<div class="input-group">
<input class="form-control" id="search"
value="{{ request()->session()->get('search') }}"
placeholder="Buscar" name="search"
type="text" id="search"/>
<div class="input-group-btn">
<button type="submit" class="btn btn-primary">
<i class="fa fa-search" aria-hidden="true"></i>
</button>
</div>
</div>
{!! Form::close() !!}
</div>
</div>
</div>
<table class="table">
<thead>
<tr>
<th style="vertical-align: middle"><a href="{{url('cards?field=id&sort='.(request()->session()->get('sort')=='asc'?'desc':'asc'))}}">ID</a></th>
{{request()->session()->get('field')=='id'?(request()->session()->get('sort')=='asc'?'':''):''}}
<th style="vertical-align: middle"><a href="{{url('cards?field=user_id&sort='.(request()->session()->get('sort')=='asc'?'desc':'asc'))}}">Usuario</a></th>
{{request()->session()->get('field')=='user_id'?(request()->session()->get('sort')=='asc'?'':''):''}}
<th style="vertical-align: middle"><a href="{{url('cards?field=user_id&sort='.(request()->session()->get('sort')=='asc'?'desc':'asc'))}}">Openpay ID</a></th>
{{request()->session()->get('field')=='user_id'?(request()->session()->get('sort')=='asc'?'':''):''}}
<th style="vertical-align: middle"><a href="{{url('cards?field=token&sort='.(request()->session()->get('sort')=='asc'?'desc':'asc'))}}">Token</a></th>
{{request()->session()->get('field')=='token'?(request()->session()->get('sort')=='asc'?'':''):''}}
</tr>
</thead>
<tbody>
@php
$i=1;
@endphp
@foreach ($cards as $card)
<tr>
<th>{{ $card->id }}</th>
<th>{{ $card->user->name }}</th>
<td>{{ $card->user->openpay_id }}</td>
<td>{{ $card->token}}</td>
<td>
<input type="hidden" name="_method" value="delete"/>
<a class="btn btn-danger btn-xs" title="Delete"
href="javascript:if(confirm('¿Estás seguro de que quieres eliminar esta tarjeta?')) javascript:if(confirm('Usualmente no se deben eliminar tarjetas, ¿Estás seguro?')) ajaxDelete('{{url('cards/delete/'.$card->id)}}','{{csrf_token()}}')">
<i class="fa fa-trash"></i>
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
{{ $cards->links() }}
</div>

View File

@@ -111,7 +111,7 @@
@else
<li class="nav-item dropdown">
<a id="navbarDropdown" class="nav-link dropdown-toggle" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" v-pre style="padding-left:55px;">
<img src="{{ isset(Auth::user()->profile_photo) ? asset('img/users/' . Auth::user()->id . '/' . Auth::user()->profile_photo) : asset('img/users/default.png') }}" style="width:48px; height:auto; position:absolute; top:-5px; left:-1px; border-radius:50%"/>
<img src="{{ Auth::user()->profile_photo ?? asset('img/users/default.png') }}" style="width:48px; height:auto; position:absolute; top:-5px; left:-1px; border-radius:50%"/>
{{ Auth::user()->name }} <span class="caret"></span>
</a>

View File

@@ -2,7 +2,7 @@
@section('content')
<div id="content">
@include('cards.index')
@include('payment-batches.index')
</div>
<div class="loading">
<i class="fa fa-refresh fa-spin fa-2x fa-tw"></i>

View File

@@ -0,0 +1,56 @@
<div class="container-fluid">
<div class="row mb-3">
<div class="col">
<h3>Pagos Realizados</h3>
</div>
</div>
@if(session('error'))
<div class="alert alert-danger">{{ session('error') }}</div>
@endif
<table class="table">
<thead>
<tr>
<th>#</th>
<th>Archivo</th>
<th>Generado por</th>
<th>Fecha de generación</th>
<th>Rango de fechas</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($batches as $batch)
<tr>
<td>{{ $batch->id }}</td>
<td>{{ $batch->file_name }}</td>
<td>{{ optional($batch->user)->name ?? '—' }}</td>
<td>{{ $batch->created_at->format('d/m/Y H:i') }}</td>
<td>
@if($batch->date_from || $batch->date_to)
{{ $batch->date_from ? \Carbon\Carbon::parse($batch->date_from)->format('d/m/Y') : '—' }}
al
{{ $batch->date_to ? \Carbon\Carbon::parse($batch->date_to)->format('d/m/Y') : '—' }}
@else
Todos los registros
@endif
</td>
<td>
<a href="{{ url('payment-batches/' . $batch->id . '/download') }}"
class="btn btn-sm btn-outline-primary">
<i class="fa fa-download"></i> Descargar
</a>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-muted">Sin pagos realizados aún.</td>
</tr>
@endforelse
</tbody>
</table>
{{ $batches->links() }}
</div>

View File

@@ -13,27 +13,53 @@
$sortUrl = fn($f) => url('payments') . '?' . http_build_query(array_filter(['date_from' => $dateFrom, 'date_to' => $dateTo, 'field' => $f, 'sort' => $currentField === $f ? $nextSort : 'desc']));
@endphp
<div class="row">
<div class="col-sm-5">
<h3>Pagos a Proveedores</h3>
@if(session('warning'))
<div class="alert alert-warning">{{ session('warning') }}</div>
@endif
<div class="row align-items-center mb-2">
<div class="col-sm-4">
<h3 class="mb-0">Pagos a Proveedores</h3>
</div>
<div class="col-sm-7">
<div class="pull-right">
{!! Form::open(['method' => 'GET', 'url' => 'payments', 'class' => 'form-inline', 'role' => 'search']) !!}
<label class="mr-2 mb-0">Filtrar</label>
<input type="date" class="form-control mr-1" name="date_from" value="{{ $dateFrom }}">
<input type="date" class="form-control mr-1" name="date_to" value="{{ $dateTo }}">
<button type="submit" class="btn btn-primary mr-1">
<i class="fa fa-filter" aria-hidden="true"></i>
<div class="col-sm-8">
<div class="d-flex justify-content-end align-items-center flex-wrap" style="gap:.5rem">
{!! Form::open(['method' => 'GET', 'url' => 'payments', 'class' => 'd-flex align-items-center', 'style' => 'gap:.4rem']) !!}
<label class="mb-0 mr-1">Filtrar</label>
<input type="date" class="form-control form-control-sm" name="date_from" value="{{ $dateFrom }}">
<input type="date" class="form-control form-control-sm" name="date_to" value="{{ $dateTo }}">
<button type="submit" class="btn btn-sm btn-primary">
<i class="fa fa-filter"></i>
</button>
<a href="{{ url('payments') }}" class="btn btn-default">
<i class="fa fa-times" aria-hidden="true"></i>
<a href="{{ url('payments') }}" class="btn btn-sm btn-default">
<i class="fa fa-times"></i>
</a>
{!! Form::close() !!}
<form method="POST" action="{{ url('payments/generate') }}" id="generateForm">
@csrf
<input type="hidden" name="date_from" value="{{ $dateFrom }}">
<input type="hidden" name="date_to" value="{{ $dateTo }}">
<button type="button" class="btn btn-sm btn-success" onclick="confirmGenerate()">
<i class="fa fa-file-excel-o"></i> Generar Pagos
</button>
</form>
</div>
</div>
</div>
<script>
function confirmGenerate() {
var total = {{ $payments->total() }};
if (total === 0) {
alert('No hay contratos pendientes de pago.');
return;
}
if (confirm('Se generará un archivo Excel con ' + total + ' proveedor(es) y se marcarán sus contratos como pagados. ¿Continuar?')) {
document.getElementById('generateForm').submit();
}
}
</script>
<table class="table">
<thead>
<tr>

View File

@@ -21,11 +21,13 @@
gap: 1rem;
position: relative;
}
.chat-info .btn-verdict {
.chat-info .btn-actions {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 0.4rem;
}
.chat-info span { color: #495057; }
.chat-info strong { color: #212529; }
@@ -135,10 +137,16 @@
@if($report->veredict)
<span>Veredicto: <strong>{{ $report->veredict }}</strong></span>
@endif
<a class="btn btn-secondary btn-xs btn-verdict" title="Veredict"
href="{{ url('reports/veredict/' . $report->id) }}">
<i class="fa fa-gavel"></i>
</a>
<div class="btn-actions">
<button type="button" class="btn btn-info btn-xs" title="Ver detalles"
data-toggle="modal" data-target="#modalDetalles">
<i class="fa fa-info-circle"></i>
</button>
<a class="btn btn-secondary btn-xs" title="Veredicto"
href="{{ url('reports/veredict/' . $report->id) }}">
<i class="fa fa-gavel"></i>
</a>
</div>
</div>
{{-- Leyenda --}}
@@ -196,4 +204,76 @@
</div>
</div>
{{-- Modal fuera del chat-wrapper para evitar stacking context del flex --}}
<div class="modal fade" id="modalDetalles" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Detalles del Contrato #{{ $contract->id }}</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<h6 class="font-weight-bold mb-2">Información del contrato</h6>
<table class="table table-sm table-borderless mb-4">
<tr><th style="width:35%">Cliente</th><td>{{ $contract->user->name ?? '—' }}</td></tr>
<tr><th>Proveedor</th><td>{{ $contract->suppliers->company_name ?? '—' }}</td></tr>
<tr><th>Categoría</th><td>{{ $contract->categories->name ?? '—' }}</td></tr>
<tr><th>Dirección</th><td>{{ $contract->address }}</td></tr>
<tr><th>Cita</th><td>{{ $contract->appointment }}</td></tr>
<tr><th>Monto</th><td>${{ $contract->amount }}</td></tr>
<tr><th>Estado</th><td>{{ $contract->status->name ?? $contract->status_id }}</td></tr>
<tr><th>Transaction ID</th><td><code>{{ $contract->transaction_id ?? '—' }}</code></td></tr>
</table>
@if($nohome)
<hr>
<h6 class="font-weight-bold mb-2">Evidencia del proveedor (No Home)</h6>
<table class="table table-sm table-borderless mb-3">
<tr>
<th style="width:35%">Descripción</th>
<td>{{ $nohome->house_description ?? '—' }}</td>
</tr>
@if($nohome->location)
<tr>
<th>Coordenadas GPS</th>
<td>
{{ $nohome->location->getLatitude() }}, {{ $nohome->location->getLongitude() }}
<a href="https://www.google.com/maps?q={{ $nohome->location->getLatitude() }},{{ $nohome->location->getLongitude() }}"
target="_blank" class="btn btn-xs btn-outline-secondary ml-2">
<i class="fa fa-map-marker"></i> Ver en mapa
</a>
</td>
</tr>
@endif
</table>
@if($nohome->house_photo)
<img src="{{ $nohome->house_photo }}" alt="Foto del domicilio"
class="img-fluid rounded" style="max-height:400px;">
@endif
@else
<hr>
<p class="text-muted mb-0"><em>El proveedor no registró evidencia de visita.</em></p>
@endif
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cerrar</button>
</div>
</div>
</div>
</div>
@endsection
@section('js')
<script>
// main-content tiene overflow-y:auto que crea stacking context
// Mover el modal a body para que Bootstrap pueda posicionarlo correctamente
$(document).ready(function () {
$('#modalDetalles').appendTo('body');
});
</script>
@endsection

View File

@@ -17,6 +17,20 @@
<span id="error-name" class="invalid-feedback"></span>
</div>
</div>
<div class="form-group row">
{!! Form::label("contract_status","Acción sobre el contrato",["class"=>"col-form-label col-md-3 col-lg-2"]) !!}
<div class="col-md-8">
{!! Form::select("contract_status", [
'' => '— Sin cambio de estatus —',
'8' => 'Devuelto al cliente (reembolso Stripe)',
'9' => 'Disputa terminada (pagar al proveedor)',
], null, ["class"=>"form-control"]) !!}
<small class="text-muted">
"Devuelto al cliente" emite el reembolso en Stripe automáticamente.
"Disputa terminada" aparece en la sección de Pagos.
</small>
</div>
</div>
@if ($errors->any())
<div class="alert alert-danger">
<ul>

View File

@@ -15,12 +15,12 @@
<a href="/banks" class="sidebar-link {{ Request::is('banks*') ? 'active' : '' }}">
<i class="fa fa-university"></i> Bancos
</a>
<a href="/cards" class="sidebar-link {{ Request::is('cards*') ? 'active' : '' }}">
<i class="fa fa-credit-card"></i> Tarjetas
</a>
<a href="/payments" class="sidebar-link {{ Request::is('payments*') ? 'active' : '' }}">
<a href="/payments" class="sidebar-link {{ Request::is('payments') ? 'active' : '' }}">
<i class="fa fa-money"></i> Pagos
</a>
<a href="/payment-batches" class="sidebar-link {{ Request::is('payment-batches*') ? 'active' : '' }}">
<i class="fa fa-history"></i> Pagos Realizados
</a>
<a href="/postulations" class="sidebar-link {{ Request::is('postulations*') ? 'active' : '' }}">
<i class="fa fa-file-text"></i> Postulaciones
</a>

View File

@@ -28,7 +28,8 @@
'4' => 'Facturación',
'5' => 'Moderador',
'6' => 'Administrador',
'7' => 'SuperAdmin'
'7' => 'SuperAdmin',
'8' => 'Técnico'
), $user->roles->id) !!}
<span id="error-name" class="invalid-feedback"></span>
</div>
@@ -42,13 +43,7 @@
</div>
</div>
<div class="form-group row required">
{!! Form::label("openpay_id","Openpay ID",["class"=>"col-form-label col-md-3 col-lg-2"]) !!}
<div class="col-md-8">
{!! Form::text("openpay_id",null,["class"=>"form-control".($errors->has('openpay_id')?" is-invalid":""),'placeholder'=>'Openpay ID']) !!}
<span id="error-openpay_id" class="invalid-feedback"></span>
</div>
</div>
@if ($errors->any())
<div class="alert alert-danger">
<ul>

View File

@@ -35,6 +35,8 @@
<th style="vertical-align: middle"><a href="{{url('users?field=id&sort='.(request()->session()->get('sort')=='asc'?'desc':'asc'))}}">ID</a></th>
{{request()->session()->get('field')=='id'?(request()->session()->get('sort')=='asc'?'':''):''}}
<th style="vertical-align: middle">Foto</th>
<th style="vertical-align: middle"><a href="{{url('users?field=name&sort='.(request()->session()->get('sort')=='asc'?'desc':'asc'))}}">Nombre</a></th>
{{request()->session()->get('field')=='name'?(request()->session()->get('sort')=='asc'?'':''):''}}
@@ -70,6 +72,13 @@
@foreach ($users as $user)
<tr>
<th>{{ $user->id }}</th>
<td>
@if($user->profile_photo)
<img src="{{ $user->profile_photo }}" style="width:36px;height:36px;object-fit:cover;border-radius:50%;">
@else
<span class="text-muted"></span>
@endif
</td>
<td>{{ $user->name }}</td>
<td >{{ $user->email }}</td>
<td >{{ $user->roles->name }}</td>