++ fix grafici, aggiunta statistiche su area assegnatario, sezione profilo

This commit is contained in:
Francesco Picone
2026-07-25 23:28:41 +02:00
parent 177523994d
commit 95c9180f52
7 changed files with 151 additions and 8 deletions

View File

@@ -162,6 +162,25 @@ regola è un no-op (nessun territorio assegnato è prioritario); su backup vecch
con stato contraddittorio l'import normalizza il flag anziché riprodurlo tale e con stato contraddittorio l'import normalizza il flag anziché riprodurlo tale e
quale. quale.
### Profilo utente, viewer PDF, privacy admin-only
Tre ritocchi minori ma con qualche implicazione:
- **Pagina profilo** (`Profile` → rotta `profilo`, nome `profile`) accessibile a
ogni utente loggato (nel gruppo `auth`, nessun permesso): mostra nome, email e
qualifica del solo utente corrente (sola lettura). Nell'header il blocco utente
è ora un link a questa pagina, con nome in evidenza ed `email · qualifica`
sotto. Non esiste un campo "username": l'email fa da identificativo.
- **Viewer PDF pubblico**: oltre a territorio/proclamatore/data mostra ora
*giorni assegnato*, *consegna prevista* e *scadenza*. Sono attributi calcolati
su `Assegnazione` (`data_consegna_presunta`, `giorni_alla_scadenza`) basati
sulla soglia `giorni_per_smarrito` — la stessa oltre cui `Territorio::stato`
diventa `da_rientrare`. Il viewer è servito solo per assegnazioni aperte, ma
gli accessor gestiscono comunque il caso `returned_at` (ritornano null).
- **Privacy solo admin**: la rotta `privacy` è passata dal solo `auth` al gruppo
`permission:settings.manage` e il link in sidebar è sotto
`@can('settings.manage')`.
### Export/Import XML: backup completo e round-trip ### Export/Import XML: backup completo e round-trip
`Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali, `Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali,

15
app/Livewire/Profile.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
namespace App\Livewire;
use Livewire\Component;
class Profile extends Component
{
public function render()
{
return view('livewire.profile', [
'user' => auth()->user(),
]);
}
}

View File

