añadir nuevas funcionalidades
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

This commit is contained in:
2025-04-30 20:56:28 +02:00
parent 883daf32ed
commit f97a7a8498
27 changed files with 2359 additions and 875 deletions

View File

@@ -47,24 +47,24 @@ class ProjectController extends Controller
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'required|string',
'status' => 'required|in:active,inactive',
'team' => 'sometimes|array',
'team.*' => 'exists:users,id',
'description' => 'nullable|string',
'status' => 'required|in:Activo,Inactivo',
//'team' => 'sometimes|array',
//'team.*' => 'exists:users,id',
'project_image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
'address' => 'nullable|string|max:255',
'province' => 'nullable|string|max:100',
'country' => 'nullable|string|size:2',
//'country' => 'nullable|string|size:2',
'postal_code' => 'nullable|string|max:10',
'latitude' => 'required|numeric|between:-90,90',
'longitude' => 'required|numeric|between:-180,180',
'icon' => 'nullable|in:'.implode(',', config('project.icons')),
//'icon' => 'nullable|in:'.implode(',', config('project.icons')),
'start_date' => 'nullable|date',
'deadline' => 'nullable|date|after:start_date',
'categories' => 'array|exists:categories,id',
'categories' => 'nullable|array|exists:categories,id',
//'categories' => 'required|array',
'categories.*' => 'exists:categories,id',
'documents.*' => 'file|max:5120|mimes:pdf,docx,xlsx,jpg,png'
//'categories.*' => 'exists:categories,id',
//'documents.*' => 'file|max:5120|mimes:pdf,docx,xlsx,jpg,png'
]);
@@ -98,12 +98,10 @@ class ProjectController extends Controller
}
}
return redirect()->route('projects.show', $project)
->with('success', 'Proyecto creado exitosamente');
return redirect()->route('projects.show', $project)->with('success', 'Proyecto creado exitosamente');
} catch (\Exception $e) {
return back()->withInput()
->with('error', 'Error al crear el proyecto: ' . $e->getMessage());
return back()->withInput()->with('error', 'Error al crear el proyecto: ' . $e->getMessage());
}
}

View File

