feat(users): idioma por defecto con banderas SVG + conmutador coherente

- Campo "Idioma por defecto" al crear/editar usuario (columna locale ya existente),
  como desplegable Alpine con banderas SVG reales (no emoji, que en Windows se ven
  como "ES"/"GB") servidas localmente: public/images/flags/{es,gb}.svg.
- User: locale añadido a fillable. UserForm: propiedad/validación/guardado de locale.
- LanguageSwitcher de la cabecera usa las mismas banderas SVG.
- Regla CSS [x-cloak] en el layout para evitar parpadeo de desplegables Alpine.

Tests: UserLocaleTest (2) — crear/editar persisten el idioma.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 13:10:27 +02:00
parent 8c774d075d
commit 19e1f57983
8 changed files with 150 additions and 3 deletions
+13
View File
@@ -36,6 +36,9 @@ class UserForm extends Component
// Permisos // Permisos
public string $formRole = ''; public string $formRole = '';
// Preferencias
public string $locale = 'es';
// Notas // Notas
public string $notes = ''; public string $notes = '';
@@ -43,6 +46,12 @@ class UserForm extends Component
public $roles; public $roles;
public $companies; public $companies;
/** Idiomas disponibles (código => nombre + archivo de bandera). */
public array $languages = [
'es' => ['name' => 'Español', 'flag' => 'es.svg'],
'en' => ['name' => 'English', 'flag' => 'gb.svg'],
];
public function mount(?User $user = null): void public function mount(?User $user = null): void
{ {
abort_unless(Auth::user()->can('create users') || Auth::user()->can('edit users'), 403); abort_unless(Auth::user()->can('create users') || Auth::user()->can('edit users'), 403);
@@ -65,6 +74,7 @@ class UserForm extends Component
$this->email = $user->email; $this->email = $user->email;
$this->notes = $user->notes ?? ''; $this->notes = $user->notes ?? '';
$this->formRole = $user->roles->first()?->name ?? $this->formRole; $this->formRole = $user->roles->first()?->name ?? $this->formRole;
$this->locale = $user->locale ?? $this->locale;
} }
} }
@@ -83,6 +93,7 @@ class UserForm extends Component
'phone' => 'nullable|string|max:30', 'phone' => 'nullable|string|max:30',
'email' => "required|email|max:255|unique:users,email,{$id}", 'email' => "required|email|max:255|unique:users,email,{$id}",
'formRole' => 'required|exists:roles,name', 'formRole' => 'required|exists:roles,name',
'locale' => 'required|in:' . implode(',', array_keys($this->languages)),
]; ];
if (!$this->user) { if (!$this->user) {
@@ -103,6 +114,7 @@ class UserForm extends Component
'companyId' => 'empresa', 'companyId' => 'empresa',
'formPassword'=> 'contraseña', 'formPassword'=> 'contraseña',
'formRole' => 'rol', 'formRole' => 'rol',
'locale' => 'idioma',
]; ];
public function copyCompanyAddress(): void public function copyCompanyAddress(): void
@@ -139,6 +151,7 @@ class UserForm extends Component
'phone' => $this->phone ?: null, 'phone' => $this->phone ?: null,
'email' => $this->email, 'email' => $this->email,
'notes' => $this->notes ?: null, 'notes' => $this->notes ?: null,
'locale' => $this->locale,
]; ];
if ($this->formPassword !== '') { if ($this->formPassword !== '') {
+1
View File
@@ -25,6 +25,7 @@ class User extends Authenticatable
'email', 'password', 'email', 'password',
'status', 'valid_from', 'valid_until', 'status', 'valid_from', 'valid_until',
'company_id', 'phone', 'address', 'notes', 'company_id', 'phone', 'address', 'notes',
'locale',
]; ];
/** /**
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 500">
<rect width="750" height="500" fill="#AA151B"/>
<rect y="125" width="750" height="250" fill="#F1BF00"/>
</svg>

After

Width:  |  Height:  |  Size: 178 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30">
<clipPath id="gb-t">
<path d="M30,15 h30 v15 z v15 h-30 z h-30 v-15 z v-15 h30 z"/>
</clipPath>
<rect width="60" height="30" fill="#012169"/>
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" stroke-width="6"/>
<path d="M0,0 L60,30 M60,0 L0,30" clip-path="url(#gb-t)" stroke="#C8102E" stroke-width="4"/>
<path d="M30,0 v30 M0,15 h60" stroke="#fff" stroke-width="10"/>
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" stroke-width="6"/>
</svg>

After

Width:  |  Height:  |  Size: 518 B

+3
View File
@@ -15,6 +15,9 @@
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
{{-- Evita el parpadeo de elementos Alpine (x-cloak) antes de inicializar --}}
<style>[x-cloak]{display:none !important;}</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/> crossorigin=""/>
@@ -1,10 +1,12 @@
<div class="flex items-center gap-1" <div class="flex items-center gap-1"
x-on:locale-changed.window="window.location.reload()"> x-on:locale-changed.window="window.location.reload()">
@foreach(['en' => '🇬🇧 EN', 'es' => '🇪🇸 ES'] as $code => $label) @foreach(['es' => ['flag' => 'es.svg', 'label' => 'ES'], 'en' => ['flag' => 'gb.svg', 'label' => 'EN']] as $code => $lang)
<button wire:click="switchLanguage('{{ $code }}')" <button wire:click="switchLanguage('{{ $code }}')"
class="btn btn-xs {{ $currentLocale === $code ? 'btn-primary' : 'btn-ghost' }}" class="btn btn-xs gap-1.5 {{ $currentLocale === $code ? 'btn-primary' : 'btn-ghost' }}"
title="{{ __('Language') }}: {{ __($code === 'en' ? 'English' : 'Spanish') }}"> title="{{ __('Language') }}: {{ __($code === 'en' ? 'English' : 'Spanish') }}">
{{ $label }} <img src="{{ asset('images/flags/' . $lang['flag']) }}"
class="w-4 h-auto rounded-sm border border-base-300" alt="{{ $lang['label'] }}">
{{ $lang['label'] }}
</button> </button>
@endforeach @endforeach
</div> </div>
@@ -291,6 +291,46 @@
@error('formRole') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror @error('formRole') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
</div> </div>
<div class="flex items-start gap-4 mt-4">
<label class="w-48 shrink-0 pt-2 text-sm font-medium text-gray-700">
Idioma por defecto <span class="text-error">*</span>
<p class="text-xs text-gray-400 font-normal mt-0.5">Idioma de la interfaz para este usuario</p>
</label>
<div class="flex-1">
<div x-data="{ open: false }" @click.outside="open = false" class="relative w-full max-w-xs">
{{-- Trigger: muestra la bandera + nombre del idioma seleccionado --}}
<button type="button" @click="open = !open"
class="select select-bordered w-full flex items-center gap-2 text-left">
@foreach($languages as $code => $lang)
<span x-show="$wire.locale === '{{ $code }}'" class="flex items-center gap-2">
<img src="{{ asset('images/flags/' . $lang['flag']) }}"
class="w-5 h-auto rounded-sm border border-base-300" alt="{{ $lang['name'] }}">
{{ $lang['name'] }}
</span>
@endforeach
</button>
{{-- Opciones --}}
<ul x-show="open" x-transition x-cloak
class="absolute z-50 mt-1 w-full bg-base-100 border border-base-300 rounded-box shadow-lg p-1">
@foreach($languages as $code => $lang)
<li>
<button type="button"
@click="$wire.set('locale', '{{ $code }}'); open = false"
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-base-200 text-left"
:class="$wire.locale === '{{ $code }}' ? 'bg-base-200 font-medium' : ''">
<img src="{{ asset('images/flags/' . $lang['flag']) }}"
class="w-5 h-auto rounded-sm border border-base-300" alt="{{ $lang['name'] }}">
{{ $lang['name'] }}
</button>
</li>
@endforeach
</ul>
</div>
@error('locale') <p class="text-error text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div> </div>
{{-- ══════════════════════════════════════════════════════════ {{-- ══════════════════════════════════════════════════════════
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace Tests\Feature;
use App\Livewire\UserForm;
use App\Models\Company;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class UserLocaleTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
foreach (['create users', 'edit users'] as $p) {
Permission::findOrCreate($p);
}
Role::findOrCreate('Tecnico');
$admin = User::factory()->create();
$admin->givePermissionTo(['create users', 'edit users']);
return $admin;
}
private function company(): Company
{
return Company::create(['name' => 'ACME', 'estado' => 'activo', 'type' => 'constructor']);
}
public function test_creating_a_user_persists_the_selected_locale(): void
{
$admin = $this->admin();
$company = $this->company();
Livewire::actingAs($admin)
->test(UserForm::class)
->set('firstName', 'Ada')
->set('lastName', 'Lovelace')
->set('email', 'ada@example.com')
->set('companyId', $company->id)
->set('formRole', 'Tecnico')
->set('formPassword', 'Password123')
->set('locale', 'en')
->call('save');
$this->assertDatabaseHas('users', ['email' => 'ada@example.com', 'locale' => 'en']);
}
public function test_editing_a_user_loads_and_updates_the_locale(): void
{
$admin = $this->admin();
$company = $this->company();
$target = User::factory()->create([
'locale' => 'es',
'company_id' => $company->id,
'first_name' => 'Bob',
'last_name' => 'Stone',
]);
$target->assignRole('Tecnico');
Livewire::actingAs($admin)
->test(UserForm::class, ['user' => $target])
->assertSet('locale', 'es')
->set('locale', 'en')
->call('save');
$this->assertEquals('en', $target->fresh()->locale);
}
}