Merge branch 'fix-estilos-vite': chat multimedia

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 12:31:09 -06:00
18 changed files with 400 additions and 1868 deletions
-29
View File
@@ -1,29 +0,0 @@
<?php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
class ChatMessageSent implements ShouldBroadcastNow
{
public $message;
public function __construct(Message $message)
{
$this->message = $message->load('user');
}
public function broadcastOn()
{
return new PrivateChannel(
'chat.' . $this->message->conversation_id
);
}
public function broadcastAs()
{
return 'ChatMessageSent';
}
}
+21 -12
View File
@@ -2,30 +2,39 @@
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class MessageSent implements ShouldBroadcast
class MessageSent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $message;
public function __construct(public Message $message){}
public function __construct($message)
public function broadcastOn(): array
{
$this->message = $message;
return [
new Channel('chat.' . $this->message->receiver_id),
];
}
public function broadcastOn()
public function broadcastWith(): array
{
return new Channel('chat');
}
public function broadcastAs()
{
return 'MessageSent';
return [
'id' => $this->message->id,
'body' => $this->message->body,
'attachment' => $this->message->attachment,
'attachment_type' => $this->message->attachment_type,
'created_at' => $this->message->created_at->format('H:i'),
'sender' => [
'id' => $this->message->sender->id,
'name' => $this->message->sender->name,
'avatar' => $this->message->sender->profile_photo_url ?? null,
],
];
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Broadcasting\PrivateChannel;
class UserStoppedTyping implements ShouldBroadcast
{
public $conversationId;
public $user;
public function __construct($conversationId, $user)
{
$this->conversationId = $conversationId;
$this->user = $user;
}
public function broadcastOn()
{
return new PrivateChannel('chat.' . $this->conversationId);
}
public function broadcastAs()
{
return 'UserStoppedTyping';
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class UserTyping implements ShouldBroadcast
{
public $conversationId;
public $user;
public function __construct($conversationId, $user)
{
$this->conversationId = $conversationId;
$this->user = $user;
}
public function broadcastOn()
{
return new PrivateChannel('chat.' . $this->conversationId);
}
public function broadcastAs()
{
return 'UserTyping';
}
}
+63 -55
View File
@@ -2,74 +2,82 @@
namespace App\Http\Controllers;
use App\Events\ChatMessageSent;
use App\Models\Conversation;
use App\Events\MessageSent;
use App\Models\Message;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ChatController extends Controller
{
public function send(Request $request)
{
$request->validate([
'conversation_id' => 'required|exists:conversations,id',
'message' => 'required|string'
]);
$message = Message::create([
'conversation_id' => $request->conversation_id,
'user_id' => auth()->id(),
'message' => $request->message
]);
// cargar usuario para que venga en el JSON
$message->load('user');
// enviar a los demás usuarios por websocket
broadcast(new ChatMessageSent($message))->toOthers();
return response()->json([
'message' => $message
]);
}
public function start(User $user)
// Lista de usuarios con quienes puedo chatear
public function index()
{
$authUser = auth()->user();
$users = User::where('id', '!=', auth()->id())->get();
return view('chat.index', compact('users'));
}
// buscar conversación existente
$conversation = Conversation::whereHas('users', function ($q) use ($authUser) {
$q->where('user_id', $authUser->id);
})
->whereHas('users', function ($q) use ($user) {
$q->where('user_id', $user->id);
})
->first();
// Mensajes entre yo y otro usuario
public function show(User $user)
{
$messages = Message::where(function ($q) use ($user) {
$q->where('user_id', auth()->id())
->where('receiver_id', $user->id);
})
->orWhere(function ($q) use ($user) {
$q->where('user_id', $user->id)
->where('receiver_id', auth()->id());
})
->with('sender')
->orderBy('created_at', 'asc')
->get();
// si no existe → crear
if (!$conversation) {
$conversation = Conversation::create();
// marcar como leídos
Message::where('user_id', $user->id)
->where('receiver_id', auth()->id())
->whereNull('read_at')
->update(['read_at' => now()]);
$conversation->users()->attach([
$authUser->id,
$user->id
]);
return view('chat.show', compact('user', 'messages'));
}
// Enviar mensaje
public function send(Request $request)
{
$request->validate([
'receiver_id' => 'required|exists:users,id',
'body' => 'nullable|string',
'attachment' => 'nullable|file|max:20480',
]);
$attachment = null;
$attachmentType = null;
if ($request->hasFile('attachment')) {
$file = $request->file('attachment');
$attachment = $file->store('chat', 'public');
$mime = $file->getMimeType();
if (str_starts_with($mime, 'image')) $attachmentType = 'image';
elseif (str_starts_with($mime, 'video')) $attachmentType = 'video';
elseif (str_starts_with($mime, 'audio')) $attachmentType = 'audio';
else $attachmentType = 'file';
}
return response()->json($conversation);
}
$message = Message::create([
'user_id' => auth()->id(),
'receiver_id' => $request->receiver_id,
'body' => $request->body,
'attachment' => $attachment,
'attachment_type' => $attachmentType,
]);
public function show($id)
{
$conversation = Conversation::with([
'messages' => function ($query) {
$query->orderBy('created_at', 'asc');
},
'messages.user'
])->findOrFail($id);
$message->load('sender');
return view('chat.show', compact('conversation'));
broadcast(new MessageSent($message))->toOthers();
$message->created_at = $message->created_at->format('H:i');
return response()->json($message);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Friendship extends Model
{
protected $fillable = [
'sender_id',
'receiver_id',
'status',
];
public function sender()
{
return $this->belongsTo(User::class, 'sender_id');
}
public function receiver()
{
return $this->belongsTo(User::class, 'receiver_id');
}
}
+16 -4
View File
@@ -7,13 +7,25 @@ use Illuminate\Database\Eloquent\Model;
class Message extends Model
{
protected $fillable = [
'conversation_id',
'user_id',
'message'
'receiver_id',
'body',
'attachment',
'attachment_type',
'read_at',
];
public function user()
protected $casts = [
'read_at' => 'datetime',
];
public function sender()
{
return $this->belongsTo(User::class);
return $this->belongsTo(User::class, 'user_id');
}
public function receiver()
{
return $this->belongsTo(User::class, 'receiver_id');
}
}
@@ -13,9 +13,12 @@ return new class extends Migration
{
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('message');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('receiver_id')->constrained('users')->onDelete('cascade');
$table->text('body')->nullable(); // nullable porque puede ser solo archivo
$table->string('attachment')->nullable(); // ruta del archivo
$table->string('attachment_type')->nullable(); // 'image', 'audio', 'video'
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('friendships', function (Blueprint $table) {
$table->id();
$table->foreignId('sender_id')->constrained('users')->onDelete('cascade');
$table->foreignId('receiver_id')->constrained('users')->onDelete('cascade');
$table->enum('status', ['pending', 'accepted', 'rejected', 'blocked'])->default('pending');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('friendships');
}
};
+26 -1457
View File
File diff suppressed because it is too large Load Diff
+3 -10
View File
@@ -1,24 +1,17 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"dev:all": "concurrently \"php artisan serve\" \"php artisan queue:work\" \"php artisan reverb:start\" \"npm run dev\""
"dev": "vite",
"build": "vite build",
"dev:all": "concurrently \"php artisan serve\" \"php artisan queue:work\" \"php artisan reverb:start\" \"npm run dev\""
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"@tailwindcss/vite": "^4.0.0",
"autoprefixer": "^10.4.16",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-echo": "^2.3.0",
"laravel-vite-plugin": "^2.0.0",
"postcss": "^8.4.32",
"pusher-js": "^8.4.0",
"tailwindcss": "^3.4.0",
"vite": "^7.0.7"
}
}
-7
View File
@@ -1,7 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
[x-cloak] {
display: none;
}
+39
View File
@@ -0,0 +1,39 @@
@extends('layouts.app')
@section('content')
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="card shadow" style="height:calc(100vh - 120px); display:flex; flex-direction:column;">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="fas fa-comments"></i> Chats</h5>
</div>
<div class="card-body p-0" style="overflow-y: auto;">
@foreach($users as $user)
<a href="{{ route('chat.show', $user) }}" class="text-decoration-none">
<div class="d-flex align-items-center p-3 border-bottom chat-user-item">
<img src="{{ $user->profile_photo_url }}"
class="rounded-circle mr-3"
width="45" height="45"
style="object-fit:cover">
<div>
<div class="font-weight-bold text-dark">{{ $user->name }}</div>
<small class="text-muted">{{ $user->getRoleNames()->first() }}</small>
</div>
</div>
</a>
@endforeach
</div>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<style>
#content { overflow: hidden !important; }
.container-fluid { height: calc(100vh - 120px); }
.sticky-footer { display: none !important; }
</style>
@endsection
+160 -212
View File
@@ -1,231 +1,179 @@
@extends('layouts.landing')
@section('title',"Chat")
@extends('layouts.app')
@section('content')
<div class="container-fluid">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
Mensajes
</h6>
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<img src="{{ $user->profile_photo_url }}"
class="rounded-circle mr-2"
width="38" height="38"
style="object-fit:cover">
<strong>{{ $user->name }}</strong>
</div>
<a href="{{ route('chat.index') }}" class="btn btn-sm btn-light shadow-sm">
<i class="fas fa-arrow-left fa-sm"></i> Regresar
</a>
</div>
<div class="card-body">
{{-- CONTENEDOR CHAT --}}
<div id="chat-app"
data-conversation-id="{{ $conversation->id }}">
<div id="messages"
style="height:70vh;overflow-y:auto;padding:10px;background:#f5f5f5;border-radius:10px;">
@foreach($conversation->messages->sortBy('created_at') as $message)
<div class="message {{ $message->user_id == auth()->id() ? 'mine' : 'theirs' }}">
<div class="bubble">
<strong>{{ $message->user->name }}</strong><br>
{{ $message->message }}
</div>
<div class="card-body overflow-auto flex-grow-1 p-3" id="chat-messages">
@foreach($messages as $msg)
<div class="d-flex mb-2 {{ $msg->user_id === auth()->id() ? 'justify-content-end' : 'justify-content-start' }}">
<div class="px-3 py-2 rounded {{ $msg->user_id === auth()->id() ? 'bg-primary text-white' : 'bg-light' }}"
style="max-width:65%">
@if($msg->body)
<div>{{ $msg->body }}</div>
@endif
@if($msg->attachment)
@if($msg->attachment_type === 'image')
<img src="{{ Storage::url($msg->attachment) }}" class="img-fluid rounded mt-1">
@elseif($msg->attachment_type === 'audio')
<audio controls class="mt-1" style="max-width:250px">
<source src="{{ Storage::url($msg->attachment) }}">
</audio>
@elseif($msg->attachment_type === 'video')
<video controls class="mt-1 rounded" style="max-width:250px">
<source src="{{ Storage::url($msg->attachment) }}">
</video>
@else
<a href="{{ Storage::url($msg->attachment) }}" target="_blank" class="btn btn-sm btn-light mt-1">
<i class="fas fa-file"></i> Ver archivo
</a>
@endif
@endif
<div class="text-right mt-1" style="font-size:0.7rem; opacity:0.7">
{{ $msg->created_at->format('H:i') }}
</div>
@endforeach
</div>
</div>
@endforeach
</div>
<div id="typingIndicator"
style="font-size:12px;color:#888;padding:8px 5px;height:20px;">
</div>
{{-- INPUT --}}
<div style="margin-top:10px;display:flex;gap:10px;">
<input id="messageInput"
class="form-control"
placeholder="Escribe mensaje...">
<button onclick="sendMessage()"
class="btn btn-primary">
Enviar
<div class="card-footer p-2">
<form id="chat-form" enctype="multipart/form-data">
@csrf
<input type="hidden" name="receiver_id" value="{{ $user->id }}">
<div class="input-group">
<input type="text" name="body" id="chat-input"
class="form-control rounded-pill mr-2"
placeholder="Escribe un mensaje..." autocomplete="off">
<label class="btn btn-light mr-1 mb-0" title="Adjuntar archivo">
<i class="fas fa-paperclip"></i>
<input type="file" name="attachment" id="attachment" hidden>
</label>
<button type="submit" class="btn btn-primary rounded-pill px-4">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
<div id="attachment-preview" class="mt-1 text-muted small"></div>
</form>
</div>
</div>
</div>
@endsection
@section('scripts')
<style>
.sticky-footer { display: none !important; }
.card { height: calc(100vh - 160px); display: flex; flex-direction: column; }
#chat-messages { flex: 1; overflow-y: auto; }
</style>
<script>
const receiverId = {{ $user->id }};
const authId = {{ auth()->id() }};
const chatMessages = document.getElementById('chat-messages');
// scroll al fondo
function scrollBottom() {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
scrollBottom();
// preview archivo
document.getElementById('attachment').addEventListener('change', function() {
const name = this.files[0]?.name ?? '';
document.getElementById('attachment-preview').textContent = name ? '📎 ' + name : '';
});
// enviar mensaje
document.getElementById('chat-form').addEventListener('submit', function(e) {
e.preventDefault();
const form = this;
const data = new FormData(form);
axios.post('{{ route("chat.send") }}', data, {
headers: { 'X-Socket-ID': window.Echo.socketId() }
}).then(response => {
appendMessage(response.data, true);
scrollBottom();
form.reset();
document.getElementById('attachment-preview').textContent = '';
});
});
function buildAttachmentHtml(msg, isMine) {
if (!msg.attachment) return '';
const url = '/storage/' + msg.attachment;
const linkClass = isMine ? 'text-white' : 'text-dark';
switch (msg.attachment_type) {
case 'image':
return `<img src="${url}" class="img-fluid rounded mt-1" style="max-width:240px; cursor:pointer"
onclick="window.open('${url}','_blank')">`;
case 'video':
return `<video controls class="mt-1 rounded" style="max-width:240px">
<source src="${url}">Tu navegador no soporta video.
</video>`;
case 'audio':
return `<audio controls class="mt-1" style="max-width:240px">
<source src="${url}">Tu navegador no soporta audio.
</audio>`;
default:
return `<a href="${url}" target="_blank" class="btn btn-sm ${isMine ? 'btn-light' : 'btn-secondary'} mt-1">
<i class="fas fa-file mr-1"></i>Ver archivo
</a>`;
}
}
function appendMessage(msg, isMine) {
const div = document.createElement('div');
div.className = `d-flex mb-2 ${isMine ? 'justify-content-end' : 'justify-content-start'}`;
const time = typeof msg.created_at === 'string' && msg.created_at.length > 5
? new Date(msg.created_at).toLocaleTimeString('es-MX', {hour:'2-digit', minute:'2-digit'})
: msg.created_at;
div.innerHTML = `
<div class="px-3 py-2 rounded ${isMine ? 'bg-primary text-white' : 'bg-light'}" style="max-width:65%">
${msg.body ? `<div>${msg.body}</div>` : ''}
${buildAttachmentHtml(msg, isMine)}
<div class="text-right mt-1" style="font-size:0.7rem; opacity:0.7">${time}</div>
</div>`;
chatMessages.appendChild(div);
}
// esperar a que Echo esté disponible
function waitForEcho(callback) {
if (window.Echo) {
callback();
} else {
setTimeout(() => waitForEcho(callback), 100);
}
}
waitForEcho(() => {
window.Echo.channel(`chat.${authId}`)
.listen('MessageSent', (e) => {
if (e.sender.id !== receiverId) return;
appendMessage(e, false);
scrollBottom();
});
});
</script>
@endsection
@push('scripts')
<script>
window.USER_ID = {{ auth()->id() }};
</script>
<script>
let conversationId;
let typingTimeout;
let typingActive = false;
document.addEventListener('DOMContentLoaded', function () {
const chatApp = document.getElementById('chat-app');
const messagesDiv = document.getElementById('messages');
const input = document.getElementById('messageInput');
if (!chatApp || !messagesDiv) {
console.error('Chat DOM no encontrado');
return;
}
conversationId = chatApp.dataset.conversationId;
console.log('Chat conectado a:', conversationId);
/*
|--------------------------------------------------------------------------
| ESCUCHAR "USUARIO ESCRIBIENDO"
|--------------------------------------------------------------------------
*/
window.Echo.private(`chat.${conversationId}`)
.listen('.UserTyping', (e) => {
if (e.user.id == window.USER_ID) return;
document.getElementById('typingIndicator').innerText =
e.user.name + ' está escribiendo...';
});
/*
|--------------------------------------------------------------------------
| ESCUCHAR "USUARIO DEJÓ DE ESCRIBIR"
|--------------------------------------------------------------------------
*/
window.Echo.private(`chat.${conversationId}`)
.listen('.UserStoppedTyping', (e) => {
if (e.user.id == window.USER_ID) return;
document.getElementById('typingIndicator').innerText = '';
});
/*
|--------------------------------------------------------------------------
| ESCUCHAR MENSAJES
|--------------------------------------------------------------------------
*/
window.Echo.private(`chat.${conversationId}`)
.listen('.ChatMessageSent', (e) => {
if (e.message.user_id == window.USER_ID) return;
appendMessage(e.message);
document.getElementById('typingIndicator').innerText = '';
});
/*
|--------------------------------------------------------------------------
| DETECTAR ESCRITURA
|--------------------------------------------------------------------------
*/
input.addEventListener('input', () => {
if (!typingActive) {
typingActive = true;
axios.post('/chat/typing', {
conversation_id: conversationId
});
}
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
typingActive = false;
axios.post('/chat/stop-typing', {
conversation_id: conversationId
});
}, 700);
});
scrollChat();
});
function sendMessage() {
let input = document.getElementById('messageInput');
if (!input.value.trim()) return;
typingActive = false;
axios.post('/chat/stop-typing', {
conversation_id: conversationId
});
axios.post('/chat/send', {
conversation_id: conversationId,
message: input.value
})
.then(response => {
appendMessage(response.data.message);
document.getElementById('typingIndicator').innerText = '';
});
input.value = '';
}
function appendMessage(message) {
const messagesDiv = document.getElementById('messages');
let mine = message.user_id == window.USER_ID;
let div = document.createElement('div');
div.className = 'message ' + (mine ? 'mine' : 'theirs');
div.innerHTML = `
<div class="bubble">
<strong>${message.user.name}</strong><br>
${message.message}
</div>
`;
messagesDiv.appendChild(div);
scrollChat();
}
function scrollChat() {
const container = document.getElementById('messages');
container.scrollTop = container.scrollHeight;
}
</script>
@endpush
@@ -197,11 +197,11 @@
<!-- Heading -->
<div class="sidebar-heading" style="color:white">Adicionales</div>
{{-- <li class="nav-item">
<a class="nav-link" href="chats.php">
<li class="nav-item">
<a class="nav-link" href="{{ route('chat.index') }}">
<i class="fas fa-fw fa-comments" style="color: #e8e8e8;"></i>
<span>Chats</span></a>
</li> --}}
</li>
@can('asistencia')
+3 -5
View File
@@ -4,11 +4,7 @@
<html lang="es">
<head>
@include('layouts._partials.head')
{{-- @vite([ 'resources/js/app.js']) --}}
{{-- Livewire styles --}}
@livewireStyles
</head>
<body id="page-top" class="sidebar-toggled">
@@ -26,13 +22,15 @@
</div>
</div>
{{-- JS base --}}
@include('layouts._partials.scripts')
{{-- Livewire --}}
@livewireScripts
{{-- Vite (Echo/Reverb) --}}
@vite(['resources/js/app.js'])
{{-- scripts por vista --}}
@yield('scripts')
+3 -9
View File
@@ -1,17 +1,11 @@
<?php
use Illuminate\Support\Facades\Broadcast;
use App\Models\Conversation;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
Broadcast::channel('chat.{conversationId}', function ($user, $conversationId) {
return Conversation::where('id', $conversationId)
->whereHas('users', function ($q) use ($user) {
$q->where('user_id', $user->id);
})
->exists();
Broadcast::channel('chat.{receiverId}', function ($user, $receiverId) {
return (int) $user->id === (int) $receiverId;
});
+3 -6
View File
@@ -52,12 +52,9 @@ Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified',
Route::resource('/carrera',CarreraController::class);
Route::resource('/code',CodeController::class);
Route::resource('/concepto',ConceptoController::class);
// Route::get('/chat/start/{user}',[ChatController::class, 'start']);
// Route::get('/chat/{conversation}',[ChatController::class, 'show']);
// Route::post('/chat/send',[ChatController::class,'send']);
// Route::post('/chat/typing', function (Request $request) {broadcast(new \App\Events\UserTyping($request->conversation_id,auth()->user()))->toOthers();return response()->json();});
// Route::post('/chat/stop-typing', function (Request $request) {broadcast(new \App\Events\UserStoppedTyping($request->conversation_id,auth()->user()))->toOthers();return response()->json();});
Route::resource('/docente',DocenteController::class);
Route::get('/chat', [ChatController::class, 'index'])->name('chat.index');
Route::get('/chat/{user}', [ChatController::class, 'show'])->name('chat.show');
Route::post('/chat/send', [ChatController::class, 'send'])->name('chat.send');Route::resource('/docente',DocenteController::class);
Route::resource('/documento',DocumentoController::class);
Route::get('/documento/ver/{id}', function ($id) { $doc = Documento::findOrFail($id); $alumno = auth()->user()->alumnos()->first(); if (!$alumno || $doc->alumno_id !== $alumno->id) { abort(403); } return response()->file( storage_path('app/public/'.$doc->archivo) ); });
Route::get('/documento/ver/{id}', [DocumentoController::class, 'ver'])->name('documento.ver');