@@ -5,7 +5,14 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use App\Http\Requests\UpdateUserRequest;
use App\Rules\PasswordRule;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class UserController extends Controller
@@ -13,7 +20,7 @@ class UserController extends Controller
public function index()
{
$this->authorize('viewAny', User::class);
$users = User::with('roles')->paginate(10);
$users = User::paginate(10);
return view('users.index', compact('users'));
}
@@ -27,24 +34,60 @@ class UserController extends Controller
public function store(Request $request)
{
$this->authorize('create', User::class);
$data = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
'roles' => 'array'
]);
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password'])
]);
$user->syncRoles($data['roles'] ?? []);
return redirect()->route('users.index')
->with('success', 'Usuario creado exitosamente');
try {
// Validación de datos
$validated = $request->validate([
'title' => 'nullable|string|max:10',
'first_name' => 'required|string|max:50',
'last_name' => 'required|string|max:50',
'username' => 'required|string|unique:users|max:30',
'password' => ['required',
new PasswordRule(
minLength: 12,
requireUppercase: true,
requireNumeric: true,
requireSpecialCharacter: true,
//uncompromised: true, // Verificar contra Have I Been Pwned
//requireLetters: true
),
Password::defaults()->mixedCase()->numbers()->symbols()
->uncompromised(3) // Número mínimo de apariciones en brechas
],
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'email' => 'required|email|unique:users',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255'
]);
// Creación del usuario
$user = User::create([
'title' => $validated['title'],
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'username' => $validated['username'],
'password' => Hash::make($validated['password']),
'email' => $validated['email'],
'phone' => $validated['phone'],
'address' => $validated['address'],
'access_start' => $validated['start_date'],
'access_end' => $validated['end_date'],
'is_active' => true
]);
if ($request->hasFile('photo')) {
$path = $request->file('photo')->store('public/photos');
$user->profile_photo_path = basename($path);
$user->save();
}
// Asignación de roles (opcional, usando Spatie Permissions)
// $user->assignRole('user');
return redirect()->route('users.index')->with('success', 'Usuario creado exitosamente.')->with('temp_password', $validated['password']);;
} catch (\Exception $e) {
return back()->withInput()->with('error', 'Error al crear el usuario: ' . $e->getMessage());
}
}
public function edit(User $user)
@@ -52,17 +95,114 @@ class UserController extends Controller
$this->authorize('update', $user);
$roles = Role::all();
$userRoles = $user->roles->pluck('id')->toArray();
return view('users.edit', compact('user', 'roles', 'userRoles'));
return view('users.create', compact('user', 'roles', 'userRoles'));
}
public function update(UpdateUserRequest $request, User $user)
public function update(Request $request, User $user)
{
$user->update($request->validated());
$user->syncRoles($request->roles);
return redirect()->route('users.index')
->with('success', 'Usuario actualizado correctamente');
try {
// Validación de datos
$validated = $request->validate([
'title' => 'nullable|string|max:10',
'first_name' => 'required|string|max:50',
'last_name' => 'required|string|max:50',
'username' => [
'required',
'string',
'max:30',
Rule::unique('users')->ignore($user->id)
],
'password' => [
'nullable',
Password::min(12)
->mixedCase()
->numbers()
->symbols()
->uncompromised(3)
],
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'email' => [
'required',
'email',
Rule::unique('users')->ignore($user->id)
],
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255'
]);
// Preparar datos para actualización
$updateData = [
'title' => $validated['title'],
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'username' => $validated['username'],
'email' => $validated['email'],
'phone' => $validated['phone'],
'address' => $validated['address'],
'access_start' => $validated['start_date'],
'access_end' => $validated['end_date'],
'is_active' => $request->has('is_active') // Si usas un checkbox
];
// Actualizar contraseña solo si se proporciona
if (!empty($validated['password'])) {
$updateData['password'] = Hash::make($validated['password']);
}
if ($request->hasFile('photo')) {
// Eliminar foto anterior si existe
if ($user->prfile_photo_path) {
Storage::delete('public/photos/'.$user->profile_photo_path);
}
$path = $request->file('photo')->store('public/photos');
$user->update(['profile_photo_path' => basename($path)]);
}
// Actualizar el usuario
$user->update($updateData);
// Redireccionar con mensaje de éxito
return redirect()->route('users.show', $user)
->with('success', 'Usuario actualizado exitosamente');
} catch (ValidationException $e) {
// Redireccionar con errores de validación
return redirect()->back()->withErrors($e->validator)->withInput();
} catch (QueryException $e) {
// Manejar errores de base de datos
$errorCode = $e->errorInfo[1];
$errorMessage = 'Error al actualizar el usuario: ';
if ($errorCode == 1062) {
$errorMessage .= 'El nombre de usuario o correo electrónico ya está en uso';
} else {
$errorMessage .= 'Error en la base de datos';
}
Log::error("Error actualizando usuario ID {$user->id}: " . $e->getMessage());
return redirect()->back()->with('error', $errorMessage)->withInput();
} catch (\Exception $e) {
// Manejar otros errores
Log::error("Error general actualizando usuario ID {$user->id}: " . $e->getMessage());
return redirect()->back()->with('error', 'Ocurrió un error inesperado al actualizar el usuario')->withInput();
}
}
public function show(User $user)
{
$previousUser = User::where('id', '<', $user->id)->latest('id')->first();
$nextUser = User::where('id', '>', $user->id)->oldest('id')->first();
return view('users.show', [
'user' => $user,
'previousUser' => $previousUser,
'nextUser' => $nextUser,
'permissionGroups' => Permission::all()->groupBy('group')
]);
}
public function updatePassword(Request $request, User $user)

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Livewire;
use Livewire\Component;
class CountrySelect extends Component
{
public $selectedCountry;
public $countrySearch = '';
public $search = '';
public $isOpen = false;
public $initialCountry;
protected $rules = [
'selectedCountry' => 'required',
];
public function mount($initialCountry = null)
{
$this->selectedCountry = $initialCountry;
}
public function selectCountry($code)
{
$this->selectedCountry = $code;
$this->isOpen = false;
$this->search = ''; // Limpiar la búsqueda al seleccionar
}
public function render()
{
$countries = collect(config('countries'))
->when($this->search, function($collection) {
// Corregimos el filtrado aquí
return $collection->filter(function($name, $code) {
$searchLower = strtolower($this->search);
$nameLower = strtolower($name);
$codeLower = strtolower($code);
return str_contains($nameLower, $searchLower) ||
str_contains($codeLower, $searchLower);
});
});
return view('livewire.country-select', compact('countries'));
}
public function formattedCountry($code, $name)
{
return $this->getFlagEmoji($code).' '.$name.' ('.strtoupper($code).')';
}
protected function getFlagEmoji($countryCode)
{
$countryCode = strtoupper($countryCode);
$flagOffset = 0x1F1E6;
$asciiOffset = 0x41;
$firstChar = ord($countryCode[0]) - $asciiOffset + $flagOffset;
$secondChar = ord($countryCode[1]) - $asciiOffset + $flagOffset;
return mb_convert_encoding('&#'.intval($firstChar).';', 'UTF-8', 'HTML-ENTITIES')
. mb_convert_encoding('&#'.intval($secondChar).';', 'UTF-8', 'HTML-ENTITIES');
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithFileUploads;
use Illuminate\Support\Facades\Storage;
class ImageUploader extends Component
{
use WithFileUploads;
public $photo;
public $currentImage;
public $fieldName;
public $placeholder;
public $storagePath = 'tmp/uploads';
protected $rules = [
'photo' => 'nullable|image|max:2048', // 2MB Max
];
public function mount($fieldName = 'photo', $currentImage = null, $placeholder = null)
{
$this->fieldName = $fieldName;
$this->currentImage = $currentImage;
$this->placeholder = $placeholder ?? asset('images/default-avatar.png');
}
public function updatedPhoto()
{
$this->validate([
'photo' => 'image|max:2048', // 2MB Max
]);
}
public function removePhoto()
{
$this->photo = null;
$this->currentImage = null;
}
public function save()
{
$this->validate();
if ($this->photo) {
$path = $this->photo->store($this->storagePath);
if ($this->model) {
// Eliminar imagen anterior si existe
if ($this->model->{$this->fieldName}) {
Storage::delete($this->model->{$this->fieldName});
}
$this->model->{$this->fieldName} = $path;
$this->model->save();
}
$this->currentUrl = Storage::url($path);
$this->showSavedMessage = true;
$this->photo = null; // Limpiar el input de subida
}
}
protected function getCurrentImageUrl()
{
if ($this->model && $this->model->{$this->fieldName}) {
return Storage::url($this->model->{$this->fieldName});
}
return $this->placeholder;
}
public function render()
{
return view('livewire.image-uploader');
}
}

View File

@@ -65,7 +65,7 @@ class ProjectShow extends Component
]);
$this->reset('folderName');
$this->project->load('rootFolders'); // Recargar carpetas raíz
$this->project->load('rootFolders'); // Recargar carpetas raíz
if ($this->currentFolder) {
$this->currentFolder->load('children'); // Recargar hijos si está en una subcarpeta
}
@@ -118,9 +118,6 @@ class ProjectShow extends Component
public function render()
{
return view('livewire.project-show')
->layout('layouts.livewire-app', [
'title' => $this->project->name
]);
return view('livewire.project-show');
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Livewire;
use App\Models\User;
use Livewire\Component;
use Livewire\WithFileUploads;
use Illuminate\Support\Facades\Storage;
class UserPhotoUpload extends Component
{
use WithFileUploads;
public $photo;
public $existingPhoto;
public $tempPhoto;
public $userId;
protected $listeners = ['photoUpdated' => 'refreshPhoto'];
public function mount($existingPhoto = null, $userId = null)
{
$this->existingPhoto = $existingPhoto;
$this->userId = $userId;
}
public function uploadPhoto()
{
$this->validate([
'photo' => 'image|max:2048|mimes:jpg,png,jpeg,gif',
]);
$this->tempPhoto = $this->photo->temporaryUrl();
}
public function updatedPhoto()
{
$this->validate([
'photo' => 'image|max:2048|mimes:jpg,png,jpeg,gif',
]);
$this->tempPhoto = $this->photo->temporaryUrl();
// Emitir evento con la foto temporal
$this->emit('photoUploaded', $this->photo->getRealPath());
}
public function removePhoto()
{
$this->reset('photo', 'tempPhoto');
}
public function deletePhoto()
{
if ($this->existingPhoto) {
Storage::delete('public/photos/' . $this->existingPhoto);
if ($this->userId) {
$user = User::find($this->userId);
$user->update(['profile_photo_path' => null]);
}
$this->existingPhoto = null;
$this->emit('photoDeleted');
}
}
public function refreshPhoto()
{
$this->existingPhoto = User::find($this->userId)->profile_photo_path;
}
public function render()
{
return view('livewire.user-photo-upload', [
//'photo' => $this->photo,
'tempPhoto' => $this->tempPhoto,
'existingPhoto' => $this->existingPhoto,
]);
}
}

109
app/Livewire/UserTable.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\User;
class UserTable extends Component
{
use WithPagination;
public $sortField = 'first_name';
public $sortDirection = 'asc';
public $columns = [
'full_name' => true,
'is_active' => true,
'username' => true,
'email' => true,
'phone' => false,
'access_start' => false,
'created_at' => false,
'is_active' => false,
];
public $filters = [
'full_name' => '',
'is_active' => '',
'username' => '',
'email' => '',
'phone' => '',
'access_start' => '',
'created_at' => ''
];
protected $queryString = [
'filters' => ['except' => ''],
'columns' => ['except' => '']
];
public function mount()
{
// Recuperar preferencias de columnas de la sesión
$this->columns = session('user_columns', $this->columns);
}
public function updatedColumns()
{
// Guardar preferencias en sesión
session(['user_columns' => $this->columns]);
}
public function sortBy($field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortDirection = 'asc';
}
$this->sortField = $field;
}
public function render()
{
$users = User::query()
->when($this->filters['full_name'], fn($q, $search) =>
$q->whereRaw("CONCAT(title, ' ', first_name, ' ', last_name) LIKE ?", ["%$search%"]))
->when($this->sortField === 'full_name', fn($q) =>
$q->orderByRaw("CONCAT(title, ' ', first_name, ' ', last_name) " . $this->sortDirection)
)
->when($this->sortField === 'created_at', fn($q) =>
$q->orderBy('created_at', $this->sortDirection)
)
->when($this->sortField === 'access_start', fn($q) =>
$q->orderBy('access_start', $this->sortDirection)
)
->when(in_array($this->sortField, ['username', 'email', 'phone']), fn($q) =>
$q->orderBy($this->sortField, $this->sortDirection)
)
->when($this->filters['full_name'], fn($q, $search) =>
$q->whereRaw("CONCAT(title, ' ', first_name, ' ', last_name) LIKE ?", ["%$search%"]))
->when($this->filters['username'], fn($q, $search) =>
$q->where('username', 'LIKE', "%$search%"))
->when($this->filters['email'], fn($q, $search) =>
$q->where('email', 'LIKE', "%$search%"))
->when($this->filters['phone'], fn($q, $search) =>
$q->where('phone', 'LIKE', "%$search%"))
->when($this->filters['access_start'], fn($q, $date) =>
$q->whereDate('access_start', $date))
->when($this->filters['created_at'], fn($q, $date) =>
$q->whereDate('created_at', $date))
->paginate(10);
return view('livewire.user-table', [
'users' => $users,
'available_columns' => [
'full_name' => 'Nombre Completo',
'username' => 'Usuario',
'email' => 'Correo',
'phone' => 'Teléfono',
'access_start' => 'Fecha de acceso',
'created_at' => 'Fecha creación',
'is_active' => 'Estado'
]
]);
}
}