@@ -103,6 +103,40 @@ class Assegnazione extends Model
return is_null($this->returned_at); return is_null($this->returned_at);
} }
/**
* Scadenza dell'assegnazione: data entro cui il territorio andrebbe
* riconsegnato, cioè assigned_at + la soglia "giorni_per_smarrito" (la
* stessa oltre cui Territorio::stato diventa "da_rientrare"). Null per le
* assegnazioni già rientrate.
*/
public function getDataConsegnaPresuntaAttribute(): ?Carbon
{
if ($this->returned_at !== null) {
return null;
}
$soglia = (int) Setting::getValue('giorni_per_smarrito', 120);
return Carbon::parse($this->assigned_at)->addDays($soglia);
}
/**
* Giorni che mancano alla scadenza: positivo se ancora entro i termini,
* negativo (giorni di ritardo) se già scaduta. Null se rientrata.
*/
public function getGiorniAllaScadenzaAttribute(): ?int
{
$scadenza = $this->data_consegna_presunta?->copy()->startOfDay();
if (! $scadenza) {
return null;
}
$oggi = Carbon::today();
$giorni = (int) $oggi->diffInDays($scadenza);
return $oggi->lessThanOrEqualTo($scadenza) ? $giorni : -$giorni;
}
public function ensurePdfAccessCode(): string public function ensurePdfAccessCode(): string
{ {
if ($this->pdf_access_code) { if ($this->pdf_access_code) {

View File

@@ -215,6 +215,27 @@
<span class="label">Data</span> <span class="label">Data</span>
<span class="value">{{ $assignment->assigned_at->format('d/m/Y') }}</span> <span class="value">{{ $assignment->assigned_at->format('d/m/Y') }}</span>
</div> </div>
<div class="chip">
<span class="label">Giorni assegnato</span>
<span class="value">{{ $assignment->giorni }}</span>
</div>
<div class="chip">
<span class="label">Consegna prevista</span>
<span class="value">{{ optional($assignment->data_consegna_presunta)->format('d/m/Y') ?? '—' }}</span>
</div>
@php($giorniScadenza = $assignment->giorni_alla_scadenza)
<div class="chip">
<span class="label">Scadenza</span>
<span class="value" @if($giorniScadenza !== null && $giorniScadenza < 0) style="color:#dc2626" @endif>
@if($giorniScadenza === null)
—
@elseif($giorniScadenza >= 0)
tra {{ $giorniScadenza }} {{ $giorniScadenza === 1 ? 'giorno' : 'giorni' }}
@else
scaduto da {{ abs($giorniScadenza) }} {{ abs($giorniScadenza) === 1 ? 'giorno' : 'giorni' }}
@endif
</span>
</div>
</div> </div>
@if($showDownload) @if($showDownload)

View File

@@ -32,13 +32,15 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg" style="background:rgba(255,255,255,0.1)"> <a href="{{ route('profile') }}" title="Il mio profilo"
<svg class="h-4 w-4" style="color:rgba(255,255,255,0.7)" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg> class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg transition {{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}"
<span class="text-sm" style="color:rgba(255,255,255,0.9)"> style="background:rgba(255,255,255,0.1)" onmouseover="this.style.background='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.1)'">
{{ auth()->user()->name }} <svg class="h-5 w-5 flex-none" style="color:rgba(255,255,255,0.7)" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<span class="text-xs" style="color:rgba(255,255,255,0.6)">({{ auth()->user()->roles->first()?->name ?? 'utente' }})</span> <span class="flex flex-col leading-tight text-left">
<span class="text-sm font-medium" style="color:rgba(255,255,255,0.95)">{{ auth()->user()->name }}</span>
<span class="text-xs" style="color:rgba(255,255,255,0.6)">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
</span> </span>
</div> </a>
<form method="POST" action="{{ route('logout') }}"> <form method="POST" action="{{ route('logout') }}">
@csrf @csrf
<button type="submit" class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg transition" style="color:rgba(255,255,255,0.8);background:rgba(255,255,255,0.1)" onmouseover="this.style.background='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.1)'"> <button type="submit" class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg transition" style="color:rgba(255,255,255,0.8);background:rgba(255,255,255,0.1)" onmouseover="this.style.background='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.1)'">
@@ -149,6 +151,7 @@
</div> </div>
@endcan @endcan
@can('settings.manage')
<div class="pt-4 mt-4 border-t border-gray-200"> <div class="pt-4 mt-4 border-t border-gray-200">
<a href="{{ route('privacy') }}" <a href="{{ route('privacy') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('privacy') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}"> class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('privacy') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
@@ -156,6 +159,7 @@
Privacy Privacy
</a> </a>
</div> </div>
@endcan
</nav> </nav>
</aside> </aside>

View File

@@ -0,0 +1,44 @@
<div>
<div class="mb-6">
<a href="{{ route('dashboard') }}" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-indigo-600 transition-colors mb-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Torna alla home
</a>
<h1 class="text-2xl font-bold text-gray-900">Il mio profilo</h1>
</div>
<div class="max-w-2xl bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{{-- Intestazione: avatar iniziale + nome --}}
<div class="p-6 flex items-center gap-4 border-b border-gray-100">
<div class="h-16 w-16 flex-none rounded-full flex items-center justify-center text-white text-2xl font-bold shadow-sm"
style="background:linear-gradient(135deg,#4f46e5,#6366f1)">
{{ strtoupper(mb_substr($user->name, 0, 1)) }}
</div>
<div class="min-w-0">
<h2 class="text-xl font-bold text-gray-900 truncate">{{ $user->name }}</h2>
<p class="text-sm text-gray-500 truncate mt-0.5">
<span class="break-all">{{ $user->email }}</span>
<span class="ml-1 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700 align-middle">
{{ ucfirst($user->roles->first()?->name ?? 'utente') }}
</span>
</p>
</div>
</div>
{{-- Dettagli --}}
<dl class="divide-y divide-gray-100">
<div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-3 gap-1 sm:gap-4">
<dt class="text-sm font-medium text-gray-500">Nome</dt>
<dd class="text-sm text-gray-900 sm:col-span-2">{{ $user->name }}</dd>
</div>
<div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-3 gap-1 sm:gap-4">
<dt class="text-sm font-medium text-gray-500">Email</dt>
<dd class="text-sm text-gray-900 sm:col-span-2 break-all">{{ $user->email }}</dd>
</div>
<div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-3 gap-1 sm:gap-4">
<dt class="text-sm font-medium text-gray-500">Qualifica</dt>
<dd class="text-sm text-gray-900 sm:col-span-2">{{ ucfirst($user->roles->first()?->name ?? 'utente') }}</dd>
</div>
</dl>
</div>
</div>

View File

@@ -31,6 +31,7 @@ use App\Livewire\Settings\TipologieIndex;
use App\Livewire\Settings\UsersIndex; use App\Livewire\Settings\UsersIndex;
use App\Livewire\Settings\XmlExchange; use App\Livewire\Settings\XmlExchange;
use App\Livewire\Privacy; use App\Livewire\Privacy;
use App\Livewire\Profile;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -66,6 +67,9 @@ Route::middleware('auth')->group(function () {
// Home Dashboard // Home Dashboard
Route::get('/', Home::class)->name('dashboard'); Route::get('/', Home::class)->name('dashboard');
// Profilo personale (ogni utente loggato)
Route::get('profilo', Profile::class)->name('profile');
// Territori — visualizzazione (lista + dettaglio) per tutti gli utenti // Territori — visualizzazione (lista + dettaglio) per tutti gli utenti
// operativi, cioè chi può assegnare i territori. // operativi, cioè chi può assegnare i territori.
Route::middleware('permission:territori.assign')->group(function () { Route::middleware('permission:territori.assign')->group(function () {
@@ -137,6 +141,8 @@ Route::middleware('auth')->group(function () {
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip'); Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
}); });
// Privacy / Informativa GDPR // Privacy / Informativa GDPR — visibile solo all'amministratore.
Route::middleware('permission:settings.manage')->group(function () {
Route::get('privacy', Privacy::class)->name('privacy'); Route::get('privacy', Privacy::class)->name('privacy');
}); });
});