++ fix temporary PDF viewer URLs, which were causing issues with caching and expiring links. Instead, we now generate short-lived URLs that redirect to the PDF viewer route, ensuring that users can access the PDFs without running into expired links. This change affects the Assegnazione model, the ShortPdfLinkController, and the relevant Blade views for assignments and records. Additionally, I've updated the Home Livewire component to calculate and display the average duration of assignments in months, providing more insight into assignment durations on the dashboard.

This commit is contained in:
2026-04-13 15:05:37 +00:00
parent 465e7cf092
commit 0553d4ef74
23 changed files with 781 additions and 212 deletions

View File

@@ -3,25 +3,21 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use Carbon\Carbon;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
class AssignmentPdfController extends Controller class AssignmentPdfController extends Controller
{ {
public function viewer(Request $request, Assegnazione $assignment, string $code): View public function viewer(Request $request, Assegnazione $assignment, string $code): View
{ {
$this->validateAccess($request, $assignment, $code); $this->validateAccess($assignment, $code);
$expiresAt = Carbon::createFromTimestamp((int) $request->query('expires')); $pdfUrl = route('assignments.pdf.file', [
$pdfUrl = URL::temporarySignedRoute( 'assignment' => $assignment->id,
'assignments.pdf.file', 'code' => $code,
$expiresAt, ]);
['assignment' => $assignment->id, 'code' => $code]
);
return view('assignments.pdf-viewer', [ return view('assignments.pdf-viewer', [
'assignment' => $assignment, 'assignment' => $assignment,
@@ -31,7 +27,7 @@ class AssignmentPdfController extends Controller
public function file(Request $request, Assegnazione $assignment, string $code): StreamedResponse public function file(Request $request, Assegnazione $assignment, string $code): StreamedResponse
{ {
$this->validateAccess($request, $assignment, $code); $this->validateAccess($assignment, $code);
$pdfPath = $assignment->territorio?->pdf_path; $pdfPath = $assignment->territorio?->pdf_path;
abort_unless($pdfPath && Storage::disk('public')->exists($pdfPath), 404); abort_unless($pdfPath && Storage::disk('public')->exists($pdfPath), 404);
@@ -46,9 +42,8 @@ class AssignmentPdfController extends Controller
); );
} }
protected function validateAccess(Request $request, Assegnazione $assignment, string $code): void protected function validateAccess(Assegnazione $assignment, string $code): void
{ {
abort_unless($request->hasValidSignature(), 403);
abort_unless($assignment->pdf_access_code && hash_equals($assignment->pdf_access_code, $code), 404); abort_unless($assignment->pdf_access_code && hash_equals($assignment->pdf_access_code, $code), 404);
abort_unless($assignment->is_aperta, 403); abort_unless($assignment->is_aperta, 403);
abort_unless($assignment->territorio?->pdf_path, 404); abort_unless($assignment->territorio?->pdf_path, 404);

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Livewire\Settings\XmlExchange;
use Illuminate\Http\Request;
class XmlExchangeUploadController extends Controller
{
public function convertSqlToXml(Request $request)
{
$request->validate([
'sqlDump' => ['required', 'file', 'max:256000'],
]);
$file = $request->file('sqlDump');
$ext = strtolower($file->getClientOriginalExtension());
if (! in_array($ext, ['sql', 'txt'])) {
return back()->withErrors(['sqlDump' => 'Il file deve essere .sql o .txt']);
}
$content = file_get_contents($file->getRealPath());
if (! $content) {
return back()->withErrors(['sqlDump' => 'File vuoto o non leggibile.']);
}
$exchange = app(XmlExchange::class);
$dataset = $exchange->legacySqlToDatasetPublic($content);
$xml = $exchange->datasetToXmlPublic($dataset, 'legacy-sql-conversion');
return response()->streamDownload(function () use ($xml) {
echo $xml;
}, 'termanager-conversion.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
}
public function importXml(Request $request)
{
$request->validate([
'xmlImport' => ['required', 'file', 'max:256000'],
]);
$file = $request->file('xmlImport');
$ext = strtolower($file->getClientOriginalExtension());
if (! in_array($ext, ['xml', 'txt'])) {
return back()->withErrors(['xmlImport' => 'Il file deve essere .xml o .txt']);
}
$content = file_get_contents($file->getRealPath());
if (! $content) {
return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']);
}
$exchange = new XmlExchange();
$result = $exchange->importXmlFromContent($content);
if (isset($result['error'])) {
return back()->withErrors(['xmlImport' => $result['error']]);
}
$stats = $result['stats'];
$issues = $result['issues'];
$message = 'Import XML completato con successo.';
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
}
return redirect()->route('xml.exchange')
->with('success', $message)
->with('importStats', $stats)
->with('importIssues', $issues);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers;
use App\Models\Assegnazione;
use Illuminate\Http\RedirectResponse;
class ShortPdfLinkController extends Controller
{
public function __invoke(string $code): RedirectResponse
{
$assignment = Assegnazione::where('pdf_access_code', $code)->firstOrFail();
abort_unless($assignment->is_aperta, 403);
abort_unless($assignment->territorio?->pdf_path, 404);
return redirect()->route('assignments.pdf.viewer', [
'assignment' => $assignment->id,
'code' => $code,
]);
}
}

View File

@@ -108,12 +108,24 @@ class Home extends Component
->count('territorio_id'); ->count('territorio_id');
} }
// Monthly average // Monthly average (territories/month this year)
$mediaPercorrenzaMensile = 0; $mediaPercorrenzaMensile = 0;
if ($annoCorrente && $annoCorrente->mesi_trascorsi > 0) { if ($annoCorrente && $annoCorrente->mesi_trascorsi > 0) {
$mediaPercorrenzaMensile = round($territoriPercorsi / $annoCorrente->mesi_trascorsi, 1); $mediaPercorrenzaMensile = round($territoriPercorsi / $annoCorrente->mesi_trascorsi, 1);
} }
// Average assignment duration in months (current year only, matches old app "Media Percorrenza Congregazione")
$avgGiorni = null;
if ($annoCorrente) {
$avgGiorni = Assegnazione::where('anno_teocratico_id', $annoCorrente->id)
->whereNotNull('returned_at')
->whereRaw('YEAR(assigned_at) >= 1900')
->whereRaw('DATEDIFF(returned_at, assigned_at) > 0')
->selectRaw('AVG(DATEDIFF(returned_at, assigned_at)) as media_giorni')
->value('media_giorni');
}
$mediaDurataPercorrenzaMesi = $avgGiorni ? round($avgGiorni / 30.44, 1) : 0;
// Campaign stats // Campaign stats
$campagnaStats = null; $campagnaStats = null;
if ($campagnaAttiva) { if ($campagnaAttiva) {
@@ -178,6 +190,7 @@ class Home extends Component
'totInReparto' => $totInReparto, 'totInReparto' => $totInReparto,
'territoriPercorsi' => $territoriPercorsi, 'territoriPercorsi' => $territoriPercorsi,
'mediaPercorrenzaMensile' => $mediaPercorrenzaMensile, 'mediaPercorrenzaMensile' => $mediaPercorrenzaMensile,
'mediaDurataPercorrenzaMesi' => $mediaDurataPercorrenzaMesi,
'campagnaStats' => $campagnaStats, 'campagnaStats' => $campagnaStats,
'homeLimit' => $homeLimit, 'homeLimit' => $homeLimit,
'territoriDaAssegnare' => $territoriDaAssegnare, 'territoriDaAssegnare' => $territoriDaAssegnare,

View File

@@ -25,8 +25,6 @@ class XmlExchange extends Component
{ {
use WithFileUploads; use WithFileUploads;
public $sqlDump;
public $xmlImport;
public array $importStats = []; public array $importStats = [];
public array $importIssues = []; public array $importIssues = [];
public array $pdfFolder = []; public array $pdfFolder = [];
@@ -46,46 +44,135 @@ class XmlExchange extends Component
} }
} }
public function convertLegacySqlToXml() public function importTerritoryPdfFolder(): void
{ {
$this->validate([ $this->validate([
'sqlDump' => ['required', 'file', 'mimes:sql,txt'], 'pdfFolder' => ['required', 'array', 'min:1'],
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
]); ]);
$content = file_get_contents($this->sqlDump->getRealPath()); $importId = (string) Str::uuid();
$dataset = $this->legacySqlToDataset($content ?: ''); $storedFiles = [];
$xml = $this->datasetToXml($dataset, 'legacy-sql-conversion');
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
'local'
);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
}
$this->pdfFolder = [];
}
public function refreshPdfImportStatus(): void
{
if (! $this->currentPdfImportId) {
return;
}
$state = app(TerritorioPdfImportState::class)->get($this->currentPdfImportId);
if (! $state) {
return;
}
$this->pdfImportStatus = $state['status'] ?? 'idle';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->pdfImportLogText = implode(PHP_EOL, $this->pdfImportLogs);
}
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
{
$state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
$this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->refreshPdfImportStatus();
session()->flash('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
}
public function downloadImportLogPdf()
{
if (empty($this->importStats)) {
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
return;
}
$stats = $this->importStats;
$issues = $this->importIssues;
$generatedAt = now()->format('d/m/Y H:i:s');
$html = view('pdf.import-log', compact('stats', 'issues', 'generatedAt'))->render();
return response()->streamDownload(function () use ($html) {
echo Pdf::loadHTML($html)
->setPaper('a4', 'portrait')
->output();
}, 'import-log-' . now()->format('Ymd-His') . '.pdf', ['Content-Type' => 'application/pdf']);
}
public function exportCurrentAsXml()
{
$dataset = $this->currentDataset();
$xml = $this->datasetToXml($dataset, 'current-app-export');
return response()->streamDownload(function () use ($xml) { return response()->streamDownload(function () use ($xml) {
echo $xml; echo $xml;
}, 'termanager-conversion.xml', ['Content-Type' => 'application/xml; charset=UTF-8']); }, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
} }
public function importXmlIntoApp(): void public function render()
{
if (session()->has('importStats')) {
$this->importStats = session('importStats');
}
if (session()->has('importIssues')) {
$this->importIssues = session('importIssues');
}
return view('livewire.settings.xml-exchange');
}
public function legacySqlToDatasetPublic(string $sql): array
{
return $this->legacySqlToDataset($sql);
}
public function datasetToXmlPublic(array $dataset, string $source): string
{
return $this->datasetToXml($dataset, $source);
}
public function importXmlFromContent(string $content): array
{ {
$this->importStats = []; $this->importStats = [];
$this->importIssues = []; $this->importIssues = [];
$this->validate([
'xmlImport' => ['required', 'file', 'mimes:xml,txt'],
]);
$content = file_get_contents($this->xmlImport->getRealPath());
if (! $content) {
$this->addError('xmlImport', 'File XML non valido.');
return;
}
$xml = @simplexml_load_string($content); $xml = @simplexml_load_string($content);
if (! $xml) { if (! $xml) {
$this->addError('xmlImport', 'Impossibile leggere il file XML.'); return ['error' => 'Impossibile leggere il file XML.'];
return;
} }
$actorId = auth()->id() ?? User::query()->value('id'); $actorId = auth()->id() ?? User::query()->value('id');
if (! $actorId) { if (! $actorId) {
$this->addError('xmlImport', 'Serve almeno un utente nel sistema per importare i dati.'); return ['error' => 'Serve almeno un utente nel sistema per importare i dati.'];
return;
} }
$stats = [ $stats = [
@@ -268,115 +355,12 @@ class XmlExchange extends Component
} }
}); });
$this->importStats = $stats; return [
'stats' => $stats,
$message = 'Import XML completato con successo.'; 'issues' => $this->importIssues,
if ($stats['duplicate_territori'] > 0 || $stats['assegnazioni_saltate'] > 0) {
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
}
session()->flash('success', $message);
}
public function importTerritoryPdfFolder(): void
{
$this->validate([
'pdfFolder' => ['required', 'array', 'min:1'],
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
]);
$importId = (string) Str::uuid();
$storedFiles = [];
foreach ($this->pdfFolder as $index => $file) {
$originalName = $file->getClientOriginalName();
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
$storedPath = $file->storeAs(
'bulk-territori-imports/' . $importId,
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
'local'
);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
]; ];
} }
$this->pdfFolder = [];
}
public function refreshPdfImportStatus(): void
{
if (! $this->currentPdfImportId) {
return;
}
$state = app(TerritorioPdfImportState::class)->get($this->currentPdfImportId);
if (! $state) {
return;
}
$this->pdfImportStatus = $state['status'] ?? 'idle';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->pdfImportLogText = implode(PHP_EOL, $this->pdfImportLogs);
}
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
{
$state = app(TerritorioPdfImportDispatcher::class)
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
$this->currentPdfImportId = $importId;
$this->pdfImportStatus = $state['status'] ?? 'queued';
$this->pdfImportStats = $state['stats'] ?? [];
$this->pdfImportLogs = $state['logs'] ?? [];
$this->pdfImportIssues = $state['issues'] ?? [];
$this->refreshPdfImportStatus();
session()->flash('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
}
public function downloadImportLogPdf()
{
if (empty($this->importStats)) {
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
return;
}
$stats = $this->importStats;
$issues = $this->importIssues;
$generatedAt = now()->format('d/m/Y H:i:s');
$html = view('pdf.import-log', compact('stats', 'issues', 'generatedAt'))->render();
return response()->streamDownload(function () use ($html) {
echo Pdf::loadHTML($html)
->setPaper('a4', 'portrait')
->output();
}, 'import-log-' . now()->format('Ymd-His') . '.pdf', ['Content-Type' => 'application/pdf']);
}
public function exportCurrentAsXml()
{
$dataset = $this->currentDataset();
$xml = $this->datasetToXml($dataset, 'current-app-export');
return response()->streamDownload(function () use ($xml) {
echo $xml;
}, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
}
public function render()
{
return view('livewire.settings.xml-exchange');
}
private function currentDataset(): array private function currentDataset(): array
{ {
$settings = Setting::instance(); $settings = Setting::instance();
@@ -512,7 +496,7 @@ class XmlExchange extends Component
private function extractInsertRows(string $sql): array private function extractInsertRows(string $sql): array
{ {
$result = []; $result = [];
preg_match_all('/INSERT INTO `([^`]+)` VALUES\s*(.+?);/s', $sql, $matches, PREG_SET_ORDER); preg_match_all('/INSERT INTO `([^`]+)` VALUES\s*(.+);/m', $sql, $matches, PREG_SET_ORDER);
foreach ($matches as $match) { foreach ($matches as $match) {
$table = $match[1]; $table = $match[1];
@@ -545,7 +529,7 @@ class XmlExchange extends Component
$escape = false; $escape = false;
continue; continue;
} }
if ($ch === '\\\\') { if ($ch === '\\') {
$escape = true; $escape = true;
continue; continue;
} }
@@ -603,7 +587,7 @@ class XmlExchange extends Component
$escape = false; $escape = false;
continue; continue;
} }
if ($ch === '\\\\') { if ($ch === '\\') {
$escape = true; $escape = true;
continue; continue;
} }
@@ -645,7 +629,8 @@ class XmlExchange extends Component
if (str_starts_with($raw, "'") && str_ends_with($raw, "'")) { if (str_starts_with($raw, "'") && str_ends_with($raw, "'")) {
$v = substr($raw, 1, -1); $v = substr($raw, 1, -1);
$v = str_replace(["\\\\", "\\'", '\\"', '\\n', '\\r', '\\t'], ['\\', "'", '"', "\n", "\r", "\t"], $v); $v = str_replace(["\\\\", "\\'", '\\"', '\\n', '\\r', '\\t'], ['\\', "'", '"', "\n", "\r", "\t"], $v);
return html_entity_decode($v, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $v = html_entity_decode($v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $this->normalizeUnicodeQuotes($v);
} }
if (is_numeric($raw)) { if (is_numeric($raw)) {
@@ -655,6 +640,15 @@ class XmlExchange extends Component
return $raw; return $raw;
} }
private function normalizeUnicodeQuotes(string $value): string
{
return str_replace(
["\u{2018}", "\u{2019}", "\u{2032}", "\u{2035}", "\u{201C}", "\u{201D}", "\u{201E}", "\u{2033}", "\u{2036}"],
["'", "'", "'", "'", '"', '"', '"', '"', '"'],
$value
);
}
private function datesFromLegacyAnnoLabel(string $label): array private function datesFromLegacyAnnoLabel(string $label): array
{ {
if (preg_match('/(\d{4})\s*-\s*(\d{4})/', $label, $m)) { if (preg_match('/(\d{4})\s*-\s*(\d{4})/', $label, $m)) {
@@ -709,7 +703,7 @@ class XmlExchange extends Component
$settings = $xml->addChild('settings'); $settings = $xml->addChild('settings');
foreach ($dataset['settings'] as $key => $value) { foreach ($dataset['settings'] as $key => $value) {
$settings->addChild($key, htmlspecialchars((string) $value, ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($settings, $key, (string) $value);
} }
$zonesNode = $xml->addChild('zones'); $zonesNode = $xml->addChild('zones');
@@ -718,7 +712,7 @@ class XmlExchange extends Component
if (isset($zone['legacy_id'])) { if (isset($zone['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $zone['legacy_id']); $node->addAttribute('legacy_id', (string) $zone['legacy_id']);
} }
$node->addChild('nome', htmlspecialchars((string) ($zone['nome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($node, 'nome', (string) ($zone['nome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($zone['attivo'] ?? 1)));
} }
@@ -728,7 +722,7 @@ class XmlExchange extends Component
if (isset($tipologia['legacy_id'])) { if (isset($tipologia['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $tipologia['legacy_id']); $node->addAttribute('legacy_id', (string) $tipologia['legacy_id']);
} }
$node->addChild('nome', htmlspecialchars((string) ($tipologia['nome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($node, 'nome', (string) ($tipologia['nome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($tipologia['attivo'] ?? 1)));
} }
@@ -738,8 +732,8 @@ class XmlExchange extends Component
if (isset($proclamatore['legacy_id'])) { if (isset($proclamatore['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $proclamatore['legacy_id']); $node->addAttribute('legacy_id', (string) $proclamatore['legacy_id']);
} }
$node->addChild('nome', htmlspecialchars((string) ($proclamatore['nome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($node, 'nome', (string) ($proclamatore['nome'] ?? ''));
$node->addChild('cognome', htmlspecialchars((string) ($proclamatore['cognome'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($node, 'cognome', (string) ($proclamatore['cognome'] ?? ''));
$node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($proclamatore['attivo'] ?? 1)));
} }
@@ -749,11 +743,11 @@ class XmlExchange extends Component
if (isset($territorio['legacy_id'])) { if (isset($territorio['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $territorio['legacy_id']); $node->addAttribute('legacy_id', (string) $territorio['legacy_id']);
} }
$node->addChild('numero', htmlspecialchars((string) ($territorio['numero'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($node, 'numero', (string) ($territorio['numero'] ?? ''));
$node->addChild('legacy_zona_id', (string) ($territorio['legacy_zona_id'] ?? ($territorio['zona_id'] ?? ''))); $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'] ?? ''))); $node->addChild('legacy_tipologia_id', (string) ($territorio['legacy_tipologia_id'] ?? ($territorio['tipologia_id'] ?? '')));
$node->addChild('confini', htmlspecialchars((string) ($territorio['confini'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($node, 'confini', (string) ($territorio['confini'] ?? ''));
$node->addChild('note', htmlspecialchars((string) ($territorio['note'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($node, 'note', (string) ($territorio['note'] ?? ''));
$node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1))); $node->addChild('attivo', (string) ((int) ($territorio['attivo'] ?? 1)));
$node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0))); $node->addChild('prioritario', (string) ((int) ($territorio['prioritario'] ?? 0)));
} }
@@ -764,7 +758,7 @@ class XmlExchange extends Component
if (isset($anno['legacy_id'])) { if (isset($anno['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $anno['legacy_id']); $node->addAttribute('legacy_id', (string) $anno['legacy_id']);
} }
$node->addChild('label', htmlspecialchars((string) ($anno['label'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($node, 'label', (string) ($anno['label'] ?? ''));
$node->addChild('start_date', (string) ($anno['start_date'] ?? '')); $node->addChild('start_date', (string) ($anno['start_date'] ?? ''));
$node->addChild('end_date', (string) ($anno['end_date'] ?? '')); $node->addChild('end_date', (string) ($anno['end_date'] ?? ''));
} }
@@ -775,7 +769,7 @@ class XmlExchange extends Component
if (isset($campagna['legacy_id'])) { if (isset($campagna['legacy_id'])) {
$node->addAttribute('legacy_id', (string) $campagna['legacy_id']); $node->addAttribute('legacy_id', (string) $campagna['legacy_id']);
} }
$node->addChild('descrizione', htmlspecialchars((string) ($campagna['descrizione'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($node, 'descrizione', (string) ($campagna['descrizione'] ?? ''));
$node->addChild('start_date', (string) ($campagna['start_date'] ?? '')); $node->addChild('start_date', (string) ($campagna['start_date'] ?? ''));
$node->addChild('end_date', (string) ($campagna['end_date'] ?? '')); $node->addChild('end_date', (string) ($campagna['end_date'] ?? ''));
} }
@@ -793,9 +787,18 @@ class XmlExchange extends Component
$node->addChild('assigned_at', (string) ($assegnazione['assigned_at'] ?? '')); $node->addChild('assigned_at', (string) ($assegnazione['assigned_at'] ?? ''));
$node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? '')); $node->addChild('returned_at', (string) ($assegnazione['returned_at'] ?? ''));
$node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0))); $node->addChild('counted_in_campaign', (string) ((int) ($assegnazione['counted_in_campaign'] ?? 0)));
$node->addChild('note', htmlspecialchars((string) ($assegnazione['note'] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8')); $this->addXmlText($node, 'note', (string) ($assegnazione['note'] ?? ''));
} }
return $xml->asXML() ?: ''; return $xml->asXML() ?: '';
} }
private function addXmlText(\SimpleXMLElement $parent, string $name, string $value): \SimpleXMLElement
{
$child = $parent->addChild($name);
$dom = dom_import_simplexml($child);
$dom->textContent = $value;
return $child;
}
} }

View File

@@ -4,7 +4,6 @@ namespace App\Models;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class Assegnazione extends Model class Assegnazione extends Model
@@ -103,18 +102,19 @@ class Assegnazione extends Model
return null; return null;
} }
$months = max(1, (int) Setting::getValue('assignment_link_ttl_hours', 1)); return route('assignments.pdf.viewer', [
$url = URL::temporarySignedRoute(
'assignments.pdf.viewer',
now()->addMonths($months),
[
'assignment' => $this->id, 'assignment' => $this->id,
'code' => $this->ensurePdfAccessCode(), 'code' => $this->ensurePdfAccessCode(),
] ]);
); }
return $url; public function shortPdfUrl(): ?string
{
if (! $this->is_aperta || ! $this->territorio?->pdf_path) {
return null;
}
return route('assignments.pdf.short', ['code' => $this->ensurePdfAccessCode()]);
} }
// ─── Scopes ───────────────────────────────────────────────── // ─── Scopes ─────────────────────────────────────────────────

View File

@@ -21,6 +21,18 @@ server {
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params; include fastcgi_params;
fastcgi_hide_header X-Powered-By; fastcgi_hide_header X-Powered-By;
# Forward proxy headers so Laravel can validate signed URLs behind reverse proxy
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_X_FORWARDED_HOST $http_host;
fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port;
# Trust external proxy proto, or default to https for signed URL validation
set $fwd_proto $http_x_forwarded_proto;
if ($fwd_proto = "") {
set $fwd_proto "https";
}
fastcgi_param HTTP_X_FORWARDED_PROTO $fwd_proto;
} }
location ~ /\.(?!well-known).* { location ~ /\.(?!well-known).* {

View File

@@ -89,7 +89,7 @@
</div> </div>
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
@foreach($assegnazioniAperte as $a) @foreach($assegnazioniAperte as $a)
@php($pdfUrl = $a->temporaryPdfViewerUrl()) @php($pdfUrl = $a->shortPdfUrl())
<div class="px-5 py-4 hover:bg-indigo-50/30 transition-colors"> <div class="px-5 py-4 hover:bg-indigo-50/30 transition-colors">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">

View File

@@ -37,7 +37,7 @@
<div> <div>
<p class="text-xs font-medium text-gray-500 uppercase">Percorsi (anno)</p> <p class="text-xs font-medium text-gray-500 uppercase">Percorsi (anno)</p>
<p class="mt-1 text-3xl font-bold" style="color:#22c55e">{{ $territoriPercorsi }}</p> <p class="mt-1 text-3xl font-bold" style="color:#22c55e">{{ $territoriPercorsi }}</p>
<p class="text-xs text-gray-500">media {{ $mediaPercorrenzaMensile }}/mese</p> <p class="text-xs text-gray-500">durata media {{ $mediaDurataPercorrenzaMesi }} mesi</p>
</div> </div>
<div class="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#dcfce7"> <div class="h-10 w-10 rounded-lg flex items-center justify-center" style="background:#dcfce7">
<svg class="h-5 w-5" style="color:#22c55e" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="h-5 w-5" style="color:#22c55e" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>

View File

@@ -66,7 +66,7 @@
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@forelse($assegnazioni as $a) @forelse($assegnazioni as $a)
<tr class="hover:bg-indigo-50/30 transition-colors"> <tr class="hover:bg-indigo-50/30 transition-colors">
@php($temporaryPdfUrl = !$a->returned_at ? $a->temporaryPdfViewerUrl() : null) @php($temporaryPdfUrl = !$a->returned_at ? $a->shortPdfUrl() : null)
<td class="px-3 py-2"> <td class="px-3 py-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if($a->territorio?->thumbnail_path) @if($a->territorio?->thumbnail_path)

View File

@@ -246,47 +246,46 @@
<h2 class="text-lg font-semibold text-gray-900">Conversione dump SQL legacy</h2> <h2 class="text-lg font-semibold text-gray-900">Conversione dump SQL legacy</h2>
<p class="text-xs text-gray-500">Carica il file SQL (es. dump-termanager-202604071526.sql) e genera un XML compatibile con l'import dell'app.</p> <p class="text-xs text-gray-500">Carica il file SQL (es. dump-termanager-202604071526.sql) e genera un XML compatibile con l'import dell'app.</p>
<form action="{{ route('xml.convert-sql') }}" method="POST" enctype="multipart/form-data">
@csrf
<div> <div>
<input wire:model="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"> <input name="sqlDump" type="file" accept=".sql,.txt,.SQL,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('sqlDump') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('sqlDump') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2 mt-4">
<button <button
wire:click="convertLegacySqlToXml" type="submit"
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition border border-indigo-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;" style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#4f46e5;color:#fff;border:1px solid #3730a3;border-radius:10px;padding:10px 14px;"
> >
Converti in XML Converti in XML
</button> </button>
</div> </div>
</form>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"> <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">Import XML nell'app</h2> <h2 class="text-lg font-semibold text-gray-900">Import XML nell'app</h2>
<p class="text-xs text-gray-500">Importa un XML nel formato TerManager2. L'import sostituisce i dati gestionali (zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni e impostazioni).</p> <p class="text-xs text-gray-500">Importa un XML nel formato TerManager2. L'import sostituisce i dati gestionali (zone, tipologie, proclamatori, territori, anni, campagne, assegnazioni e impostazioni).</p>
<div wire:loading wire:target="importXmlIntoApp" style="padding:10px 12px;border-radius:10px;background:#fffbeb;border:1px solid #f59e0b;color:#92400e;font-size:13px;"> <form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" onsubmit="return confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.');">
Importazione in corso... attendi il completamento. @csrf
</div>
<div> <div>
<input wire:model="xmlImport" type="file" accept=".xml,.txt,.XML,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100"> <input name="xmlImport" type="file" accept=".xml,.txt,.XML,.TXT" class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
@error('xmlImport') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('xmlImport') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2 mt-4">
<button <button
wire:click="importXmlIntoApp" type="submit"
type="button"
onclick="if(!confirm('Confermi l\'import XML? I dati gestionali correnti verranno sostituiti.')) event.stopImmediatePropagation();"
class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition border border-amber-700" class="px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition border border-amber-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#d97706;color:#fff;border:1px solid #92400e;border-radius:10px;padding:10px 14px;" style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#d97706;color:#fff;border:1px solid #92400e;border-radius:10px;padding:10px 14px;"
> >
Importa XML Importa XML
</button> </button>
</div> </div>
</form>
</div> </div>
@if(!empty($importStats)) @if(!empty($importStats))

View File

@@ -128,9 +128,8 @@
{{ str_replace('_', ' ', ucfirst($stato)) }} {{ str_replace('_', ' ', ucfirst($stato)) }}
</span> </span>
@if($territorio->is_prioritario) @if($territorio->is_prioritario)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1" <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-1">
title="{{ $territorio->prioritario ? 'Prioritario (manuale)' : 'Prioritario (giacenza)' }}"> Prioritario
{{ $territorio->prioritario ? 'Man.' : 'Auto' }}
</span> </span>
@endif @endif
</td> </td>

View File

@@ -81,7 +81,7 @@
</div> </div>
@if($activeAssignment) @if($activeAssignment)
@php($temporaryPdfUrl = $activeAssignment->temporaryPdfViewerUrl()) @php($temporaryPdfUrl = $activeAssignment->shortPdfUrl())
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6" x-data="{ copied: false }"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6" x-data="{ copied: false }">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div> <div>

View File

@@ -0,0 +1,46 @@
@if ($paginator->hasPages())
<nav>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.previous')">
<span class="page-link" aria-hidden="true">&lsaquo;</span>
</li>
@else
<li class="page-item">
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">&lsaquo;</a>
</li>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<li class="page-item disabled" aria-disabled="true"><span class="page-link">{{ $element }}</span></li>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<li class="page-item active" aria-current="page"><span class="page-link">{{ $page }}</span></li>
@else
<li class="page-item"><a class="page-link" href="{{ $url }}">{{ $page }}</a></li>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')">&rsaquo;</a>
</li>
@else
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
<span class="page-link" aria-hidden="true">&rsaquo;</span>
</li>
@endif
</ul>
</nav>
@endif

View File

@@ -0,0 +1,88 @@
@if ($paginator->hasPages())
<nav class="d-flex justify-items-center justify-content-between">
<div class="d-flex justify-content-between flex-fill d-sm-none">
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.previous')</span>
</li>
@else
<li class="page-item">
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev">@lang('pagination.previous')</a>
</li>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next">@lang('pagination.next')</a>
</li>
@else
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.next')</span>
</li>
@endif
</ul>
</div>
<div class="d-none flex-sm-fill d-sm-flex align-items-sm-center justify-content-sm-between">
<div>
<p class="small text-muted">
{!! __('Showing') !!}
<span class="fw-semibold">{{ $paginator->firstItem() }}</span>
{!! __('to') !!}
<span class="fw-semibold">{{ $paginator->lastItem() }}</span>
{!! __('of') !!}
<span class="fw-semibold">{{ $paginator->total() }}</span>
{!! __('results') !!}
</p>
</div>
<div>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.previous')">
<span class="page-link" aria-hidden="true">&lsaquo;</span>
</li>
@else
<li class="page-item">
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">&lsaquo;</a>
</li>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<li class="page-item disabled" aria-disabled="true"><span class="page-link">{{ $element }}</span></li>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<li class="page-item active" aria-current="page"><span class="page-link">{{ $page }}</span></li>
@else
<li class="page-item"><a class="page-link" href="{{ $url }}">{{ $page }}</a></li>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')">&rsaquo;</a>
</li>
@else
<li class="page-item disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
<span class="page-link" aria-hidden="true">&rsaquo;</span>
</li>
@endif
</ul>
</div>
</div>
</nav>
@endif

View File

@@ -0,0 +1,46 @@
@if ($paginator->hasPages())
<nav>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="disabled" aria-disabled="true" aria-label="@lang('pagination.previous')">
<span aria-hidden="true">&lsaquo;</span>
</li>
@else
<li>
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')">&lsaquo;</a>
</li>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<li class="disabled" aria-disabled="true"><span>{{ $element }}</span></li>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<li class="active" aria-current="page"><span>{{ $page }}</span></li>
@else
<li><a href="{{ $url }}">{{ $page }}</a></li>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li>
<a href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')">&rsaquo;</a>
</li>
@else
<li class="disabled" aria-disabled="true" aria-label="@lang('pagination.next')">
<span aria-hidden="true">&rsaquo;</span>
</li>
@endif
</ul>
</nav>
@endif

View File

@@ -0,0 +1,36 @@
@if ($paginator->hasPages())
<div class="ui pagination menu" role="navigation">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<a class="icon item disabled" aria-disabled="true" aria-label="@lang('pagination.previous')"> <i class="left chevron icon"></i> </a>
@else
<a class="icon item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="@lang('pagination.previous')"> <i class="left chevron icon"></i> </a>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<a class="icon item disabled" aria-disabled="true">{{ $element }}</a>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<a class="item active" href="{{ $url }}" aria-current="page">{{ $page }}</a>
@else
<a class="item" href="{{ $url }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<a class="icon item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="@lang('pagination.next')"> <i class="right chevron icon"></i> </a>
@else
<a class="icon item disabled" aria-disabled="true" aria-label="@lang('pagination.next')"> <i class="right chevron icon"></i> </a>
@endif
</div>
@endif

View File

@@ -0,0 +1,27 @@
@if ($paginator->hasPages())
<nav>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.previous')</span>
</li>
@else
<li class="page-item">
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev">@lang('pagination.previous')</a>
</li>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next">@lang('pagination.next')</a>
</li>
@else
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">@lang('pagination.next')</span>
</li>
@endif
</ul>
</nav>
@endif

View File

@@ -0,0 +1,29 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="Pagination Navigation">
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">{!! __('pagination.previous') !!}</span>
</li>
@else
<li class="page-item">
<a class="page-link" href="{{ $paginator->previousPageUrl() }}" rel="prev">
{!! __('pagination.previous') !!}
</a>
</li>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li class="page-item">
<a class="page-link" href="{{ $paginator->nextPageUrl() }}" rel="next">{!! __('pagination.next') !!}</a>
</li>
@else
<li class="page-item disabled" aria-disabled="true">
<span class="page-link">{!! __('pagination.next') !!}</span>
</li>
@endif
</ul>
</nav>
@endif

View File

@@ -0,0 +1,19 @@
@if ($paginator->hasPages())
<nav>
<ul class="pagination">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<li class="disabled" aria-disabled="true"><span>@lang('pagination.previous')</span></li>
@else
<li><a href="{{ $paginator->previousPageUrl() }}" rel="prev">@lang('pagination.previous')</a></li>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<li><a href="{{ $paginator->nextPageUrl() }}" rel="next">@lang('pagination.next')</a></li>
@else
<li class="disabled" aria-disabled="true"><span>@lang('pagination.next')</span></li>
@endif
</ul>
</nav>
@endif

View File

@@ -0,0 +1,25 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="Pagination Navigation" class="flex justify-between">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
{!! __('pagination.previous') !!}
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
{!! __('pagination.previous') !!}
</a>
@endif
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
{!! __('pagination.next') !!}
</a>
@else
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
{!! __('pagination.next') !!}
</span>
@endif
</nav>
@endif

View File

@@ -0,0 +1,131 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="Navigazione pagine"
class="flex flex-col sm:flex-row items-center justify-between gap-4 py-2">
{{-- Info testo --}}
<p class="text-sm text-gray-500 order-2 sm:order-1 shrink-0">
@if ($paginator->firstItem())
Risultati <span class="font-semibold text-gray-700">{{ $paginator->firstItem() }}{{ $paginator->lastItem() }}</span>
di <span class="font-semibold text-gray-700">{{ $paginator->total() }}</span>
@else
{{ $paginator->count() }} risultati
@endif
</p>
{{-- Controlli --}}
<div class="flex items-center gap-1.5 order-1 sm:order-2">
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="Pagina precedente">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</a>
@endif
{{-- Numeri pagina (solo desktop) --}}
<div class="hidden sm:flex items-center gap-1.5">
@foreach ($elements as $element)
@if (is_string($element))
<span class="inline-flex items-center justify-center w-9 h-9 text-sm text-gray-400 select-none"></span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span aria-current="page"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-sm font-semibold text-white select-none"
style="background:#4f46e5">{{ $page }}</span>
@else
<a href="{{ $url }}"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="Vai a pagina {{ $page }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
</div>
{{-- Indicatore pagina corrente (solo mobile) --}}
<span class="sm:hidden inline-flex items-center justify-center px-4 h-9 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-600 select-none">
{{ $paginator->currentPage() }} / {{ $paginator->lastPage() }}
</span>
{{-- Next --}}
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="Pagina successiva">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</a>
@else
<span class="inline-flex items-center justify-center w-9 h-9 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</span>
@endif
</div>
</nav>
@endif
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="{{ __('pagination.previous') }}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
</a>
@endif
{{-- Numeri pagina: nascosti su mobile, visibili da sm in su --}}
<div class="hidden sm:flex items-center gap-1">
@foreach ($elements as $element)
@if (is_string($element))
<span class="inline-flex items-center justify-center w-8 h-8 text-xs text-gray-400 select-none"></span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span aria-current="page"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg text-xs font-semibold text-white select-none"
style="background:#4f46e5">{{ $page }}</span>
@else
<a href="{{ $url }}"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white text-xs font-medium text-gray-600 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
</div>
{{-- Indicatore pagina mobile --}}
<span class="sm:hidden inline-flex items-center justify-center px-3 h-8 rounded-lg border border-gray-200 bg-white text-xs font-medium text-gray-600 select-none">
{{ $paginator->currentPage() }} / {{ $paginator->lastPage() }}
</span>
{{-- Next --}}
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-600 transition"
aria-label="{{ __('pagination.next') }}">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</a>
@else
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg border border-gray-200 text-gray-300 cursor-default select-none">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
</span>
@endif
</div>
</nav>
@endif

View File

@@ -2,7 +2,9 @@
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Settings\TerritoryPdfImportController; use App\Http\Controllers\Settings\TerritoryPdfImportController;
use App\Http\Controllers\Settings\XmlExchangeUploadController;
use App\Http\Controllers\AssignmentPdfController; use App\Http\Controllers\AssignmentPdfController;
use App\Http\Controllers\ShortPdfLinkController;
use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\LoginController;
use App\Livewire\Home; use App\Livewire\Home;
use App\Livewire\Territori\TerritorioIndex; use App\Livewire\Territori\TerritorioIndex;
@@ -48,6 +50,7 @@ Route::post('logout', function () {
return redirect('/login'); return redirect('/login');
})->middleware('auth')->name('logout'); })->middleware('auth')->name('logout');
Route::get('p/{code}', ShortPdfLinkController::class)->name('assignments.pdf.short');
Route::get('assegnazioni/pdf/{assignment}/{code}', [AssignmentPdfController::class, 'viewer']) Route::get('assegnazioni/pdf/{assignment}/{code}', [AssignmentPdfController::class, 'viewer'])
->name('assignments.pdf.viewer'); ->name('assignments.pdf.viewer');
Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file']) Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file'])
@@ -114,6 +117,8 @@ Route::middleware('auth')->group(function () {
Route::get('zone', ZoneIndex::class)->name('zone.index'); Route::get('zone', ZoneIndex::class)->name('zone.index');
Route::get('tipologie', TipologieIndex::class)->name('tipologie.index'); Route::get('tipologie', TipologieIndex::class)->name('tipologie.index');
Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange'); Route::get('xml-exchange', XmlExchange::class)->name('xml.exchange');
Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml');
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip'); Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
}); });