View File

@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Spatie\Permission\Traits\HasRoles;
@@ -21,9 +22,18 @@ class User extends Authenticatable
* @var list<string>
*/
protected $fillable = [
'name',
'title',
'first_name',
'last_name',
'username',
'email',
'password',
'phone',
'address',
'access_start',
'access_end',
'is_active',
'profile_photo_path',
];
/**
@@ -39,26 +49,25 @@ class User extends Authenticatable
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
* @var list<string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_protected' => 'boolean',
];
}
protected $casts = [
'email_verified_at' => 'datetime',
'access_start' => 'date',
'access_end' => 'date',
'is_active' => 'boolean'
];
/**
* Get the user's initials
*/
public function initials(): string
{
return Str::of($this->name)
/*return Str::of($this->name)
->explode(' ')
->map(fn (string $name) => Str::of($name)->substr(0, 1))
->implode('');
->implode('');*/
return Str::of('');
}
public function groups()
@@ -89,5 +98,16 @@ class User extends Authenticatable
return $group->hasAnyPermission($permissions);
});
}
public function getFullNameAttribute()
{
return "{$this->title} {$this->first_name} {$this->last_name}";
}
// Accesor para la URL completa
public function getProfilePhotoUrlAttribute()
{
return $this->profile_photo ? Storage::url($this->profile_photo) : asset('images/default-user.png');
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Str;
class PasswordRule implements ValidationRule
{
protected $minLength;
protected $requireUppercase;
protected $requireNumeric;
protected $requireSpecialCharacter;
public function __construct(
int $minLength = 8,
bool $requireUppercase = true,
bool $requireNumeric = true,
bool $requireSpecialCharacter = true
) {
$this->minLength = $minLength;
$this->requireUppercase = $requireUppercase;
$this->requireNumeric = $requireNumeric;
$this->requireSpecialCharacter = $requireSpecialCharacter;
}
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
//
}
public function passes($attribute, $value)
{
$passes = true;
if (strlen($value) < $this->minLength) {
$passes = false;
}
if ($this->requireUppercase && !preg_match('/[A-Z]/', $value)) {
$passes = false;
}
if ($this->requireNumeric && !preg_match('/[0-9]/', $value)) {
$passes = false;
}
if ($this->requireSpecialCharacter && !preg_match('/[\W_]/', $value)) {
$passes = false;
}
return $passes;
}
public function message()
{
return 'La contraseña debe contener al menos '.$this->minLength.' caracteres'.
($this->requireUppercase ? ', una mayúscula' : '').
($this->requireNumeric ? ', un número' : '').
($this->requireSpecialCharacter ? ' y un carácter especial' : '');
}
}