Compare commits
9 Commits
3ec2422fbc
...
76ea127d5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ea127d5b | ||
|
|
561153c754 | ||
|
|
69072b6f09 | ||
|
|
34415862d2 | ||
|
|
95c9180f52 | ||
|
|
177523994d | ||
|
|
24a6d3d59f | ||
|
|
c505708ae2 | ||
|
|
9421891989 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@
|
||||
/storage/app/livewire-tmp/*.*
|
||||
/storage/logs/
|
||||
/storage/framework/
|
||||
/storage/app/public/territori-thumbnails/*.png
|
||||
/bootstrap/cache/
|
||||
/public/hot
|
||||
/public/storage
|
||||
|
||||
180
HANDOFF.md
180
HANDOFF.md
@@ -112,6 +112,179 @@ drift per i database esistenti: revoca `settings.manage` dal ruolo `assistente`
|
||||
se presente. È idempotente (no-op sul baseline) e volutamente irreversibile —
|
||||
il `down()` non ripristina il permesso.
|
||||
|
||||
Stesso principio per le **campagne**: creazione e visualizzazione restano su
|
||||
`campagne.manage` (che ha anche l'assistente), ma **modifica ed eliminazione
|
||||
sono riservate all'amministratore** (`settings.manage`). Il gate è su tre
|
||||
livelli: la route `campagne/{campagna}/edit` è nel gruppo `settings.manage`;
|
||||
`CampagnaEdit::mount()`/`save()` e `CampagnaIndex::deleteCampagna()` chiamano
|
||||
`$this->authorize('settings.manage')` (l'index è raggiungibile da chi ha
|
||||
`campagne.manage`, quindi il delete va protetto nel metodo, non solo in route);
|
||||
i pulsanti Modifica/Elimina in `campagna-index`/`campagna-show` sono sotto
|
||||
`@can('settings.manage')`. Nota: `Campagna` non ha soft delete — l'eliminazione
|
||||
è definitiva.
|
||||
|
||||
Per i **territori** vale un modello leggermente diverso, perché la
|
||||
visualizzazione va aperta a tutti gli operativi: lista (`territori.index`) e
|
||||
dettaglio (`territori.show`) sono ora su `territori.assign` (che hanno admin,
|
||||
assistente e operatore); creazione/modifica/cestino restano su `territori.manage`
|
||||
(solo admin). Attenzione a due punti:
|
||||
|
||||
- **Ordine delle rotte.** Le rotte letterali `territori/create` e
|
||||
`territori/cestino` devono restare registrate PRIMA del wildcard
|
||||
`territori/{territorio}`, altrimenti verrebbero catturate dalla rotta di
|
||||
dettaglio.
|
||||
|
||||
- **`TerritorioIndex` perde la copertura del middleware persistente.** Il suo
|
||||
componente è ora servito da una rotta rilassata (`territori.assign`), quindi i
|
||||
suoi metodi di mutazione (`toggleActive`, `togglePriority`, `deleteTerritorio`)
|
||||
NON sono più protetti dal middleware di route e hanno un
|
||||
`$this->authorize('territori.manage')` interno — senza, un operativo potrebbe
|
||||
invocarli via `/livewire/update` col pulsante nascosto. Per difesa in
|
||||
profondità lo stesso check è stato aggiunto anche a `TerritorioCreate::save`,
|
||||
`TerritorioEdit::save`/`removePdf`, `TerritorioCestino::restore`/`forceDelete`
|
||||
(che restano comunque su rotte `territori.manage`). I pulsanti
|
||||
Modifica/toggle/Elimina/Nuovo/Cestino in `territorio-index`/`territorio-show`
|
||||
sono sotto `@can('territori.manage')`; il link "Dettaglio" resta visibile a
|
||||
tutti. Il menu laterale mostra "Territori" a chi ha `territori.assign`.
|
||||
|
||||
### Sola lettura per proclamatori e campagne (non-admin)
|
||||
|
||||
Stessa impostazione dei territori estesa ad altre due sezioni:
|
||||
|
||||
- **Proclamatori**: `index` e `show` restano su `proclamatori.manage` (l'assistente
|
||||
li vede in sola lettura); `create`/`edit`/`cestino` e tutte le mutazioni
|
||||
(`ProclamatoreIndex::toggleActive`/`deleteProclamatore`/`anonimizza`,
|
||||
`ProclamatoreCreate::save`, `ProclamatoreEdit::save`,
|
||||
`ProclamatoreCestino::restore`/`forceDelete`) richiedono `settings.manage`.
|
||||
`ProclamatoreIndex` è servito da una rotta rilassata, quindi i suoi metodi di
|
||||
mutazione hanno il check interno; i pulsanti in `proclamatore-index`/`-show`/
|
||||
`-cestino` sono sotto `@can('settings.manage')`. Le rotte letterali
|
||||
create/cestino restano prima del wildcard `{proclamatore}`.
|
||||
- **Campagne**: la **creazione** è ora admin-only come già modifica/eliminazione
|
||||
— `CampagnaCreate::save` chiama `authorize('settings.manage')`, la rotta
|
||||
`campagne/create` è nel gruppo `settings.manage` (registrata prima del
|
||||
wildcard) e il pulsante "Nuova Campagna" è sotto `@can('settings.manage')`.
|
||||
`index`/`show` restano su `campagne.manage` (sola lettura per l'assistente).
|
||||
|
||||
### Tabelle-elenco responsive su mobile (portrait)
|
||||
|
||||
Le liste principali usano una sola "politica" CSS invece di layout duplicati:
|
||||
la classe `table-cards` (in `resources/css/app.css`) sotto `640px` nasconde il
|
||||
`thead` e trasforma ogni riga in una scheda, con ogni cella resa come
|
||||
"ETICHETTA … valore" (l'etichetta viene dall'attributo `data-label` della `<td>`).
|
||||
Un telefono in portrait sta sotto la soglia → schede; ruotato in landscape la
|
||||
larghezza supera i 640px → torna la tabella normale (per questo landscape "non ha
|
||||
problemi"). Convenzioni: ogni `<td>` dati ha `data-label="..."`; la cella azioni
|
||||
ha classe `cell-actions` (niente label, occupa la riga a piena larghezza); le
|
||||
celle `colspan` (stato vuoto, form di modifica utente inline) restano `block` a
|
||||
piena larghezza. Applicata a tutte le liste e sottopagine con tabelle: territori,
|
||||
proclamatori, campagne, registro, utenti, audit-log, i due cestini, le tabelle
|
||||
"storico assegnazioni" di territorio-show/proclamatore-show, "territori
|
||||
conteggiati" di campagna-show e le due tabelle di esito import in xml-exchange
|
||||
(una a classi Tailwind, una a stili inline — `border:none !important` nella regola
|
||||
gestisce anche i bordi inline). La pagina *Assegnazioni* era già ottimizzata.
|
||||
|
||||
### Scadenza link PDF unificata con la soglia rientro
|
||||
|
||||
La "Validità link PDF (mesi)" è stata rimossa: il link condivisibile ora scade
|
||||
esattamente quando l'assegnazione raggiunge la **soglia rientro**
|
||||
(`giorni_per_smarrito`), cioè `assigned_at + giorni_per_smarrito` — la stessa
|
||||
data di `Assegnazione::data_consegna_presunta` e del chip "Scadenza" nel viewer.
|
||||
`AssignmentPdfController::linkScaduto` e `ShortPdfLinkController` calcolano ora
|
||||
`addDays(giorni_per_smarrito)` invece di `addMonths(ttl)`; la scadenza vale solo
|
||||
per i non autenticati e il link muore comunque al rientro (`is_aperta`). Rimossi
|
||||
il campo Impostazioni e la proprietà `assignment_link_ttl_months` in
|
||||
`SettingsEdit`. **La colonna `assignment_link_ttl_hours` resta nel DB** (niente
|
||||
migration distruttiva): è ormai vestigiale per la logica ma l'export/import XML
|
||||
continua a leggerla/scriverla senza effetti.
|
||||
|
||||
### Ricerca rapida nella lista link (pagina Assegna)
|
||||
|
||||
La sezione "Territori Assegnati" (i link PDF) ha un unico campo che filtra per
|
||||
numero territorio, zona e proclamatore. Il filtro è **in memoria** (proprietà
|
||||
`linkSearch`, collection `assegnazioniAperteVisibili`) perché nome/cognome del
|
||||
proclamatore sono cifrati e non filtrabili via SQL — stesso approccio del
|
||||
Registro. La sezione resta visibile anche a risultato vuoto (mostra "Nessun
|
||||
risultato"), perché l'`@if` esterno guarda la lista non filtrata.
|
||||
|
||||
### Assegnare un territorio ne azzera il flag "prioritario"
|
||||
|
||||
`prioritario` è un flag **manuale**; `is_prioritario` è true se il flag è acceso
|
||||
*oppure* se la giacenza supera la soglia — ma la parte a soglia vale solo per i
|
||||
territori *in reparto* (`scopePrioritari`/`getIsPrioritarioAttribute` filtrano su
|
||||
`inReparto`). Quindi un territorio assegnato può risultare prioritario **solo**
|
||||
per via del flag manuale. Regola: alla creazione di un'assegnazione **aperta** il
|
||||
flag viene spento. È implementata come evento `Assegnazione::created` (in
|
||||
`booted()`), guardato su `returned_at === null` e `prioritario === true`, così
|
||||
copre ogni via di creazione (pagina Assegna, CRUD del Registro, import XML) senza
|
||||
duplicare la logica. Interazione con l'import XML: per i backup creati dopo questa
|
||||
regola è un no-op (nessun territorio assegnato è prioritario); su backup vecchi
|
||||
con stato contraddittorio l'import normalizza il flag anziché riprodurlo tale e
|
||||
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): l'utente corrente può
|
||||
modificare il proprio nome/cognome ed email (`updateProfile`) e cambiare la
|
||||
password (`updatePassword`, con verifica della password attuale via regola
|
||||
`current_password`; il cast `hashed` cifra al salvataggio). Dopo il salvataggio
|
||||
dei dati `updateProfile` fa `redirect(navigate:true)` sulla stessa pagina, così
|
||||
header (nel layout, fuori dal componente) e card mostrano subito i nuovi valori.
|
||||
Il link al profilo è nell'header su desktop e, su mobile, in fondo alla sidebar
|
||||
(`lg:hidden`, dopo un separatore) — sull'header è `hidden sm:flex`. Non esiste
|
||||
un campo "username" né "cognome" separato: `name` è il nome completo
|
||||
(etichettato "Nome e cognome") e l'email fa da identificativo. La qualifica
|
||||
(ruolo) non è modificabile dal profilo — resta in gestione all'admin.
|
||||
- **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
|
||||
|
||||
`Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali,
|
||||
non più un sottoinsieme. L'export (`currentDataset` + `datasetToXml`) include
|
||||
ora: utenti (con hash password, ruoli, `email_verified_at`, `remember_token`),
|
||||
i campi settings prima omessi (`public_base_url`, `logo_path`,
|
||||
`pdf_viewer_show_download`, `setup_completed`), lo stato soft-delete
|
||||
(`deleted_at`) di proclamatori/territori, i path file dei territori
|
||||
(`pdf_path`, `thumbnail_path`), le FK di audit delle assegnazioni
|
||||
(`created_by`/`returned_by`, esportate come `legacy_*_id`), `link_sent`,
|
||||
`pdf_access_code`, e i timestamp `created_at`/`updated_at` di ogni entità.
|
||||
L'import (`importXmlFromContent`) legge simmetricamente tutti questi campi, così
|
||||
un export si reimporta senza perdite.
|
||||
|
||||
Tre scelte non ovvie:
|
||||
|
||||
- **`legacy_id` derivato dall'`id` reale.** Il rimappaggio delle FK in import si
|
||||
basa sull'attributo `legacy_id`. Prima veniva scritto solo dalla conversione
|
||||
SQL legacy: l'export dell'app corrente ne era privo, quindi un suo re-import
|
||||
**perdeva territori e assegnazioni** (riferimenti non risolti). Ora
|
||||
`addLegacyIdAttribute()` usa `legacy_id` se presente, altrimenti l'`id` reale.
|
||||
|
||||
- **Gli utenti sono UPSERT per email, non azzerati.** Tutte le altre entità
|
||||
vengono cancellate e ricreate; gli utenti no. Azzerare la tabella `users`
|
||||
durante un restore rischierebbe di **estromettere l'amministratore** che sta
|
||||
importando (backup che precede il suo account, o file parziale). Gli account
|
||||
esistenti vengono aggiornati, quelli nel file (ri)creati; nessuno viene
|
||||
eliminato. Conseguenza: non è un mirror perfetto (utenti extra restano), ma è
|
||||
senza perdite e non blocca fuori nessuno.
|
||||
|
||||
- **Hash password e timestamp scritti col query builder.** Dopo il `save()` del
|
||||
modello, password/`remember_token`/`email_verified_at`/`created_at`/
|
||||
`updated_at` vengono scritti con `DB::table()->update()`: preserva l'hash
|
||||
esatto (il cast `hashed` rivaliderebbe il costo bcrypt e potrebbe rifiutare un
|
||||
hash generato con `BCRYPT_ROUNDS` diverso) ed evita che Eloquent sovrascriva i
|
||||
timestamp importati. Stessa tecnica in `applyRowMeta()` per le altre entità.
|
||||
|
||||
### Un solo container esegue le migrazioni
|
||||
|
||||
Solo `app` migra. `queue-worker` e `scheduler` usano `queue-entrypoint.sh`, che
|
||||
@@ -143,6 +316,7 @@ Contesto utile se ti imbatti in comportamenti che sembrano regressioni.
|
||||
| `pdftoppm` senza timeout | Un PDF malformato bloccava il worker **per sempre** | `TerritorioThumbnailService` |
|
||||
| Collisione nomi file PDF | `1 A` e `1-A` scrivevano sullo stesso file → dati sovrascritti | `Jobs/ImportTerritoryPdfFolder` |
|
||||
| Zip senza limiti | Zip bomb → esaurimento memoria | `TerritorioPdfImportDispatcher` |
|
||||
| Export XML senza `legacy_id` | Il re-import dell'export dell'app corrente perdeva territori e assegnazioni (FK non risolte) | `Livewire/Settings/XmlExchange` (`addLegacyIdAttribute`) |
|
||||
| Migrazioni thumbnail duplicate | `migrate:rollback` falliva a metà | rimosse 2 migrazioni ridondanti |
|
||||
|
||||
---
|
||||
@@ -214,6 +388,12 @@ miniature e la chiave di fallback. Non va trattato come rigenerabile.
|
||||
**Modifiche a dipendenze o asset richiedono un rebuild.** Non è più sufficiente
|
||||
riavviare il container: `vendor/` e `public/build` vivono dentro l'immagine.
|
||||
|
||||
**L'export XML contiene dati sensibili.** Il file di export include gli hash
|
||||
delle password di tutti gli utenti e i nomi/cognomi dei proclamatori in chiaro.
|
||||
Va trattato come un segreto: chi lo ottiene può tentare il crack offline degli
|
||||
hash. La pagina di export lo segnala, ma il controllo resta umano — la
|
||||
funzione è già riservata all'amministratore (`settings.manage`).
|
||||
|
||||
---
|
||||
|
||||
## Come verificare che tutto funzioni
|
||||
|
||||
@@ -65,9 +65,11 @@ class AssignmentPdfController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
$ttlMonths = max(1, (int) \App\Models\Setting::getValue('assignment_link_ttl_hours', 1));
|
||||
// Il link scade quando l'assegnazione raggiunge la soglia rientro
|
||||
// (giorni_per_smarrito), cioè quando il territorio diventa "da rientrare".
|
||||
$soglia = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120));
|
||||
|
||||
if ($assignment->assigned_at->copy()->addMonths($ttlMonths)->isPast()) {
|
||||
if ($assignment->assigned_at->copy()->addDays($soglia)->isPast()) {
|
||||
return view('assignments.link-scaduto', [
|
||||
'numero' => $assignment->territorio?->numero,
|
||||
]);
|
||||
|
||||
@@ -15,10 +15,11 @@ class ShortPdfLinkController extends Controller
|
||||
abort_unless($assignment->is_aperta, 403);
|
||||
abort_unless($assignment->territorio?->pdf_path, 404);
|
||||
|
||||
// Unauthenticated users (proclamatori) are subject to link TTL
|
||||
// Unauthenticated users (proclamatori) are subject to the link expiry:
|
||||
// il link scade al raggiungimento della soglia rientro (giorni_per_smarrito).
|
||||
if (! auth()->check()) {
|
||||
$ttlMonths = max(1, (int) \App\Models\Setting::getValue('assignment_link_ttl_hours', 1));
|
||||
if ($assignment->assigned_at->copy()->addMonths($ttlMonths)->isPast()) {
|
||||
$soglia = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120));
|
||||
if ($assignment->assigned_at->copy()->addDays($soglia)->isPast()) {
|
||||
return view('assignments.link-scaduto', [
|
||||
'numero' => $assignment->territorio?->numero,
|
||||
]);
|
||||
|
||||
@@ -16,6 +16,7 @@ class Assegna extends Component
|
||||
public ?int $proclamatore_id = null;
|
||||
public string $assigned_at = '';
|
||||
public string $territorioSearch = '';
|
||||
public string $linkSearch = '';
|
||||
|
||||
// Optional pre-selection from parent context
|
||||
public ?int $preselectedTerritorioId = null;
|
||||
@@ -129,13 +130,31 @@ class Assegna extends Component
|
||||
->get()
|
||||
->sortBy(fn($a) => (int) $a->territorio?->numero);
|
||||
|
||||
$linkTtlMonths = max(1, (int) \App\Models\Setting::getValue('assignment_link_ttl_hours', 1));
|
||||
// Ricerca rapida nella lista dei link: numero territorio, zona,
|
||||
// proclamatore in un solo campo. In memoria perché nome/cognome del
|
||||
// proclamatore sono cifrati e non filtrabili via SQL.
|
||||
$assegnazioniAperteVisibili = $assegnazioniAperte;
|
||||
if (trim($this->linkSearch) !== '') {
|
||||
$term = mb_strtolower(trim($this->linkSearch));
|
||||
$assegnazioniAperteVisibili = $assegnazioniAperte->filter(function ($a) use ($term) {
|
||||
$haystack = mb_strtolower(
|
||||
($a->territorio?->numero ?? '') . ' ' .
|
||||
($a->territorio?->zona?->nome ?? '') . ' ' .
|
||||
($a->proclamatore?->nome_completo ?? '')
|
||||
);
|
||||
return str_contains($haystack, $term);
|
||||
})->values();
|
||||
}
|
||||
|
||||
// Il link scade alla soglia rientro (giorni_per_smarrito).
|
||||
$sogliaRientro = max(1, (int) \App\Models\Setting::getValue('giorni_per_smarrito', 120));
|
||||
|
||||
return view('livewire.assegnazioni.assegna', [
|
||||
'territoriDisponibili' => $territoriDisponibili,
|
||||
'proclamatoriAttivi' => $proclamatoriAttivi,
|
||||
'assegnazioniAperte' => $assegnazioniAperte,
|
||||
'linkTtlMonths' => $linkTtlMonths,
|
||||
'assegnazioniAperteVisibili' => $assegnazioniAperteVisibili,
|
||||
'sogliaRientro' => $sogliaRientro,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ class CampagnaCreate extends Component
|
||||
|
||||
public function save()
|
||||
{
|
||||
// Creazione campagne riservata all'amministratore.
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$this->validate();
|
||||
|
||||
$campagna = Campagna::create([
|
||||
|
||||
@@ -14,6 +14,9 @@ class CampagnaEdit extends Component
|
||||
|
||||
public function mount(Campagna $campagna)
|
||||
{
|
||||
// Modifica campagne riservata all'amministratore.
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$this->campagna = $campagna;
|
||||
$this->descrizione = $campagna->descrizione;
|
||||
$this->start_date = $campagna->start_date->format('Y-m-d');
|
||||
@@ -31,6 +34,10 @@ class CampagnaEdit extends Component
|
||||
|
||||
public function save()
|
||||
{
|
||||
// Re-check on the update request: route middleware guards the initial
|
||||
// load, but the action can be replayed on a persisted component.
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$this->validate();
|
||||
|
||||
$this->campagna->update([
|
||||
|
||||
@@ -12,6 +12,11 @@ class CampagnaIndex extends Component
|
||||
|
||||
public function deleteCampagna(int $id)
|
||||
{
|
||||
// Eliminazione campagne riservata all'amministratore. La index è
|
||||
// accessibile a chiunque abbia campagne.manage, quindi il gate deve
|
||||
// stare qui, non solo sul middleware di route.
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$campagna = Campagna::findOrFail($id);
|
||||
$campagna->delete();
|
||||
session()->flash('success', "Campagna '{$campagna->descrizione}' eliminata.");
|
||||
|
||||
@@ -12,6 +12,8 @@ class ProclamatoreCestino extends Component
|
||||
|
||||
public function restore(int $id)
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
|
||||
$proclamatore->restore();
|
||||
activity()->causedBy(auth()->user())
|
||||
@@ -22,6 +24,8 @@ class ProclamatoreCestino extends Component
|
||||
|
||||
public function forceDelete(int $id)
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
|
||||
|
||||
if ($proclamatore->assegnazioni()->exists()) {
|
||||
|
||||
@@ -22,6 +22,8 @@ class ProclamatoreCreate extends Component
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$this->validate();
|
||||
|
||||
$proclamatore = Proclamatore::create([
|
||||
|
||||
@@ -31,6 +31,8 @@ class ProclamatoreEdit extends Component
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$this->validate();
|
||||
|
||||
$this->proclamatore->update([
|
||||
|
||||
@@ -42,12 +42,20 @@ class ProclamatoreIndex extends Component
|
||||
|
||||
public function toggleActive(int $id)
|
||||
{
|
||||
// La lista è in sola lettura per i non-admin: creare/modificare/
|
||||
// eliminare proclamatori è riservato all'amministratore. Il check sta
|
||||
// nel metodo perché la rotta index resta accessibile con
|
||||
// proclamatori.manage.
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
$proclamatore->update(['attivo' => !$proclamatore->attivo]);
|
||||
}
|
||||
|
||||
public function deleteProclamatore(int $id)
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
|
||||
if ($proclamatore->assegnazioni()->aperte()->exists()) {
|
||||
@@ -61,6 +69,8 @@ class ProclamatoreIndex extends Component
|
||||
|
||||
public function anonimizza(int $id)
|
||||
{
|
||||
$this->authorize('settings.manage');
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
$proclamatore->anonimizza();
|
||||
session()->flash('success', "Dati personali del proclamatore anonimizzati (GDPR).");
|
||||
|
||||
70
app/Livewire/Profile.php
Normal file
70
app/Livewire/Profile.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
class Profile extends Component
|
||||
{
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
|
||||
public string $current_password = '';
|
||||
public string $password = '';
|
||||
public string $password_confirmation = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$this->name = $user->name;
|
||||
$this->email = $user->email;
|
||||
}
|
||||
|
||||
public function updateProfile()
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$validated = $this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
]);
|
||||
|
||||
session()->flash('profile_success', 'Profilo aggiornato con successo.');
|
||||
|
||||
// Ricarica la pagina così header e card mostrano subito i nuovi dati
|
||||
// (l'header vive nel layout, fuori dal componente).
|
||||
return $this->redirect(route('profile'), navigate: true);
|
||||
}
|
||||
|
||||
public function updatePassword(): void
|
||||
{
|
||||
// `current_password` verifica che la password attuale digitata combaci
|
||||
// con quella dell'utente loggato prima di consentire il cambio.
|
||||
$this->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
// Il cast `hashed` sul modello User cifra il valore in fase di salvataggio.
|
||||
auth()->user()->update([
|
||||
'password' => $this->password,
|
||||
]);
|
||||
|
||||
$this->reset(['current_password', 'password', 'password_confirmation']);
|
||||
|
||||
session()->flash('password_success', 'Password aggiornata con successo.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile', [
|
||||
'user' => auth()->user(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ class SettingsEdit extends Component
|
||||
public int $giorni_giacenza_prioritari = 180;
|
||||
public int $giorni_per_smarrito = 120;
|
||||
public int $home_limit_list = 10;
|
||||
public int $assignment_link_ttl_months = 1;
|
||||
public bool $pdf_viewer_show_download = true;
|
||||
public int $audit_retention_days = 365;
|
||||
|
||||
@@ -24,7 +23,6 @@ class SettingsEdit extends Component
|
||||
$this->giorni_giacenza_prioritari = $settings->giorni_giacenza_prioritari ?? 180;
|
||||
$this->giorni_per_smarrito = $settings->giorni_per_smarrito ?? 120;
|
||||
$this->home_limit_list = $settings->home_limit_list ?? 10;
|
||||
$this->assignment_link_ttl_months = $settings->assignment_link_ttl_hours ?? 1;
|
||||
$this->pdf_viewer_show_download = $settings->pdf_viewer_show_download ?? true;
|
||||
$this->audit_retention_days = $settings->audit_retention_days ?? 365;
|
||||
}
|
||||
@@ -37,7 +35,6 @@ class SettingsEdit extends Component
|
||||
'giorni_giacenza_prioritari' => 'required|integer|min:1|max:730',
|
||||
'giorni_per_smarrito' => 'required|integer|min:30|max:365',
|
||||
'home_limit_list' => 'required|integer|min:1|max:100',
|
||||
'assignment_link_ttl_months' => 'required|integer|min:1|max:24',
|
||||
'pdf_viewer_show_download' => 'required|boolean',
|
||||
'audit_retention_days' => 'required|integer|min:30|max:3650',
|
||||
];
|
||||
@@ -54,7 +51,6 @@ class SettingsEdit extends Component
|
||||
'giorni_giacenza_prioritari' => $this->giorni_giacenza_prioritari,
|
||||
'giorni_per_smarrito' => $this->giorni_per_smarrito,
|
||||
'home_limit_list' => $this->home_limit_list,
|
||||
'assignment_link_ttl_hours' => $this->assignment_link_ttl_months,
|
||||
'pdf_viewer_show_download' => $this->pdf_viewer_show_download,
|
||||
'audit_retention_days' => $this->audit_retention_days,
|
||||
]);
|
||||
|
||||
@@ -181,6 +181,7 @@ class XmlExchange extends Component
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'utenti_importati' => 0,
|
||||
'zone_importate' => 0,
|
||||
'tipologie_importate' => 0,
|
||||
'proclamatori_importati' => 0,
|
||||
@@ -212,22 +213,96 @@ class XmlExchange extends Component
|
||||
if ($settingsNode) {
|
||||
Setting::instance()->update([
|
||||
'congregazione_nome' => (string) ($settingsNode->congregazione_nome ?? ''),
|
||||
'public_base_url' => (string) ($settingsNode->public_base_url ?? ''),
|
||||
'logo_path' => (string) ($settingsNode->logo_path ?? ''),
|
||||
'giorni_giacenza_da_assegnare' => (int) ($settingsNode->giorni_giacenza_da_assegnare ?? 120),
|
||||
'giorni_giacenza_prioritari' => (int) ($settingsNode->giorni_giacenza_prioritari ?? 180),
|
||||
'giorni_per_smarrito' => (int) ($settingsNode->giorni_per_smarrito ?? 120),
|
||||
'home_limit_list' => (int) ($settingsNode->home_limit_list ?? 10),
|
||||
'assignment_link_ttl_hours' => (int) ($settingsNode->assignment_link_ttl_months ?? $settingsNode->assignment_link_ttl_hours ?? 1),
|
||||
'pdf_viewer_show_download' => ((int) ($settingsNode->pdf_viewer_show_download ?? 0)) === 1,
|
||||
'audit_retention_days' => (int) ($settingsNode->audit_retention_days ?? 730),
|
||||
// The first-run wizard was removed; an imported dataset always
|
||||
// represents a configured install, so setup is complete.
|
||||
'setup_completed' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Users are UPSERTED by email, never wiped. Deleting the users
|
||||
// table during a restore risks locking out the administrator
|
||||
// performing the import (e.g. a backup that predates their account,
|
||||
// or a partial file). Existing accounts are updated in place;
|
||||
// accounts in the file are (re)created. $userMap remaps the
|
||||
// created_by/returned_by FKs on the assignments below.
|
||||
$userMap = [];
|
||||
foreach (($xml->users->user ?? []) as $userNode) {
|
||||
$email = trim((string) ($userNode->email ?? ''));
|
||||
$legacyId = (string) ($userNode['legacy_id'] ?? '');
|
||||
|
||||
if ($email === '') {
|
||||
$this->pushImportIssue('utente', $legacyId, 'Email mancante');
|
||||
continue;
|
||||
}
|
||||
|
||||
$user = User::query()->firstOrNew(['email' => $email]);
|
||||
$user->name = (string) ($userNode->name ?? $email);
|
||||
if (! $user->exists) {
|
||||
// password is NOT NULL; the real hash is written raw below.
|
||||
$user->password = Str::password(24);
|
||||
}
|
||||
$user->save();
|
||||
|
||||
// Ensure every referenced role exists before syncing — the
|
||||
// standard three come from the seeder, but a custom role in the
|
||||
// backup would otherwise make syncRoles() throw.
|
||||
$roleNames = [];
|
||||
foreach (($userNode->roles->role ?? []) as $roleNode) {
|
||||
$roleName = trim((string) $roleNode);
|
||||
if ($roleName !== '') {
|
||||
\Spatie\Permission\Models\Role::firstOrCreate(['name' => $roleName, 'guard_name' => 'web']);
|
||||
$roleNames[] = $roleName;
|
||||
}
|
||||
}
|
||||
$user->syncRoles($roleNames);
|
||||
|
||||
// Password hash, remember_token, verification flag and
|
||||
// timestamps are written with the query builder: the raw stored
|
||||
// hash is preserved exactly (the 'hashed' cast would re-validate
|
||||
// the bcrypt cost and could reject a hash made with a different
|
||||
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
|
||||
$meta = [];
|
||||
$passwordHash = (string) ($userNode->password ?? '');
|
||||
if ($passwordHash !== '') {
|
||||
$meta['password'] = $passwordHash;
|
||||
}
|
||||
$rememberToken = (string) ($userNode->remember_token ?? '');
|
||||
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
|
||||
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
|
||||
|
||||
$createdAt = $this->normalizeDateTimeForDb((string) ($userNode->created_at ?? ''));
|
||||
if ($createdAt !== null) {
|
||||
$meta['created_at'] = $createdAt;
|
||||
}
|
||||
$updatedAt = $this->normalizeDateTimeForDb((string) ($userNode->updated_at ?? ''));
|
||||
if ($updatedAt !== null) {
|
||||
$meta['updated_at'] = $updatedAt;
|
||||
}
|
||||
|
||||
DB::table('users')->where('id', $user->id)->update($meta);
|
||||
|
||||
$stats['utenti_importati']++;
|
||||
if ($legacyId !== '') {
|
||||
$userMap[$legacyId] = $user->id;
|
||||
}
|
||||
}
|
||||
|
||||
$zoneMap = [];
|
||||
foreach (($xml->zones->zone ?? []) as $zoneNode) {
|
||||
$z = Zona::create([
|
||||
'nome' => (string) $zoneNode->nome,
|
||||
'attivo' => ((int) ($zoneNode->attivo ?? 1)) === 1,
|
||||
]);
|
||||
$this->applyRowMeta('zone', $z->id, $zoneNode);
|
||||
$stats['zone_importate']++;
|
||||
$legacyId = (string) ($zoneNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
@@ -241,6 +316,7 @@ class XmlExchange extends Component
|
||||
'nome' => (string) $tipologiaNode->nome,
|
||||
'attivo' => ((int) ($tipologiaNode->attivo ?? 1)) === 1,
|
||||
]);
|
||||
$this->applyRowMeta('tipologie', $t->id, $tipologiaNode);
|
||||
$stats['tipologie_importate']++;
|
||||
$legacyId = (string) ($tipologiaNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
@@ -255,6 +331,7 @@ class XmlExchange extends Component
|
||||
'cognome' => (string) $proclamatoreNode->cognome,
|
||||
'attivo' => ((int) ($proclamatoreNode->attivo ?? 1)) === 1,
|
||||
]);
|
||||
$this->applyRowMeta('proclamatori', $p->id, $proclamatoreNode, true);
|
||||
$stats['proclamatori_importati']++;
|
||||
$legacyId = (string) ($proclamatoreNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
@@ -270,6 +347,7 @@ class XmlExchange extends Component
|
||||
'start_date' => (string) $annoNode->start_date,
|
||||
'end_date' => (string) $annoNode->end_date,
|
||||
]);
|
||||
$this->applyRowMeta('anni_teocratici', $anno->id, $annoNode);
|
||||
$stats['anni_importati']++;
|
||||
$legacyId = (string) ($annoNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
@@ -284,6 +362,7 @@ class XmlExchange extends Component
|
||||
'start_date' => (string) $campagnaNode->start_date,
|
||||
'end_date' => (string) $campagnaNode->end_date,
|
||||
]);
|
||||
$this->applyRowMeta('campagne', $campagna->id, $campagnaNode);
|
||||
$stats['campagne_importate']++;
|
||||
$legacyId = (string) ($campagnaNode['legacy_id'] ?? '');
|
||||
if ($legacyId !== '') {
|
||||
@@ -310,6 +389,9 @@ class XmlExchange extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$pdfPath = (string) ($territorioNode->pdf_path ?? '');
|
||||
$thumbnailPath = (string) ($territorioNode->thumbnail_path ?? '');
|
||||
|
||||
$territorio = Territorio::create([
|
||||
'numero' => $numero,
|
||||
'zona_id' => $zoneMap[$legacyZonaId],
|
||||
@@ -318,7 +400,10 @@ class XmlExchange extends Component
|
||||
'note' => (string) ($territorioNode->note ?? ''),
|
||||
'attivo' => ((int) ($territorioNode->attivo ?? 1)) === 1,
|
||||
'prioritario' => ((int) ($territorioNode->prioritario ?? 0)) === 1,
|
||||
'pdf_path' => $pdfPath !== '' ? $pdfPath : null,
|
||||
'thumbnail_path' => $thumbnailPath !== '' ? $thumbnailPath : null,
|
||||
]);
|
||||
$this->applyRowMeta('territori', $territorio->id, $territorioNode, true);
|
||||
|
||||
$territoriNumeroVisti[$numero] = true;
|
||||
$stats['territori_importati']++;
|
||||
@@ -350,7 +435,24 @@ class XmlExchange extends Component
|
||||
|
||||
$legacyCampagna = (string) ($assegnazioneNode->legacy_campagna_id ?? '');
|
||||
|
||||
Assegnazione::create([
|
||||
// Remap the audit FKs onto the imported users; fall back to the
|
||||
// acting user when the referenced account isn't in the file.
|
||||
$legacyCreatedBy = (string) ($assegnazioneNode->legacy_created_by_id ?? '');
|
||||
$createdBy = ($legacyCreatedBy !== '' && isset($userMap[$legacyCreatedBy]))
|
||||
? $userMap[$legacyCreatedBy]
|
||||
: $actorId;
|
||||
|
||||
$returnedBy = null;
|
||||
if ($returnedAt !== null) {
|
||||
$legacyReturnedBy = (string) ($assegnazioneNode->legacy_returned_by_id ?? '');
|
||||
$returnedBy = ($legacyReturnedBy !== '' && isset($userMap[$legacyReturnedBy]))
|
||||
? $userMap[$legacyReturnedBy]
|
||||
: $actorId;
|
||||
}
|
||||
|
||||
$pdfAccessCode = (string) ($assegnazioneNode->pdf_access_code ?? '');
|
||||
|
||||
$assegnazione = Assegnazione::create([
|
||||
'territorio_id' => $territorioMap[$legacyTerritorio],
|
||||
'proclamatore_id' => $proclamatoreMap[$legacyProclamatore],
|
||||
'anno_teocratico_id' => $annoMap[$legacyAnno],
|
||||
@@ -358,10 +460,13 @@ class XmlExchange extends Component
|
||||
'returned_at' => $returnedAt,
|
||||
'counted_in_campaign' => ((int) ($assegnazioneNode->counted_in_campaign ?? 0)) === 1,
|
||||
'campaign_id' => $legacyCampagna !== '' && isset($campagnaMap[$legacyCampagna]) ? $campagnaMap[$legacyCampagna] : null,
|
||||
'link_sent' => ((int) ($assegnazioneNode->link_sent ?? 0)) === 1,
|
||||
'pdf_access_code' => $pdfAccessCode !== '' ? $pdfAccessCode : null,
|
||||
'note' => (string) ($assegnazioneNode->note ?? ''),
|
||||
'created_by' => $actorId,
|
||||
'returned_by' => $returnedAt !== null ? $actorId : null,
|
||||
'created_by' => $createdBy,
|
||||
'returned_by' => $returnedBy,
|
||||
]);
|
||||
$this->applyRowMeta('assegnazioni', $assegnazione->id, $assegnazioneNode);
|
||||
$stats['assegnazioni_importate']++;
|
||||
}
|
||||
});
|
||||
@@ -379,20 +484,39 @@ class XmlExchange extends Component
|
||||
return [
|
||||
'settings' => [
|
||||
'congregazione_nome' => (string) ($settings->congregazione_nome ?? ''),
|
||||
'public_base_url' => (string) ($settings->public_base_url ?? ''),
|
||||
'logo_path' => (string) ($settings->logo_path ?? ''),
|
||||
'giorni_giacenza_da_assegnare' => (int) ($settings->giorni_giacenza_da_assegnare ?? 120),
|
||||
'giorni_giacenza_prioritari' => (int) ($settings->giorni_giacenza_prioritari ?? 180),
|
||||
'giorni_per_smarrito' => (int) ($settings->giorni_per_smarrito ?? 120),
|
||||
'home_limit_list' => (int) ($settings->home_limit_list ?? 10),
|
||||
'assignment_link_ttl_months' => (int) ($settings->assignment_link_ttl_hours ?? 1),
|
||||
'pdf_viewer_show_download' => (int) ((bool) ($settings->pdf_viewer_show_download ?? false)),
|
||||
'audit_retention_days' => (int) ($settings->audit_retention_days ?? 730),
|
||||
'setup_completed' => (int) ((bool) ($settings->setup_completed ?? true)),
|
||||
],
|
||||
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(),
|
||||
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(),
|
||||
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo'])->toArray(),
|
||||
'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario'])->toArray(),
|
||||
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date'])->toArray(),
|
||||
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date'])->toArray(),
|
||||
'assegnazioni' => Assegnazione::query()->orderBy('id')->get(['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note'])->toArray(),
|
||||
// Full user rows, credentials included. getAuthPassword() bypasses
|
||||
// the $hidden guard to read the raw bcrypt hash; roles come from the
|
||||
// Spatie relation. This is sensitive data — see the note in the
|
||||
// export UI / HANDOFF.
|
||||
'users' => User::query()->with('roles')->orderBy('id')->get()->map(fn ($u) => [
|
||||
'legacy_id' => (string) $u->id,
|
||||
'name' => (string) $u->name,
|
||||
'email' => (string) $u->email,
|
||||
'password' => (string) $u->getAuthPassword(),
|
||||
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
|
||||
'remember_token' => (string) ($u->getRememberToken() ?? ''),
|
||||
'roles' => $u->roles->pluck('name')->all(),
|
||||
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
||||
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
||||
])->all(),
|
||||
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
|
||||
'tipologie' => Tipologia::query()->orderBy('id')->get(['id', 'nome', 'attivo', 'created_at', 'updated_at'])->toArray(),
|
||||
'proclamatori' => Proclamatore::query()->withTrashed()->orderBy('id')->get(['id', 'nome', 'cognome', 'attivo', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
|
||||
'territori' => Territorio::query()->withTrashed()->orderBy('id')->get(['id', 'numero', 'zona_id', 'tipologia_id', 'confini', 'note', 'attivo', 'prioritario', 'pdf_path', 'thumbnail_path', 'deleted_at', 'created_at', 'updated_at'])->toArray(),
|
||||
'anni_teocratici' => AnnoTeocratico::query()->orderBy('id')->get(['id', 'label', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
|
||||
'campagne' => Campagna::query()->orderBy('id')->get(['id', 'descrizione', 'start_date', 'end_date', 'created_at', 'updated_at'])->toArray(),
|
||||
'assegnazioni' => Assegnazione::query()->orderBy('id')->get(['id', 'territorio_id', 'proclamatore_id', 'anno_teocratico_id', 'campaign_id', 'assigned_at', 'returned_at', 'counted_in_campaign', 'note', 'created_by', 'returned_by', 'link_sent', 'pdf_access_code', 'created_at', 'updated_at'])->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -717,43 +841,59 @@ class XmlExchange extends Component
|
||||
$this->addXmlText($settings, $key, (string) $value);
|
||||
}
|
||||
|
||||
// Users are absent from the legacy-SQL dataset, so guard with ?? [].
|
||||
$usersNode = $xml->addChild('users');
|
||||
foreach (($dataset['users'] ?? []) as $user) {
|
||||
$node = $usersNode->addChild('user');
|
||||
if (isset($user['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $user['legacy_id']);
|
||||
}
|
||||
$this->addXmlText($node, 'name', (string) ($user['name'] ?? ''));
|
||||
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
|
||||
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
||||
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
|
||||
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
|
||||
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
|
||||
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
||||
$rolesNode = $node->addChild('roles');
|
||||
foreach (($user['roles'] ?? []) as $roleName) {
|
||||
$this->addXmlText($rolesNode, 'role', (string) $roleName);
|
||||
}
|
||||
}
|
||||
|
||||
$zonesNode = $xml->addChild('zones');
|
||||
foreach ($dataset['zones'] as $zone) {
|
||||
$node = $zonesNode->addChild('zone');
|
||||
if (isset($zone['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $zone['legacy_id']);
|
||||
}
|
||||
$this->addLegacyIdAttribute($node, $zone);
|
||||
$this->addXmlText($node, 'nome', (string) ($zone['nome'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1)));
|
||||
$this->addTimestampNodes($node, $zone);
|
||||
}
|
||||
|
||||
$tipologieNode = $xml->addChild('tipologie');
|
||||
foreach ($dataset['tipologie'] as $tipologia) {
|
||||
$node = $tipologieNode->addChild('tipologia');
|
||||
if (isset($tipologia['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $tipologia['legacy_id']);
|
||||
}
|
||||
$this->addLegacyIdAttribute($node, $tipologia);
|
||||
$this->addXmlText($node, 'nome', (string) ($tipologia['nome'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1)));
|
||||
$this->addTimestampNodes($node, $tipologia);
|
||||
}
|
||||
|
||||
$proclamatoriNode = $xml->addChild('proclamatori');
|
||||
foreach ($dataset['proclamatori'] as $proclamatore) {
|
||||
$node = $proclamatoriNode->addChild('proclamatore');
|
||||
if (isset($proclamatore['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $proclamatore['legacy_id']);
|
||||
}
|
||||
$this->addLegacyIdAttribute($node, $proclamatore);
|
||||
$this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
|
||||
$this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
|
||||
$node->addChild('deleted_at', (string) ($proclamatore['deleted_at'] ?? ''));
|
||||
$this->addTimestampNodes($node, $proclamatore);
|
||||
}
|
||||
|
||||
$territoriNode = $xml->addChild('territori');
|
||||
foreach ($dataset['territori'] as $territorio) {
|
||||
$node = $territoriNode->addChild('territorio');
|
||||
if (isset($territorio['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $territorio['legacy_id']);
|
||||
}
|
||||
$this->addLegacyIdAttribute($node, $territorio);
|
||||
$this->addXmlText($node, 'numero', (string) ($territorio['numero'] ?? ''));
|
||||
$node->addChild('legacy_zona_id', (string) ($territorio['legacy_zona_id'] ?? ($territorio['zona_id'] ?? '')));
|
||||
$node->addChild('legacy_tipologia_id', (string) ($territorio['legacy_tipologia_id'] ?? ($territorio['tipologia_id'] ?? '')));
|
||||
@@ -761,44 +901,49 @@ class XmlExchange extends Component
|
||||
$this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
|
||||
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
|
||||
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
|
||||
$this->addXmlText($node, 'pdf_path', (string) ($territorio['pdf_path'] ?? ''));
|
||||
$this->addXmlText($node, 'thumbnail_path', (string) ($territorio['thumbnail_path'] ?? ''));
|
||||
$node->addChild('deleted_at', (string) ($territorio['deleted_at'] ?? ''));
|
||||
$this->addTimestampNodes($node, $territorio);
|
||||
}
|
||||
|
||||
$anniNode = $xml->addChild('anni_teocratici');
|
||||
foreach ($dataset['anni_teocratici'] as $anno) {
|
||||
$node = $anniNode->addChild('anno');
|
||||
if (isset($anno['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $anno['legacy_id']);
|
||||
}
|
||||
$this->addLegacyIdAttribute($node, $anno);
|
||||
$this->addXmlText($node, 'label', (string) ($anno['label'] ?? ''));
|
||||
$node->addChild('start_date', (string) ($anno['start_date'] ?? ''));
|
||||
$node->addChild('end_date', (string) ($anno['end_date'] ?? ''));
|
||||
$this->addTimestampNodes($node, $anno);
|
||||
}
|
||||
|
||||
$campagneNode = $xml->addChild('campagne');
|
||||
foreach ($dataset['campagne'] as $campagna) {
|
||||
$node = $campagneNode->addChild('campagna');
|
||||
if (isset($campagna['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $campagna['legacy_id']);
|
||||
}
|
||||
$this->addLegacyIdAttribute($node, $campagna);
|
||||
$this->addXmlText($node, 'descrizione', (string) ($campagna['descrizione'] ?? ''));
|
||||
$node->addChild('start_date', (string) ($campagna['start_date'] ?? ''));
|
||||
$node->addChild('end_date', (string) ($campagna['end_date'] ?? ''));
|
||||
$this->addTimestampNodes($node, $campagna);
|
||||
}
|
||||
|
||||
$assegnazioniNode = $xml->addChild('assegnazioni');
|
||||
foreach ($dataset['assegnazioni'] as $assegnazione) {
|
||||
$node = $assegnazioniNode->addChild('assegnazione');
|
||||
if (isset($assegnazione['legacy_id'])) {
|
||||
$node->addAttribute('legacy_id', (string) $assegnazione['legacy_id']);
|
||||
}
|
||||
$this->addLegacyIdAttribute($node, $assegnazione);
|
||||
$node->addChild('legacy_territorio_id', (string) ($assegnazione['legacy_territorio_id'] ?? ($assegnazione['territorio_id'] ?? '')));
|
||||
$node->addChild('legacy_proclamatore_id', (string) ($assegnazione['legacy_proclamatore_id'] ?? ($assegnazione['proclamatore_id'] ?? '')));
|
||||
$node->addChild('legacy_anno_id', (string) ($assegnazione['legacy_anno_id'] ?? ($assegnazione['anno_teocratico_id'] ?? '')));
|
||||
$node->addChild('legacy_campagna_id', (string) ($assegnazione['legacy_campagna_id'] ?? ($assegnazione['campaign_id'] ?? '')));
|
||||
$node->addChild('legacy_created_by_id', (string) ($assegnazione['legacy_created_by_id'] ?? ($assegnazione['created_by'] ?? '')));
|
||||
$node->addChild('legacy_returned_by_id', (string) ($assegnazione['legacy_returned_by_id'] ?? ($assegnazione['returned_by'] ?? '')));
|
||||
$node->addChild('assigned_at', (string) ($assegnazione['assigned_at'] ?? ''));
|
||||
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
|
||||
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
|
||||
$node->addChild('link_sent', (string) ((int) ($assegnazione['link_sent'] ?? 0)));
|
||||
$this->addXmlText($node, 'pdf_access_code', (string) ($assegnazione['pdf_access_code'] ?? ''));
|
||||
$this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
|
||||
$this->addTimestampNodes($node, $assegnazione);
|
||||
}
|
||||
|
||||
return $xml->asXML() ?: '';
|
||||
@@ -812,4 +957,75 @@ class XmlExchange extends Component
|
||||
|
||||
return $child;
|
||||
}
|
||||
|
||||
private function addTimestampNodes(\SimpleXMLElement $node, array $row): void
|
||||
{
|
||||
$node->addChild('created_at', (string) ($row['created_at'] ?? ''));
|
||||
$node->addChild('updated_at', (string) ($row['updated_at'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the legacy_id attribute used to remap foreign keys on import.
|
||||
* The legacy-SQL conversion supplies an explicit 'legacy_id'; the
|
||||
* current-app export supplies the real 'id' — either works as the stable
|
||||
* key the referencing rows (territori, assegnazioni, …) point back to.
|
||||
*/
|
||||
private function addLegacyIdAttribute(\SimpleXMLElement $node, array $row): void
|
||||
{
|
||||
$legacyId = $row['legacy_id'] ?? $row['id'] ?? null;
|
||||
if ($legacyId !== null && (string) $legacyId !== '') {
|
||||
$node->addAttribute('legacy_id', (string) $legacyId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a full datetime (created_at/updated_at/deleted_at/email_verified_at)
|
||||
* for storage, returning a `Y-m-d H:i:s` string or null. Unlike
|
||||
* normalizeDateForDb this keeps the time component.
|
||||
*/
|
||||
private function normalizeDateTimeForDb(string $raw): ?string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '' || str_starts_with($raw, '0000-00-00')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($raw)->toDateTimeString();
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore created_at/updated_at (and deleted_at, when the model soft
|
||||
* deletes) from the XML node after the row has been created. Written with
|
||||
* the query builder so Eloquent does not stamp its own timestamps over the
|
||||
* imported values. Only columns present in the XML are touched.
|
||||
*/
|
||||
private function applyRowMeta(string $table, int $id, \SimpleXMLElement $node, bool $withSoftDelete = false): void
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
$createdAt = $this->normalizeDateTimeForDb((string) ($node->created_at ?? ''));
|
||||
if ($createdAt !== null) {
|
||||
$updates['created_at'] = $createdAt;
|
||||
}
|
||||
|
||||
$updatedAt = $this->normalizeDateTimeForDb((string) ($node->updated_at ?? ''));
|
||||
if ($updatedAt !== null) {
|
||||
$updates['updated_at'] = $updatedAt;
|
||||
}
|
||||
|
||||
if ($withSoftDelete) {
|
||||
$deletedAt = $this->normalizeDateTimeForDb((string) ($node->deleted_at ?? ''));
|
||||
if ($deletedAt !== null) {
|
||||
$updates['deleted_at'] = $deletedAt;
|
||||
}
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
DB::table($table)->where('id', $id)->update($updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ class TerritorioCestino extends Component
|
||||
|
||||
public function restore(int $id)
|
||||
{
|
||||
$this->authorize('territori.manage');
|
||||
|
||||
$territorio = Territorio::onlyTrashed()->findOrFail($id);
|
||||
$territorio->restore();
|
||||
activity()->causedBy(auth()->user())
|
||||
@@ -22,6 +24,8 @@ class TerritorioCestino extends Component
|
||||
|
||||
public function forceDelete(int $id)
|
||||
{
|
||||
$this->authorize('territori.manage');
|
||||
|
||||
$territorio = Territorio::onlyTrashed()->findOrFail($id);
|
||||
|
||||
if ($territorio->assegnazioni()->exists()) {
|
||||
|
||||
@@ -36,6 +36,8 @@ class TerritorioCreate extends Component
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->authorize('territori.manage');
|
||||
|
||||
$this->validate();
|
||||
|
||||
$data = [
|
||||
|
||||
@@ -49,6 +49,8 @@ class TerritorioEdit extends Component
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->authorize('territori.manage');
|
||||
|
||||
$this->validate();
|
||||
|
||||
$data = [
|
||||
@@ -82,6 +84,8 @@ class TerritorioEdit extends Component
|
||||
|
||||
public function removePdf()
|
||||
{
|
||||
$this->authorize('territori.manage');
|
||||
|
||||
if ($this->territorio->pdf_path) {
|
||||
Storage::disk('public')->delete($this->territorio->pdf_path);
|
||||
if ($this->territorio->thumbnail_path) {
|
||||
|
||||
@@ -96,6 +96,11 @@ class TerritorioIndex extends Component
|
||||
|
||||
public function toggleActive(Territorio $territorio)
|
||||
{
|
||||
// La lista è ora visibile agli utenti operativi (territori.assign), ma
|
||||
// modificare/eliminare i territori resta riservato all'amministratore.
|
||||
// Il check DEVE stare nel metodo: la rotta index non lo garantisce più.
|
||||
$this->authorize('territori.manage');
|
||||
|
||||
$territorio->update(['attivo' => !$territorio->attivo]);
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($territorio)
|
||||
@@ -105,6 +110,8 @@ class TerritorioIndex extends Component
|
||||
|
||||
public function togglePriority(Territorio $territorio)
|
||||
{
|
||||
$this->authorize('territori.manage');
|
||||
|
||||
$territorio->update(['prioritario' => !$territorio->prioritario]);
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($territorio)
|
||||
@@ -114,6 +121,8 @@ class TerritorioIndex extends Component
|
||||
|
||||
public function deleteTerritorio(Territorio $territorio)
|
||||
{
|
||||
$this->authorize('territori.manage');
|
||||
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($territorio)
|
||||
->log('soft_deleted');
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Livewire\Territori;
|
||||
use Livewire\Component;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Setting;
|
||||
|
||||
class TerritorioShow extends Component
|
||||
{
|
||||
@@ -26,7 +25,7 @@ class TerritorioShow extends Component
|
||||
|
||||
return view('livewire.territori.territorio-show', [
|
||||
'activeAssignment' => $this->territorio->assegnazioneCorrente,
|
||||
'assignmentLinkTtlMonths' => (int) Setting::getValue('assignment_link_ttl_hours', 1),
|
||||
'assignmentScadenza' => $this->territorio->assegnazioneCorrente?->data_consegna_presunta,
|
||||
'assegnazioniPerAnno' => $assegnazioni,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,26 @@ class Assegnazione extends Model
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
// Assegnare un territorio ne azzera il flag "prioritario": una volta in
|
||||
// carico a un proclamatore (assegnazione aperta) non ha più senso
|
||||
// segnalarlo come prioritario. La priorità automatica per soglia di
|
||||
// giacenza riguarda solo i territori in reparto, quindi resta il solo
|
||||
// flag manuale da spegnere. Vale per ogni via di creazione (pagina
|
||||
// Assegna, CRUD del Registro, import XML di assegnazioni aperte).
|
||||
static::created(function (self $assegnazione): void {
|
||||
if ($assegnazione->returned_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$territorio = $assegnazione->territorio;
|
||||
if ($territorio && $territorio->prioritario) {
|
||||
$territorio->update(['prioritario' => false]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Relationships ─────────────────────────────────────────
|
||||
|
||||
public function territorio()
|
||||
@@ -83,6 +103,40 @@ class Assegnazione extends Model
|
||||
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
|
||||
{
|
||||
if ($this->pdf_access_code) {
|
||||
|
||||
@@ -101,3 +101,68 @@
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* ─── Responsive list tables ───────────────────────────────────────────────
|
||||
Under 640px (a phone held in portrait; rotate to landscape and the width
|
||||
grows past the breakpoint, restoring the normal table) every table with the
|
||||
`table-cards` class reflows: the header is hidden and each row becomes a
|
||||
card, with each cell shown as "LABEL … value" (the label comes from the
|
||||
cell's data-label attribute). Cells with no data-label (the actions column,
|
||||
marked `.cell-actions`) span the row full width. */
|
||||
@media (max-width: 640px) {
|
||||
table.table-cards,
|
||||
table.table-cards tbody {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
table.table-cards thead {
|
||||
display: none;
|
||||
}
|
||||
table.table-cards tr {
|
||||
display: block;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
padding: 4px 2px;
|
||||
background: #fff;
|
||||
}
|
||||
/* zebra striping would fight the card borders */
|
||||
table.table-cards.table-striped tbody tr:nth-child(even) {
|
||||
background: #fff;
|
||||
}
|
||||
table.table-cards td {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
padding: 6px 12px;
|
||||
border: none !important;
|
||||
}
|
||||
table.table-cards td[data-label]::before {
|
||||
content: attr(data-label);
|
||||
flex: 0 0 auto;
|
||||
margin-right: auto;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
/* Actions row: full width, buttons wrapped to the right */
|
||||
table.table-cards td.cell-actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f3f4f6 !important;
|
||||
}
|
||||
/* Cells spanning all columns (empty state, inline edit form) stay full
|
||||
width and block, so their inner layout isn't squeezed by the flex row. */
|
||||
table.table-cards td[colspan] {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,27 @@
|
||||
<span class="label">Data</span>
|
||||
<span class="value">{{ $assignment->assigned_at->format('d/m/Y') }}</span>
|
||||
</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>
|
||||
|
||||
@if($showDownload)
|
||||
|
||||
@@ -32,13 +32,15 @@
|
||||
</span>
|
||||
</div>
|
||||
<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)">
|
||||
<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>
|
||||
<span class="text-sm" style="color:rgba(255,255,255,0.9)">
|
||||
{{ auth()->user()->name }}
|
||||
<span class="text-xs" style="color:rgba(255,255,255,0.6)">({{ auth()->user()->roles->first()?->name ?? 'utente' }})</span>
|
||||
<a href="{{ route('profile') }}" title="Il mio profilo"
|
||||
class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-lg transition {{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}"
|
||||
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)'">
|
||||
<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="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>
|
||||
</div>
|
||||
</a>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@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)'">
|
||||
@@ -70,7 +72,7 @@
|
||||
Home
|
||||
</a>
|
||||
|
||||
@can('territori.manage')
|
||||
@can('territori.assign')
|
||||
<a href="{{ route('territori.index') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('territori.*') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||
@@ -149,6 +151,7 @@
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
@can('settings.manage')
|
||||
<div class="pt-4 mt-4 border-t border-gray-200">
|
||||
<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' }}">
|
||||
@@ -156,6 +159,17 @@
|
||||
Privacy
|
||||
</a>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
{{-- Utente (solo mobile: su desktop è nell'header) --}}
|
||||
<a href="{{ route('profile') }}"
|
||||
class="lg:hidden flex items-center gap-3 px-3 py-2.5 mt-4 pt-4 border-t border-gray-200 text-sm font-medium rounded-lg transition-all duration-150 {{ request()->routeIs('profile') ? 'sidebar-active' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' }}">
|
||||
<svg class="h-5 w-5 flex-none" 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="flex flex-col leading-tight min-w-0">
|
||||
<span class="truncate">{{ auth()->user()->name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -99,14 +99,25 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold" style="color:#3730a3">Territori Assegnati ({{ $assegnazioniAperte->count() }})</h3>
|
||||
<p class="text-xs" style="color:#4f46e5">Link PDF · stato invio · link valido {{ $linkTtlMonths }} {{ $linkTtlMonths === 1 ? 'mese' : 'mesi' }}</p>
|
||||
<p class="text-xs" style="color:#4f46e5">Link PDF · stato invio · il link scade alla soglia rientro ({{ $sogliaRientro }} giorni)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ricerca rapida: territorio, numero, zona, proclamatore in un solo campo --}}
|
||||
<div class="px-5 py-3 border-b border-gray-100" style="background:#fafafa">
|
||||
<div class="relative">
|
||||
<svg class="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
<input wire:model.live.debounce.300ms="linkSearch" type="text"
|
||||
placeholder="Cerca per territorio, numero, zona o proclamatore..."
|
||||
class="block w-full rounded-lg border-gray-300 text-sm pl-9 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-gray-100">
|
||||
@foreach($assegnazioniAperte as $a)
|
||||
@forelse($assegnazioniAperteVisibili as $a)
|
||||
@php
|
||||
$pdfUrl = $a->shortPdfUrl();
|
||||
$linkScaduto = ! auth()->check() && $a->assigned_at->copy()->addMonths($linkTtlMonths)->isPast();
|
||||
$linkScaduto = ! auth()->check() && $a->data_consegna_presunta && $a->data_consegna_presunta->isPast();
|
||||
@endphp
|
||||
<div class="px-5 py-4 hover:bg-indigo-50/30 transition-colors">
|
||||
{{-- Testata riga --}}
|
||||
@@ -168,7 +179,9 @@
|
||||
<p class="mt-2 text-xs text-gray-300">Nessun PDF disponibile</p>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@empty
|
||||
<div class="px-5 py-8 text-center text-sm text-gray-400">Nessun risultato per "{{ $linkSearch }}".</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-striped">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-striped table-cards">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Data/Ora</th>
|
||||
@@ -39,9 +39,9 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($activities as $activity)
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
<td class="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">{{ $activity->created_at->format('d/m/Y H:i:s') }}</td>
|
||||
<td class="px-3 py-2 text-xs">{{ $activity->causer?->name ?? data_get($activity->properties, 'causer_name') ?? 'Sistema' }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<td data-label="Data/Ora" class="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">{{ $activity->created_at->format('d/m/Y H:i:s') }}</td>
|
||||
<td data-label="Utente" class="px-3 py-2 text-xs">{{ $activity->causer?->name ?? data_get($activity->properties, 'causer_name') ?? 'Sistema' }}</td>
|
||||
<td data-label="Evento" class="px-3 py-2">
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full
|
||||
{{ match($activity->description) {
|
||||
'created' => 'bg-green-100 text-green-800',
|
||||
@@ -56,14 +56,14 @@
|
||||
{{ $activity->description }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-600">
|
||||
<td data-label="Soggetto" class="px-3 py-2 text-xs text-gray-600">
|
||||
@if($activity->subject_type)
|
||||
{{ class_basename($activity->subject_type) }} #{{ $activity->subject_id }}
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-500">
|
||||
<td data-label="Dettagli" class="px-3 py-2 text-xs text-gray-500">
|
||||
@if($activity->properties->isNotEmpty())
|
||||
<details>
|
||||
<summary class="cursor-pointer text-indigo-600 hover:text-indigo-800">Mostra</summary>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@error('descrizione') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="start_date" class="block text-sm font-medium text-gray-700">Data Inizio *</label>
|
||||
<input wire:model="start_date" type="date" id="start_date" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" required>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div>
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Campagne</h1>
|
||||
@can('campagne.manage')
|
||||
@can('settings.manage')
|
||||
<a href="{{ route('campagne.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
|
||||
<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="M12 4v16m8-8H4"/></svg>
|
||||
Nuova Campagna
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped table-cards">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Descrizione</th>
|
||||
@@ -25,10 +25,10 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($campagne as $campagna)
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ $campagna->descrizione }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $campagna->start_date->format('d/m/Y') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $campagna->end_date->format('d/m/Y') }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<td data-label="Descrizione" class="px-4 py-3 text-sm font-medium text-gray-900">{{ $campagna->descrizione }}</td>
|
||||
<td data-label="Inizio" class="px-4 py-3 text-sm text-gray-600">{{ $campagna->start_date->format('d/m/Y') }}</td>
|
||||
<td data-label="Fine" class="px-4 py-3 text-sm text-gray-600">{{ $campagna->end_date->format('d/m/Y') }}</td>
|
||||
<td data-label="Stato" class="px-4 py-3 text-sm">
|
||||
@if($campagna->is_attiva)
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">Attiva</span>
|
||||
@elseif($campagna->end_date->isPast())
|
||||
@@ -37,7 +37,7 @@
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-800">Futura</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<td data-label="% Percorrenza" class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-20 bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-indigo-500 h-2 rounded-full" style="width:{{ min($campagna->percentuale_percorrenza, 100) }}%"></div>
|
||||
@@ -45,12 +45,12 @@
|
||||
<span class="text-xs text-gray-600">{{ $campagna->percentuale_percorrenza }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<td class="px-4 py-3 text-sm text-right cell-actions">
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<a href="{{ route('campagne.show', $campagna) }}" class="btn-action btn-action-indigo" title="Dettaglio">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
</a>
|
||||
@can('campagne.manage')
|
||||
@can('settings.manage')
|
||||
<a href="{{ route('campagne.edit', $campagna) }}" class="btn-action btn-action-gray" title="Modifica">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</a>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $campagna->descrizione }}</h1>
|
||||
</div>
|
||||
@can('campagne.manage')
|
||||
@can('settings.manage')
|
||||
<a href="{{ route('campagne.edit', $campagna) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Modifica
|
||||
@@ -50,7 +50,7 @@
|
||||
<h3 class="text-sm font-semibold text-gray-700">Territori Conteggiati</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
|
||||
@@ -63,11 +63,11 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($conteggiate as $a)
|
||||
<tr>
|
||||
<td class="px-4 py-2"><a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline">N° {{ $a->territorio?->numero }}</a></td>
|
||||
<td class="px-4 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
||||
<td class="px-4 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
|
||||
<td class="px-4 py-2">{{ $a->returned_at?->format('d/m/Y') }}</td>
|
||||
<td class="px-4 py-2">{{ $a->giorni }}</td>
|
||||
<td data-label="Territorio" class="px-4 py-2"><a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline">N° {{ $a->territorio?->numero }}</a></td>
|
||||
<td data-label="Proclamatore" class="px-4 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
||||
<td data-label="Assegnato" class="px-4 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
|
||||
<td data-label="Rientrato" class="px-4 py-2">{{ $a->returned_at?->format('d/m/Y') }}</td>
|
||||
<td data-label="Giorni" class="px-4 py-2">{{ $a->giorni }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped table-cards">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Nome Completo</th>
|
||||
@@ -19,13 +20,15 @@
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@forelse($proclamatori as $proclamatore)
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-semibold text-gray-900">{{ $proclamatore->nome_completo }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->deleted_at->format('d/m/Y H:i') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<td data-label="Nome completo" class="px-4 py-3 text-sm font-semibold text-gray-900">{{ $proclamatore->nome_completo }}</td>
|
||||
<td data-label="Eliminato il" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->deleted_at->format('d/m/Y H:i') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right cell-actions">
|
||||
@can('settings.manage')
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<button wire:click="restore({{ $proclamatore->id }})" class="btn-action btn-action-green">Ripristina</button>
|
||||
<button wire:click="forceDelete({{ $proclamatore->id }})" wire:confirm="Eliminare DEFINITIVAMENTE questo proclamatore? Questa azione è irreversibile." class="btn-action btn-action-red">Elimina</button>
|
||||
</div>
|
||||
@endcan
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
@@ -35,6 +38,7 @@
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="px-4 py-3 border-t border-gray-200">
|
||||
{{ $proclamatori->links() }}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div>
|
||||
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Proclamatori</h1>
|
||||
@can('proclamatori.manage')
|
||||
@can('settings.manage')
|
||||
<a href="{{ route('proclamatori.create') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
|
||||
<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="M12 4v16m8-8H4"/></svg>
|
||||
Nuovo Proclamatore
|
||||
@@ -19,7 +19,7 @@
|
||||
<option value="inattivo">Inattivi</option>
|
||||
</select>
|
||||
<div class="text-xs text-gray-500 flex items-center">
|
||||
@can('proclamatori.manage')
|
||||
@can('settings.manage')
|
||||
<a href="{{ route('proclamatori.cestino') }}" class="text-red-500 hover:text-red-700">🗑 Cestino</a>
|
||||
@endcan
|
||||
</div>
|
||||
@@ -29,7 +29,7 @@
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped table-cards">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th wire:click="sortBy('cognome')" class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer hover:text-gray-800 transition-colors">
|
||||
@@ -46,22 +46,22 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($proclamatori as $proclamatore)
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{ $proclamatore->nome }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<td data-label="Cognome" class="px-4 py-3 text-sm font-medium text-gray-900">{{ $proclamatore->cognome }}</td>
|
||||
<td data-label="Nome" class="px-4 py-3 text-sm text-gray-700">{{ $proclamatore->nome }}</td>
|
||||
<td data-label="Stato" class="px-4 py-3 text-sm">
|
||||
@if($proclamatore->attivo)
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">Attivo</span>
|
||||
@else
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600">Inattivo</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<td data-label="Territori assegnati" class="px-4 py-3 text-sm text-gray-600">{{ $proclamatore->assegnazioni_aperte_count ?? $proclamatore->assegnazioni()->aperte()->count() }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right cell-actions">
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<a href="{{ route('proclamatori.show', $proclamatore) }}" class="btn-action btn-action-indigo" title="Dettaglio">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
</a>
|
||||
@can('proclamatori.manage')
|
||||
@can('settings.manage')
|
||||
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="btn-action btn-action-gray" title="Modifica">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</a>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $proclamatore->nome_completo }}</h1>
|
||||
</div>
|
||||
@can('proclamatori.manage')
|
||||
@can('settings.manage')
|
||||
<a href="{{ route('proclamatori.edit', $proclamatore) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Modifica
|
||||
@@ -44,7 +44,7 @@
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold text-indigo-600 mb-2">Anno Teocratico {{ $annoLabel }}</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Territorio</th>
|
||||
@@ -57,21 +57,21 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($assegnazioni as $assegnazione)
|
||||
<tr>
|
||||
<td class="px-3 py-2">
|
||||
<td data-label="Territorio" class="px-3 py-2">
|
||||
<a href="{{ route('territori.show', $assegnazione->territorio_id) }}" class="text-indigo-600 hover:underline">
|
||||
{{ $assegnazione->territorio?->numero ?? 'N/A' }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||
<td data-label="Rientrato" class="px-3 py-2">
|
||||
@if($assegnazione->returned_at)
|
||||
{{ $assegnazione->returned_at->format('d/m/Y') }}
|
||||
@else
|
||||
<span class="text-amber-600 font-medium">In corso</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2">{{ $assegnazione->giorni }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</td>
|
||||
<td data-label="Campagna" class="px-3 py-2">
|
||||
@if($assegnazione->counted_in_campaign)
|
||||
<span class="text-green-600">{{ $assegnazione->campagna?->descrizione }}</span>
|
||||
@else
|
||||
|
||||
94
resources/views/livewire/profile.blade.php
Normal file
94
resources/views/livewire/profile.blade.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<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 space-y-6">
|
||||
{{-- Intestazione: avatar iniziale + nome --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 flex items-center gap-4">
|
||||
<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>
|
||||
|
||||
{{-- Dati profilo --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Dati profilo</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Aggiorna nome e cognome ed email.</p>
|
||||
|
||||
@if(session('profile_success'))
|
||||
<div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
|
||||
{{ session('profile_success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form wire:submit="updateProfile" class="space-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700">Nome e cognome</label>
|
||||
<input wire:model="name" type="text" id="name" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input wire:model="email" type="email" id="email" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
|
||||
Salva modifiche
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Cambio password --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Cambia password</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Per motivi di sicurezza conferma la password attuale.</p>
|
||||
|
||||
@if(session('password_success'))
|
||||
<div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
|
||||
{{ session('password_success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form wire:submit="updatePassword" class="space-y-4">
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium text-gray-700">Password attuale</label>
|
||||
<input wire:model="current_password" type="password" id="current_password" autocomplete="current-password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('current_password') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Nuova password</label>
|
||||
<input wire:model="password" type="password" id="password" autocomplete="new-password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('password') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">Conferma nuova password</label>
|
||||
<input wire:model="password_confirmation" type="password" id="password_confirmation" autocomplete="new-password" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-lg transition" style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
|
||||
Aggiorna password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Registro Assegnazioni</h1>
|
||||
@can('settings.manage')
|
||||
<button wire:click="openCreate" class="btn-action btn-action-indigo" style="padding:8px 18px;font-size:14px;border-radius:8px">
|
||||
@@ -42,7 +42,7 @@
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-striped">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-striped table-cards">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th wire:click="sortBy('territorio_numero')" class="px-3 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer">
|
||||
@@ -67,7 +67,7 @@
|
||||
@forelse($assegnazioni as $a)
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
@php($temporaryPdfUrl = !$a->returned_at ? $a->shortPdfUrl() : null)
|
||||
<td class="px-3 py-2">
|
||||
<td data-label="Territorio" class="px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
@if($a->territorio?->thumbnail_path)
|
||||
<img src="{{ asset('storage/' . $a->territorio->thumbnail_path) }}"
|
||||
@@ -80,18 +80,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
||||
<td class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<td data-label="Proclamatore" class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
||||
<td data-label="Assegnato" class="px-3 py-2">{{ $a->assigned_at->format('d/m/Y') }}</td>
|
||||
<td data-label="Rientrato" class="px-3 py-2">
|
||||
@if($a->returned_at)
|
||||
{{ $a->returned_at->format('d/m/Y') }}
|
||||
@else
|
||||
<span class="text-amber-600 font-medium text-xs">In corso</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2">{{ $a->giorni }}</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-500">{{ $a->annoTeocratico?->label }}</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<td data-label="Giorni" class="px-3 py-2">{{ $a->giorni }}</td>
|
||||
<td data-label="Anno" class="px-3 py-2 text-xs text-gray-500">{{ $a->annoTeocratico?->label }}</td>
|
||||
<td data-label="Campagna" class="px-3 py-2 text-xs">
|
||||
@if($a->counted_in_campaign)
|
||||
<span class="text-green-600">{{ $a->campagna?->descrizione }}</span>
|
||||
@else
|
||||
@@ -99,8 +99,8 @@
|
||||
@endif
|
||||
</td>
|
||||
@can('settings.manage')
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<td class="px-3 py-2 whitespace-nowrap cell-actions">
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
@if($temporaryPdfUrl)
|
||||
<a href="{{ $temporaryPdfUrl }}"
|
||||
target="_blank"
|
||||
@@ -143,7 +143,7 @@
|
||||
{{ $editingId ? 'Modifica voce' : 'Nuova voce' }}
|
||||
</h2>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;">
|
||||
{{-- Territorio --}}
|
||||
<div style="grid-column:1/-1;">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;display:block;margin-bottom:4px;">Territorio *</label>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<div>
|
||||
<label for="giorni_per_smarrito" class="block text-sm font-medium text-gray-700">Soglia Rientro (giorni)</label>
|
||||
<p class="text-xs text-gray-500 mb-1">Dopo quanti giorni dall'assegnazione un territorio appare nella lista "da rientrare".</p>
|
||||
<p class="text-xs text-gray-500 mb-1">Dopo quanti giorni dall'assegnazione un territorio appare nella lista "da rientrare". Determina anche la scadenza del link PDF condivisibile.</p>
|
||||
<input wire:model="giorni_per_smarrito" type="number" min="30" max="365" id="giorni_per_smarrito" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('giorni_per_smarrito') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
@@ -56,13 +56,6 @@
|
||||
@error('home_limit_list') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="assignment_link_ttl_months" class="block text-sm font-medium text-gray-700">Validità link PDF assegnazione (mesi)</label>
|
||||
<p class="text-xs text-gray-500 mb-1">Durata del link temporaneo condivisibile per il PDF dell'assegnazione attiva.</p>
|
||||
<input wire:model="assignment_link_ttl_months" type="number" min="1" max="24" id="assignment_link_ttl_months" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
@error('assignment_link_ttl_months') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
|
||||
<input wire:model="pdf_viewer_show_download" type="checkbox" value="1" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden max-w-lg">
|
||||
<ul class="divide-y divide-gray-200">
|
||||
@forelse($tipologie as $tipo)
|
||||
<li class="px-4 py-3 flex items-center justify-between hover:bg-gray-50">
|
||||
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-x-2 gap-y-1 hover:bg-gray-50">
|
||||
@if($editingId === $tipo->id)
|
||||
<form wire:submit="saveEdit" class="flex items-center gap-2 flex-1">
|
||||
<input wire:model="editingNome" type="text" class="flex-1 rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
@@ -34,7 +34,7 @@
|
||||
@endif
|
||||
<span class="text-xs text-gray-400">{{ $tipo->territori()->count() }} territori</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button wire:click="startEdit({{ $tipo->id }})" class="text-yellow-600 hover:text-yellow-800 text-xs">Rinomina</button>
|
||||
<button wire:click="toggleActive({{ $tipo->id }})" class="text-xs {{ $tipo->attivo ? 'text-gray-500' : 'text-green-600' }}">{{ $tipo->attivo ? 'Disattiva' : 'Attiva' }}</button>
|
||||
<button wire:click="deleteTipologia({{ $tipo->id }})" wire:confirm="Eliminare la tipologia '{{ $tipo->nome }}'?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Utenti esistenti</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-medium text-gray-600">Nome</th>
|
||||
@@ -111,9 +111,9 @@
|
||||
</div>
|
||||
</td>
|
||||
@else
|
||||
<td class="px-4 py-2 text-gray-900">{{ $user->name }}</td>
|
||||
<td class="px-4 py-2 text-gray-700">{{ $user->email }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<td data-label="Nome" class="px-4 py-2 text-gray-900">{{ $user->name }}</td>
|
||||
<td data-label="Email" class="px-4 py-2 text-gray-700">{{ $user->email }}</td>
|
||||
<td data-label="Ruoli" class="px-4 py-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@forelse($user->roles as $role)
|
||||
<span class="inline-flex items-center rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">{{ $role->name }}</span>
|
||||
@@ -122,7 +122,7 @@
|
||||
@endforelse
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<td class="px-4 py-2 cell-actions">
|
||||
<div class="flex gap-2">
|
||||
<button wire:click="startEdit({{ $user->id }})" type="button" class="px-2 py-1 text-xs font-medium text-indigo-700 bg-indigo-50 rounded hover:bg-indigo-100">Modifica</button>
|
||||
<button
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 text-sm font-medium text-gray-800">Riepilogo file non associati o problematici</div>
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">File</th>
|
||||
@@ -228,9 +228,9 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($pdfImportIssues as $issue)
|
||||
<tr>
|
||||
<td class="px-3 py-2 text-gray-900">{{ $issue['file'] ?? '-' }}</td>
|
||||
<td class="px-3 py-2 text-gray-600">{{ $issue['message'] ?? '-' }}</td>
|
||||
<td class="px-3 py-2 text-gray-600">{{ !empty($issue['matched_numbers']) ? implode(', ', $issue['matched_numbers']) : '-' }}</td>
|
||||
<td data-label="File" class="px-3 py-2 text-gray-900">{{ $issue['file'] ?? '-' }}</td>
|
||||
<td data-label="Motivo" class="px-3 py-2 text-gray-600">{{ $issue['message'] ?? '-' }}</td>
|
||||
<td data-label="Territori rilevati" class="px-3 py-2 text-gray-600">{{ !empty($issue['matched_numbers']) ? implode(', ', $issue['matched_numbers']) : '-' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
@@ -292,6 +292,7 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Log importazione</h2>
|
||||
<div class="text-sm text-gray-700" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;">
|
||||
<div>Utenti importati: <strong>{{ $importStats['utenti_importati'] ?? 0 }}</strong></div>
|
||||
<div>Zone importate: <strong>{{ $importStats['zone_importate'] ?? 0 }}</strong></div>
|
||||
<div>Tipologie importate: <strong>{{ $importStats['tipologie_importate'] ?? 0 }}</strong></div>
|
||||
<div>Proclamatori importati: <strong>{{ $importStats['proclamatori_importati'] ?? 0 }}</strong></div>
|
||||
@@ -316,7 +317,7 @@
|
||||
|
||||
@if(!empty($importIssues))
|
||||
<div class="mt-2" style="max-height:260px;overflow:auto;border:1px solid #e5e7eb;border-radius:10px;">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;">
|
||||
<table class="table-cards" style="width:100%;border-collapse:collapse;font-size:13px;">
|
||||
<thead style="background:#f9fafb;position:sticky;top:0;">
|
||||
<tr>
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e5e7eb;">Entità</th>
|
||||
@@ -327,9 +328,9 @@
|
||||
<tbody>
|
||||
@foreach($importIssues as $issue)
|
||||
<tr>
|
||||
<td style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['entity'] }}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['legacy_id'] }}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['reason'] }}</td>
|
||||
<td data-label="Entità" style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['entity'] }}</td>
|
||||
<td data-label="Legacy ID" style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['legacy_id'] }}</td>
|
||||
<td data-label="Motivo" style="padding:8px;border-bottom:1px solid #f3f4f6;">{{ $issue['reason'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
@@ -341,7 +342,12 @@
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Export XML</h2>
|
||||
<p class="text-xs text-gray-500">Esporta i dati correnti dell'app in XML.</p>
|
||||
<p class="text-xs text-gray-500">Esporta <strong>tutti</strong> i dati correnti dell'app in XML (backup completo, reimportabile).</p>
|
||||
|
||||
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
|
||||
<strong>Dati sensibili.</strong> Il file include gli utenti con le password (hash) e i nomi dei proclamatori in chiaro.
|
||||
Conservalo in un luogo sicuro e non condividerlo: chi lo ottiene può tentare di forzare le password offline.
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden max-w-lg">
|
||||
<ul class="divide-y divide-gray-200">
|
||||
@forelse($zone as $zona)
|
||||
<li class="px-4 py-3 flex items-center justify-between hover:bg-gray-50">
|
||||
<li class="px-4 py-3 flex flex-wrap items-center justify-between gap-x-2 gap-y-1 hover:bg-gray-50">
|
||||
@if($editingId === $zona->id)
|
||||
<form wire:submit="saveEdit" class="flex items-center gap-2 flex-1">
|
||||
<input wire:model="editingNome" type="text" class="flex-1 rounded-lg border-gray-300 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
@@ -34,7 +34,7 @@
|
||||
@endif
|
||||
<span class="text-xs text-gray-400">{{ $zona->territori()->count() }} territori</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button wire:click="startEdit({{ $zona->id }})" class="text-yellow-600 hover:text-yellow-800 text-xs">Rinomina</button>
|
||||
<button wire:click="toggleActive({{ $zona->id }})" class="text-xs {{ $zona->attivo ? 'text-gray-500' : 'text-green-600' }}">{{ $zona->attivo ? 'Disattiva' : 'Attiva' }}</button>
|
||||
<button wire:click="deleteZona({{ $zona->id }})" wire:confirm="Eliminare la zona '{{ $zona->nome }}'?" class="text-red-500 hover:text-red-700 text-xs">Elimina</button>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped table-cards">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">N°</th>
|
||||
@@ -19,10 +20,10 @@
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@forelse($territori as $territorio)
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-semibold text-gray-900">{{ $territorio->numero }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->deleted_at->format('d/m/Y H:i') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<td data-label="N°" class="px-4 py-3 text-sm font-semibold text-gray-900">{{ $territorio->numero }}</td>
|
||||
<td data-label="Eliminato il" class="px-4 py-3 text-sm text-gray-600">{{ $territorio->deleted_at->format('d/m/Y H:i') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right cell-actions">
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<button wire:click="restore({{ $territorio->id }})" class="btn-action btn-action-green">Ripristina</button>
|
||||
<button wire:click="forceDelete({{ $territorio->id }})" wire:confirm="Eliminare DEFINITIVAMENTE il territorio {{ $territorio->numero }}? Questa azione è irreversibile." class="btn-action btn-action-red">Elimina</button>
|
||||
</div>
|
||||
@@ -35,6 +36,7 @@
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="px-4 py-3 border-t border-gray-200">
|
||||
{{ $territori->links() }}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<h1 class="text-2xl font-bold text-gray-900">Territori</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Gestione dei territori della congregazione</p>
|
||||
</div>
|
||||
@can('territori.manage')
|
||||
<div class="mt-4 sm:mt-0 flex gap-2">
|
||||
<a href="{{ route('territori.cestino') }}" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition">
|
||||
<svg class="h-4 w-4" style="color:#9ca3af" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
@@ -14,6 +15,7 @@
|
||||
Nuovo territorio
|
||||
</a>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
{{-- Filters --}}
|
||||
@@ -87,7 +89,7 @@
|
||||
{{-- Table --}}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-striped table-cards">
|
||||
<thead style="background:linear-gradient(180deg,#f9fafb,#f3f4f6)">
|
||||
<tr>
|
||||
<th wire:click="sortBy('numero')" class="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase cursor-pointer hover:text-gray-800 transition-colors">
|
||||
@@ -103,7 +105,7 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse($territori as $territorio)
|
||||
<tr class="hover:bg-indigo-50/30 transition-colors">
|
||||
<td class="px-4 py-3 text-sm font-semibold text-gray-900">
|
||||
<td data-label="N°" class="px-4 py-3 text-sm font-semibold text-gray-900">
|
||||
<div class="flex items-center gap-2">
|
||||
@if($territorio->thumbnail_path)
|
||||
<img src="{{ asset('storage/' . $territorio->thumbnail_path) }}"
|
||||
@@ -113,9 +115,9 @@
|
||||
<a href="{{ route('territori.show', $territorio) }}" class="hover:text-indigo-600 transition-colors">{{ $territorio->numero }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->zona?->nome ?? '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ $territorio->tipologia?->nome ?? '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<td data-label="Zona" class="px-4 py-3 text-sm text-gray-600">{{ $territorio->zona?->nome ?? '-' }}</td>
|
||||
<td data-label="Tipologia" class="px-4 py-3 text-sm text-gray-600">{{ $territorio->tipologia?->nome ?? '-' }}</td>
|
||||
<td data-label="Stato" class="px-4 py-3 text-sm">
|
||||
@php $stato = $territorio->stato; @endphp
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{{ match($stato) {
|
||||
@@ -133,10 +135,10 @@
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
<td data-label="Assegnatario" class="px-4 py-3 text-sm text-gray-600">
|
||||
{{ $territorio->assegnatario?->nome_completo ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<td class="px-4 py-3 text-sm cell-actions">
|
||||
<div class="flex items-center justify-end gap-1 flex-wrap">
|
||||
{{-- Primary actions: Assegna / Rientra --}}
|
||||
@can('territori.assign')
|
||||
@@ -159,6 +161,7 @@
|
||||
<a href="{{ route('territori.show', $territorio) }}" class="btn-action btn-action-indigo" title="Dettaglio">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
</a>
|
||||
@can('territori.manage')
|
||||
<a href="{{ route('territori.edit', $territorio) }}" class="btn-action btn-action-gray" title="Modifica">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</a>
|
||||
@@ -172,6 +175,7 @@
|
||||
<button wire:click="deleteTerritorio({{ $territorio->id }})" wire:confirm="Spostare il territorio {{ $territorio->numero }} nel cestino?" class="btn-action btn-action-red" title="Elimina">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
</a>
|
||||
@endif
|
||||
@endcan
|
||||
@can('territori.manage')
|
||||
<a href="{{ route('territori.edit', $territorio) }}" class="btn-action btn-action-indigo" style="padding:8px 16px;font-size:14px;border-radius:8px">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Modifica
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +106,7 @@
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-indigo-900">Link PDF temporaneo</div>
|
||||
<div class="mt-1 text-xs text-indigo-700">Valido per {{ $assignmentLinkTtlMonths }} {{ $assignmentLinkTtlMonths === 1 ? 'mese' : 'mesi' }} o fino al rientro del territorio.</div>
|
||||
<div class="mt-1 text-xs text-indigo-700">Valido fino al {{ optional($assignmentScadenza)->format('d/m/Y') ?? '—' }} (soglia rientro) o fino al rientro del territorio.</div>
|
||||
</div>
|
||||
<a href="{{ $temporaryPdfUrl }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center rounded-lg border border-indigo-200 bg-white px-3 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-100 transition">Apri viewer</a>
|
||||
</div>
|
||||
@@ -149,7 +151,7 @@
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-semibold text-indigo-600 mb-2">Anno Teocratico {{ $annoLabel }}</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm table-cards">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Proclamatore</th>
|
||||
@@ -162,17 +164,17 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@foreach($assegnazioni as $assegnazione)
|
||||
<tr>
|
||||
<td class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
||||
<td class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<td data-label="Proclamatore" class="px-3 py-2">{{ $assegnazione->proclamatore?->nome_completo ?? 'N/A' }}</td>
|
||||
<td data-label="Assegnato" class="px-3 py-2">{{ $assegnazione->assigned_at->format('d/m/Y') }}</td>
|
||||
<td data-label="Rientrato" class="px-3 py-2">
|
||||
@if($assegnazione->returned_at)
|
||||
{{ $assegnazione->returned_at->format('d/m/Y') }}
|
||||
@else
|
||||
<span class="text-amber-600 font-medium">In corso</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2">{{ $assegnazione->giorni }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<td data-label="Giorni" class="px-3 py-2">{{ $assegnazione->giorni }}</td>
|
||||
<td data-label="Campagna" class="px-3 py-2">
|
||||
@if($assegnazione->counted_in_campaign)
|
||||
<span class="text-green-600">Sì — {{ $assegnazione->campagna?->descrizione }}</span>
|
||||
@elseif($assegnazione->counted_in_campaign === false)
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<div class="meta">Generato il {{ $generatedAt }}</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat"><div class="stat-label">Utenti importati</div><div class="stat-value">{{ $stats['utenti_importati'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Zone importate</div><div class="stat-value">{{ $stats['zone_importate'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Tipologie importate</div><div class="stat-value">{{ $stats['tipologie_importate'] ?? 0 }}</div></div>
|
||||
<div class="stat"><div class="stat-label">Proclamatori importati</div><div class="stat-value">{{ $stats['proclamatori_importati'] ?? 0 }}</div></div>
|
||||
|
||||
@@ -31,6 +31,7 @@ use App\Livewire\Settings\TipologieIndex;
|
||||
use App\Livewire\Settings\UsersIndex;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Livewire\Privacy;
|
||||
use App\Livewire\Profile;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -66,21 +67,44 @@ Route::middleware('auth')->group(function () {
|
||||
// Home Dashboard
|
||||
Route::get('/', Home::class)->name('dashboard');
|
||||
|
||||
// Territori
|
||||
Route::middleware('permission:territori.manage')->group(function () {
|
||||
// Profilo personale (ogni utente loggato)
|
||||
Route::get('profilo', Profile::class)->name('profile');
|
||||
|
||||
// Territori — visualizzazione (lista + dettaglio) per tutti gli utenti
|
||||
// operativi, cioè chi può assegnare i territori.
|
||||
Route::middleware('permission:territori.assign')->group(function () {
|
||||
Route::get('territori', TerritorioIndex::class)->name('territori.index');
|
||||
});
|
||||
// Gestione territori (crea/modifica/cestino): solo amministratore
|
||||
// (territori.manage). Le rotte LETTERALI (create/cestino) devono essere
|
||||
// registrate PRIMA del wildcard {territorio}, altrimenti "create"/"cestino"
|
||||
// verrebbero catturate dalla rotta di dettaglio.
|
||||
Route::middleware('permission:territori.manage')->group(function () {
|
||||
Route::get('territori/create', TerritorioCreate::class)->name('territori.create');
|
||||
Route::get('territori/cestino', TerritorioCestino::class)->name('territori.cestino');
|
||||
});
|
||||
Route::middleware('permission:territori.assign')->group(function () {
|
||||
Route::get('territori/{territorio}', TerritorioShow::class)->name('territori.show');
|
||||
});
|
||||
Route::middleware('permission:territori.manage')->group(function () {
|
||||
Route::get('territori/{territorio}/edit', TerritorioEdit::class)->name('territori.edit');
|
||||
});
|
||||
|
||||
// Proclamatori
|
||||
// Proclamatori — visualizzazione (lista + dettaglio) per chi ha
|
||||
// proclamatori.manage; sola lettura per i non-admin.
|
||||
Route::middleware('permission:proclamatori.manage')->group(function () {
|
||||
Route::get('proclamatori', ProclamatoreIndex::class)->name('proclamatori.index');
|
||||
});
|
||||
// Gestione proclamatori (crea/modifica/cestino): solo amministratore. Le
|
||||
// rotte letterali (create/cestino) devono precedere il wildcard {proclamatore}.
|
||||
Route::middleware('permission:settings.manage')->group(function () {
|
||||
Route::get('proclamatori/create', ProclamatoreCreate::class)->name('proclamatori.create');
|
||||
Route::get('proclamatori/cestino', ProclamatoreCestino::class)->name('proclamatori.cestino');
|
||||
});
|
||||
Route::middleware('permission:proclamatori.manage')->group(function () {
|
||||
Route::get('proclamatori/{proclamatore}', ProclamatoreShow::class)->name('proclamatori.show');
|
||||
});
|
||||
Route::middleware('permission:settings.manage')->group(function () {
|
||||
Route::get('proclamatori/{proclamatore}/edit', ProclamatoreEdit::class)->name('proclamatori.edit');
|
||||
});
|
||||
|
||||
@@ -92,11 +116,22 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('assegnazioni/{assegnazione}/rientra', Rientra::class)->name('assegnazioni.rientra');
|
||||
});
|
||||
|
||||
// Campagne
|
||||
// Campagne — visualizzazione per chi ha campagne.manage; sola lettura per i
|
||||
// non-admin.
|
||||
Route::middleware('permission:campagne.manage')->group(function () {
|
||||
Route::get('campagne', CampagnaIndex::class)->name('campagne.index');
|
||||
});
|
||||
// Creazione campagne: solo amministratore. La rotta letterale create deve
|
||||
// precedere il wildcard {campagna}.
|
||||
Route::middleware('permission:settings.manage')->group(function () {
|
||||
Route::get('campagne/create', CampagnaCreate::class)->name('campagne.create');
|
||||
});
|
||||
Route::middleware('permission:campagne.manage')->group(function () {
|
||||
Route::get('campagne/{campagna}', CampagnaShow::class)->name('campagne.show');
|
||||
});
|
||||
// Modifica campagne: solo amministratore (settings.manage). L'eliminazione
|
||||
// vive in CampagnaIndex::deleteCampagna(), protetta lì con lo stesso gate.
|
||||
Route::middleware('permission:settings.manage')->group(function () {
|
||||
Route::get('campagne/{campagna}/edit', CampagnaEdit::class)->name('campagne.edit');
|
||||
});
|
||||
|
||||
@@ -122,6 +157,8 @@ Route::middleware('auth')->group(function () {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
Reference in New Issue
Block a user