Compare commits

...

2 Commits

47 changed files with 1536 additions and 100 deletions

4
.gitignore vendored
View File

@@ -4,7 +4,9 @@
/storage/*.key /storage/*.key
/storage/app/.app_key /storage/app/.app_key
/storage/app/.db_seeded /storage/app/.db_seeded
/storage/app/public/territori-pdf/*.pdf /storage/app/public/territori-pdf/*.*
/storage/app/public/territori-thumbnails/*.*
/storage/app/livewire-tmp/*.*
/storage/logs/ /storage/logs/
/storage/framework/ /storage/framework/
/bootstrap/cache/ /bootstrap/cache/

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use App\Models\Assegnazione;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AssignmentPdfController extends Controller
{
public function viewer(Request $request, Assegnazione $assignment, string $code): View
{
$this->validateAccess($request, $assignment, $code);
$expiresAt = Carbon::createFromTimestamp((int) $request->query('expires'));
$pdfUrl = URL::temporarySignedRoute(
'assignments.pdf.file',
$expiresAt,
['assignment' => $assignment->id, 'code' => $code]
);
return view('assignments.pdf-viewer', [
'assignment' => $assignment,
'pdfUrl' => $pdfUrl,
]);
}
public function file(Request $request, Assegnazione $assignment, string $code): StreamedResponse
{
$this->validateAccess($request, $assignment, $code);
$pdfPath = $assignment->territorio?->pdf_path;
abort_unless($pdfPath && Storage::disk('public')->exists($pdfPath), 404);
return Storage::disk('public')->response(
$pdfPath,
'territorio-' . $assignment->territorio?->numero . '.pdf',
[
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="territorio-' . $assignment->territorio?->numero . '.pdf"',
]
);
}
protected function validateAccess(Request $request, 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->is_aperta, 403);
abort_unless($assignment->territorio?->pdf_path, 404);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Services\TerritorioPdfImportDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use RuntimeException;
class TerritoryPdfImportController extends Controller
{
public function storeZip(Request $request, TerritorioPdfImportDispatcher $dispatcher): JsonResponse|RedirectResponse
{
$request->validate([
'pdfZip' => ['required', 'file', 'mimes:zip', 'max:256000'],
]);
try {
$importId = $dispatcher->dispatchUploadedZip($request->file('pdfZip'), auth()->id());
} catch (RuntimeException $exception) {
if ($request->expectsJson()) {
return response()->json([
'message' => $exception->getMessage(),
'errors' => ['pdfZip' => [$exception->getMessage()]],
], 422);
}
return back()->withErrors(['pdfZip' => $exception->getMessage()]);
}
$redirectUrl = route('xml.exchange', ['pdf-import' => $importId]);
if ($request->expectsJson()) {
return response()->json([
'message' => 'Import PDF avviato in background.',
'import_id' => $importId,
'redirect_url' => $redirectUrl,
]);
}
return redirect($redirectUrl)
->with('success', 'Import PDF avviato in background. I log si aggiorneranno automaticamente.');
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Jobs;
use App\Models\Territorio;
use App\Models\User;
use App\Services\TerritorioPdfImportState;
use App\Services\TerritorioThumbnailService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportTerritoryPdfFolder implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public int $timeout = 0;
public function __construct(
public string $importId,
public array $files,
public int $actorId,
) {
}
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService): void
{
$stateService->markRunning($this->importId);
$stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.');
$territoriMap = [];
foreach (Territorio::withTrashed()->get() as $territorio) {
$territoriMap[$this->normalizeTerritoryNumber($territorio->numero)] = $territorio;
}
$actor = User::find($this->actorId);
$seenNumbers = [];
try {
foreach ($this->files as $file) {
$originalName = $file['original_name'] ?? 'file-sconosciuto.pdf';
$stateService->increment($this->importId, 'processed');
$territoryMatch = $this->resolveTerritoryFromFilename($originalName, $territoriMap);
if ($territoryMatch === null) {
$stateService->increment($this->importId, 'skipped');
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - nessun numero territorio riconosciuto nel nome file.');
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'no-match',
'message' => 'Nessun numero territorio riconosciuto nel nome file.',
]);
continue;
}
if (($territoryMatch['type'] ?? 'single') === 'ambiguous') {
$stateService->increment($this->importId, 'skipped');
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - nome ambiguo, possibili territori: ' . implode(', ', $territoryMatch['matched_numbers']) . '.');
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'ambiguous',
'message' => 'Nome ambiguo.',
'matched_numbers' => $territoryMatch['matched_numbers'],
]);
continue;
}
$normalizedNumber = $territoryMatch['normalized_number'];
$matchedNumber = $territoryMatch['matched_number'];
$territorio = $territoryMatch['territorio'];
if (isset($seenNumbers[$normalizedNumber])) {
$stateService->increment($this->importId, 'skipped');
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - territorio ' . $matchedNumber . ' presente piu volte nella stessa importazione.');
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'duplicate-in-batch',
'message' => 'Territorio presente piu volte nella stessa importazione.',
'matched_numbers' => [$matchedNumber],
]);
continue;
}
$seenNumbers[$normalizedNumber] = true;
$sourcePath = Storage::disk('local')->path($file['stored_path']);
if (! is_file($sourcePath)) {
$stateService->increment($this->importId, 'errors');
$stateService->appendLog($this->importId, '[ERR] ' . $originalName . ' - file temporaneo non trovato.');
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'missing-temp-file',
'message' => 'File temporaneo non trovato.',
'matched_numbers' => [$matchedNumber],
]);
continue;
}
try {
if ($territorio->pdf_path) {
Storage::disk('public')->delete($territorio->pdf_path);
}
if ($territorio->thumbnail_path) {
$thumbnailService->delete($territorio->thumbnail_path);
}
$storedFilename = Str::slug('territorio-' . $territorio->numero) . '.pdf';
$publicPath = 'territori-pdf/' . $storedFilename;
Storage::disk('public')->put($publicPath, file_get_contents($sourcePath));
$thumbnailPath = $thumbnailService->generate($publicPath);
$territorio->update([
'pdf_path' => $publicPath,
'thumbnail_path' => $thumbnailPath,
]);
if ($actor) {
activity()->causedBy($actor)
->performedOn($territorio)
->withProperties([
'numero' => $territorio->numero,
'pdf' => $originalName,
'bulk_import' => true,
])
->log('bulk_uploaded_pdf');
}
$stateService->increment($this->importId, 'updated');
$stateService->appendLog(
$this->importId,
'[OK] ' . $originalName . ' - aggiornato territorio ' . $territorio->numero . ($thumbnailPath ? ' con thumbnail generata.' : ' ma la thumbnail non e stata generata.')
);
} catch (\Throwable $exception) {
$stateService->increment($this->importId, 'errors');
$stateService->appendLog($this->importId, '[ERR] ' . $originalName . ' - ' . $exception->getMessage());
$stateService->addIssue($this->importId, [
'file' => $originalName,
'type' => 'processing-error',
'message' => $exception->getMessage(),
'matched_numbers' => [$matchedNumber],
]);
}
}
$stateService->appendLog($this->importId, 'Import completato.');
$stateService->markCompleted($this->importId);
} catch (\Throwable $exception) {
$stateService->increment($this->importId, 'errors');
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
$stateService->markFailed($this->importId);
throw $exception;
} finally {
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId);
}
}
protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
{
if ($territoriMap === []) {
return null;
}
$basename = pathinfo($filename, PATHINFO_FILENAME);
$normalizedBasename = mb_strtoupper($basename);
$normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename);
$normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename));
$territoryKeys = array_keys($territoriMap);
usort($territoryKeys, function (string $left, string $right) {
$lengthComparison = mb_strlen($right) <=> mb_strlen($left);
if ($lengthComparison !== 0) {
return $lengthComparison;
}
return strnatcasecmp($left, $right);
});
$matches = [];
foreach ($territoryKeys as $normalizedNumber) {
if (! $this->filenameContainsTerritoryNumber($normalizedBasename, $normalizedNumber)) {
continue;
}
$matches[] = [
'normalized_number' => $normalizedNumber,
'matched_number' => $territoriMap[$normalizedNumber]->numero,
'territorio' => $territoriMap[$normalizedNumber],
];
}
if ($matches === []) {
return null;
}
if (count($matches) > 1) {
return [
'type' => 'ambiguous',
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)),
];
}
return $matches[0];
}
protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool
{
$escapedNumber = preg_quote($normalizedNumber, '/');
if (preg_match('/^\d+$/', $normalizedNumber)) {
$escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/');
}
return (bool) preg_match('/(^| )' . $escapedNumber . '(?= |$)/', $normalizedBasename);
}
protected function normalizeTerritoryNumber(string $number): string
{
$normalized = preg_replace('/\s+/', ' ', trim(mb_strtoupper($number)));
if ($normalized !== '' && preg_match('/^\d+$/', $normalized)) {
return ltrim($normalized, '0') ?: '0';
}
return $normalized;
}
}

View File

@@ -15,6 +15,8 @@ class Home extends Component
public function render() public function render()
{ {
$settings = Setting::instance(); $settings = Setting::instance();
$homeLimit = max(1, (int) ($settings->home_limit_list ?? 10));
$priorityThreshold = (int) ($settings->giorni_giacenza_prioritari ?? 180);
$annoCorrente = AnnoTeocratico::corrente(); $annoCorrente = AnnoTeocratico::corrente();
$campagnaAttiva = Campagna::attiva(); $campagnaAttiva = Campagna::attiva();
@@ -49,19 +51,49 @@ class Home extends Component
} }
// Quick lists // Quick lists
$daAssegnare = Territorio::daAssegnare() $territoriDaAssegnare = Territorio::inReparto()
->with('zona', 'tipologia', 'ultimaAssegnazione') ->with('zona', 'tipologia', 'ultimaAssegnazione')
->take(10) ->get()
->get(); ->map(function (Territorio $territorio) use ($priorityThreshold) {
$ultima = $territorio->ultimaAssegnazione;
$prioritari = Territorio::prioritari() if ($ultima && $ultima->returned_at) {
->with('zona', 'tipologia', 'ultimaAssegnazione') $giorniGiacenza = $ultima->returned_at->startOfDay()->diffInDays(today());
->take(10) } elseif (! $ultima) {
->get(); $giorniGiacenza = $territorio->created_at->startOfDay()->diffInDays(today());
} else {
$giorniGiacenza = 0;
}
$territorio->setAttribute('home_giorni_giacenza', $giorniGiacenza);
$territorio->setAttribute(
'home_is_prioritario',
(bool) $territorio->prioritario || $giorniGiacenza > $priorityThreshold
);
return $territorio;
})
->sort(function (Territorio $left, Territorio $right) {
$priorityComparison = (int) $right->home_is_prioritario <=> (int) $left->home_is_prioritario;
if ($priorityComparison !== 0) {
return $priorityComparison;
}
$giacenzaComparison = $right->home_giorni_giacenza <=> $left->home_giorni_giacenza;
if ($giacenzaComparison !== 0) {
return $giacenzaComparison;
}
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})
->take($homeLimit)
->values();
$daRientrare = Territorio::daRientrare() $daRientrare = Territorio::daRientrare()
->with(['zona', 'assegnazioneCorrente.proclamatore']) ->with(['zona', 'assegnazioneCorrente.proclamatore'])
->take(10) ->take($homeLimit)
->get(); ->get();
return view('livewire.home', [ return view('livewire.home', [
@@ -73,8 +105,8 @@ class Home extends Component
'territoriPercorsi' => $territoriPercorsi, 'territoriPercorsi' => $territoriPercorsi,
'mediaPercorrenzaMensile' => $mediaPercorrenzaMensile, 'mediaPercorrenzaMensile' => $mediaPercorrenzaMensile,
'campagnaStats' => $campagnaStats, 'campagnaStats' => $campagnaStats,
'daAssegnare' => $daAssegnare, 'homeLimit' => $homeLimit,
'prioritari' => $prioritari, 'territoriDaAssegnare' => $territoriDaAssegnare,
'daRientrare' => $daRientrare, 'daRientrare' => $daRientrare,
]); ]);
} }

View File

@@ -151,7 +151,7 @@ class Registro extends Component
public function render() public function render()
{ {
$query = Assegnazione::with(['territorio.zona', 'proclamatore', 'annoTeocratico', 'campagna']); $query = Assegnazione::with(['territorio.zona', 'territorio.assegnazioneCorrente', 'proclamatore', 'annoTeocratico', 'campagna']);
if ($this->filtroAnno) { if ($this->filtroAnno) {
$query->where('anno_teocratico_id', $this->filtroAnno); $query->where('anno_teocratico_id', $this->filtroAnno);

View File

@@ -12,6 +12,7 @@ class SettingsEdit extends Component
public int $giorni_giacenza_prioritari = 180; public int $giorni_giacenza_prioritari = 180;
public int $giorni_per_smarrito = 120; public int $giorni_per_smarrito = 120;
public int $home_limit_list = 10; public int $home_limit_list = 10;
public int $assignment_link_ttl_months = 1;
public int $audit_retention_days = 365; public int $audit_retention_days = 365;
public function mount() public function mount()
@@ -22,6 +23,7 @@ class SettingsEdit extends Component
$this->giorni_giacenza_prioritari = $settings->giorni_giacenza_prioritari ?? 180; $this->giorni_giacenza_prioritari = $settings->giorni_giacenza_prioritari ?? 180;
$this->giorni_per_smarrito = $settings->giorni_per_smarrito ?? 120; $this->giorni_per_smarrito = $settings->giorni_per_smarrito ?? 120;
$this->home_limit_list = $settings->home_limit_list ?? 10; $this->home_limit_list = $settings->home_limit_list ?? 10;
$this->assignment_link_ttl_months = $settings->assignment_link_ttl_hours ?? 1;
$this->audit_retention_days = $settings->audit_retention_days ?? 365; $this->audit_retention_days = $settings->audit_retention_days ?? 365;
} }
@@ -33,6 +35,7 @@ class SettingsEdit extends Component
'giorni_giacenza_prioritari' => 'required|integer|min:1|max:730', 'giorni_giacenza_prioritari' => 'required|integer|min:1|max:730',
'giorni_per_smarrito' => 'required|integer|min:30|max:365', 'giorni_per_smarrito' => 'required|integer|min:30|max:365',
'home_limit_list' => 'required|integer|min:1|max:100', 'home_limit_list' => 'required|integer|min:1|max:100',
'assignment_link_ttl_months' => 'required|integer|min:1|max:24',
'audit_retention_days' => 'required|integer|min:30|max:3650', 'audit_retention_days' => 'required|integer|min:30|max:3650',
]; ];
} }
@@ -48,6 +51,7 @@ class SettingsEdit extends Component
'giorni_giacenza_prioritari' => $this->giorni_giacenza_prioritari, 'giorni_giacenza_prioritari' => $this->giorni_giacenza_prioritari,
'giorni_per_smarrito' => $this->giorni_per_smarrito, 'giorni_per_smarrito' => $this->giorni_per_smarrito,
'home_limit_list' => $this->home_limit_list, 'home_limit_list' => $this->home_limit_list,
'assignment_link_ttl_hours' => $this->assignment_link_ttl_months,
'audit_retention_days' => $this->audit_retention_days, 'audit_retention_days' => $this->audit_retention_days,
]); ]);

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Jobs\ImportTerritoryPdfFolder;
use App\Models\AnnoTeocratico; use App\Models\AnnoTeocratico;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\Campagna; use App\Models\Campagna;
@@ -11,9 +12,12 @@ use App\Models\Territorio;
use App\Models\Tipologia; use App\Models\Tipologia;
use App\Models\User; use App\Models\User;
use App\Models\Zona; use App\Models\Zona;
use App\Services\TerritorioPdfImportDispatcher;
use App\Services\TerritorioPdfImportState;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
@@ -25,6 +29,22 @@ class XmlExchange extends Component
public $xmlImport; public $xmlImport;
public array $importStats = []; public array $importStats = [];
public array $importIssues = []; public array $importIssues = [];
public array $pdfFolder = [];
public array $pdfImportLogs = [];
public array $pdfImportStats = [];
public array $pdfImportIssues = [];
public ?string $currentPdfImportId = null;
public string $pdfImportStatus = 'idle';
public string $pdfImportLogText = '';
public function mount(): void
{
$this->currentPdfImportId = request()->query('pdf-import');
if ($this->currentPdfImportId) {
$this->refreshPdfImportStatus();
}
}
public function convertLegacySqlToXml() public function convertLegacySqlToXml()
{ {
@@ -98,6 +118,7 @@ class XmlExchange extends Component
'giorni_giacenza_prioritari' => (int) ($settingsNode->giorni_giacenza_prioritari ?? 180), 'giorni_giacenza_prioritari' => (int) ($settingsNode->giorni_giacenza_prioritari ?? 180),
'giorni_per_smarrito' => (int) ($settingsNode->giorni_per_smarrito ?? 120), 'giorni_per_smarrito' => (int) ($settingsNode->giorni_per_smarrito ?? 120),
'home_limit_list' => (int) ($settingsNode->home_limit_list ?? 10), '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),
'audit_retention_days' => (int) ($settingsNode->audit_retention_days ?? 730), 'audit_retention_days' => (int) ($settingsNode->audit_retention_days ?? 730),
'setup_completed' => true, 'setup_completed' => true,
]); ]);
@@ -257,6 +278,70 @@ class XmlExchange extends Component
session()->flash('success', $message); 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() public function downloadImportLogPdf()
{ {
if (empty($this->importStats)) { if (empty($this->importStats)) {
@@ -303,6 +388,7 @@ class XmlExchange extends Component
'giorni_giacenza_prioritari' => (int) ($settings->giorni_giacenza_prioritari ?? 180), 'giorni_giacenza_prioritari' => (int) ($settings->giorni_giacenza_prioritari ?? 180),
'giorni_per_smarrito' => (int) ($settings->giorni_per_smarrito ?? 120), 'giorni_per_smarrito' => (int) ($settings->giorni_per_smarrito ?? 120),
'home_limit_list' => (int) ($settings->home_limit_list ?? 10), 'home_limit_list' => (int) ($settings->home_limit_list ?? 10),
'assignment_link_ttl_months' => (int) ($settings->assignment_link_ttl_hours ?? 1),
'audit_retention_days' => (int) ($settings->audit_retention_days ?? 730), 'audit_retention_days' => (int) ($settings->audit_retention_days ?? 730),
], ],
'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(), 'zones' => Zona::query()->orderBy('id')->get(['id', 'nome', 'attivo'])->toArray(),
@@ -328,6 +414,7 @@ class XmlExchange extends Component
'giorni_giacenza_prioritari' => (int) ($impostazioni[1] ?? 180), 'giorni_giacenza_prioritari' => (int) ($impostazioni[1] ?? 180),
'giorni_per_smarrito' => (int) ($impostazioni[3] ?? 120), 'giorni_per_smarrito' => (int) ($impostazioni[3] ?? 120),
'home_limit_list' => (int) ($impostazioni[2] ?? 10), 'home_limit_list' => (int) ($impostazioni[2] ?? 10),
'assignment_link_ttl_months' => 1,
'audit_retention_days' => 730, 'audit_retention_days' => 730,
]; ];

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Territori; namespace App\Livewire\Territori;
use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
use App\Models\Territorio; use App\Models\Territorio;
@@ -16,6 +17,9 @@ class TerritorioIndex extends Component
public string $filterZona = ''; public string $filterZona = '';
public string $filterTipologia = ''; public string $filterTipologia = '';
public string $filterStato = ''; public string $filterStato = '';
public string $filterPriorita = '';
public string $filterPdf = '';
public string $filterContenuti = '';
public string $sortField = 'numero'; public string $sortField = 'numero';
public string $sortDirection = 'asc'; public string $sortDirection = 'asc';
@@ -24,6 +28,9 @@ class TerritorioIndex extends Component
'filterZona' => ['except' => ''], 'filterZona' => ['except' => ''],
'filterTipologia' => ['except' => ''], 'filterTipologia' => ['except' => ''],
'filterStato' => ['except' => ''], 'filterStato' => ['except' => ''],
'filterPriorita' => ['except' => ''],
'filterPdf' => ['except' => ''],
'filterContenuti' => ['except' => ''],
]; ];
public function updatingSearch() public function updatingSearch()
@@ -31,6 +38,36 @@ class TerritorioIndex extends Component
$this->resetPage(); $this->resetPage();
} }
public function updatingFilterZona()
{
$this->resetPage();
}
public function updatingFilterTipologia()
{
$this->resetPage();
}
public function updatingFilterStato()
{
$this->resetPage();
}
public function updatingFilterPriorita()
{
$this->resetPage();
}
public function updatingFilterPdf()
{
$this->resetPage();
}
public function updatingFilterContenuti()
{
$this->resetPage();
}
public function sortBy(string $field) public function sortBy(string $field)
{ {
if ($this->sortField === $field) { if ($this->sortField === $field) {
@@ -41,6 +78,22 @@ class TerritorioIndex extends Component
} }
} }
public function clearFilters(): void
{
$this->reset([
'search',
'filterZona',
'filterTipologia',
'filterStato',
'filterPriorita',
'filterPdf',
'filterContenuti',
]);
$this->sortField = 'numero';
$this->sortDirection = 'asc';
$this->resetPage();
}
public function toggleActive(Territorio $territorio) public function toggleActive(Territorio $territorio)
{ {
$territorio->update(['attivo' => !$territorio->attivo]); $territorio->update(['attivo' => !$territorio->attivo]);
@@ -73,7 +126,14 @@ class TerritorioIndex extends Component
$query = Territorio::with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']); $query = Territorio::with(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore']);
if ($this->search) { if ($this->search) {
$query->where('numero', 'like', "%{$this->search}%"); $search = $this->search;
$query->where(function ($subQuery) use ($search) {
$subQuery->where('numero', 'like', "%{$search}%")
->orWhere('note', 'like', "%{$search}%")
->orWhere('confini', 'like', "%{$search}%")
->orWhereHas('zona', fn($zonaQuery) => $zonaQuery->where('nome', 'like', "%{$search}%"))
->orWhereHas('tipologia', fn($tipologiaQuery) => $tipologiaQuery->where('nome', 'like', "%{$search}%"));
});
} }
if ($this->filterZona) { if ($this->filterZona) {
@@ -90,16 +150,104 @@ class TerritorioIndex extends Component
'assegnato' => $query->assegnato(), 'assegnato' => $query->assegnato(),
'da_rientrare' => $query->daRientrare(), 'da_rientrare' => $query->daRientrare(),
'inattivo' => $query->where('attivo', false), 'inattivo' => $query->where('attivo', false),
'prioritari' => $query->inReparto(),
default => null, default => null,
}; };
} }
$query->orderBy($this->sortField, $this->sortDirection); if ($this->filterPdf) {
match ($this->filterPdf) {
'con_pdf' => $query->whereNotNull('pdf_path'),
'senza_pdf' => $query->whereNull('pdf_path'),
'con_thumbnail' => $query->whereNotNull('thumbnail_path'),
'senza_thumbnail' => $query->whereNull('thumbnail_path'),
default => null,
};
}
if ($this->filterContenuti) {
match ($this->filterContenuti) {
'con_note' => $query->whereNotNull('note')->where('note', '!=', ''),
'senza_note' => $query->where(function ($subQuery) {
$subQuery->whereNull('note')->orWhere('note', '');
}),
'con_confini' => $query->whereNotNull('confini')->where('confini', '!=', ''),
'senza_confini' => $query->where(function ($subQuery) {
$subQuery->whereNull('confini')->orWhere('confini', '');
}),
default => null,
};
}
$territori = $query->get();
if ($this->filterStato === 'prioritari') {
$territori = $territori->filter(fn(Territorio $territorio) => $territorio->is_prioritario)->values();
}
if ($this->filterPriorita) {
$territori = $territori->filter(function (Territorio $territorio) {
return match ($this->filterPriorita) {
'prioritari' => $territorio->is_prioritario,
'manuali' => $territorio->prioritario,
'automatici' => $territorio->is_prioritario && !$territorio->prioritario,
'non_prioritari' => !$territorio->is_prioritario,
default => true,
};
})->values();
}
if ($this->usesPriorityOrdering()) {
$territori = $territori->sort(function (Territorio $left, Territorio $right) {
$priorityComparison = (int) $right->is_prioritario <=> (int) $left->is_prioritario;
if ($priorityComparison !== 0) {
return $priorityComparison;
}
$giacenzaComparison = $right->giorni_giacenza <=> $left->giorni_giacenza;
if ($giacenzaComparison !== 0) {
return $giacenzaComparison;
}
return strnatcasecmp((string) $left->numero, (string) $right->numero);
})->values();
} else {
$territori = $territori->sort(function (Territorio $left, Territorio $right) {
$result = strnatcasecmp((string) data_get($left, $this->sortField), (string) data_get($right, $this->sortField));
return $this->sortDirection === 'asc' ? $result : -$result;
})->values();
}
$perPage = 20;
$page = $this->getPage();
$items = $territori->slice(($page - 1) * $perPage, $perPage)->values();
$paginatedTerritori = new LengthAwarePaginator(
$items,
$territori->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
return view('livewire.territori.territorio-index', [ return view('livewire.territori.territorio-index', [
'territori' => $query->paginate(20), 'territori' => $paginatedTerritori,
'zone' => Zona::attive()->get(), 'zone' => Zona::attive()->get(),
'tipologie' => Tipologia::attive()->get(), 'tipologie' => Tipologia::attive()->get(),
'usesPriorityOrdering' => $this->usesPriorityOrdering(),
]); ]);
} }
protected function usesPriorityOrdering(): bool
{
return $this->sortField === 'numero'
&& $this->sortDirection === 'asc'
&& (
in_array($this->filterStato, ['in_reparto', 'prioritari'], true)
|| $this->filterPriorita !== ''
);
}
} }

View File

@@ -5,7 +5,7 @@ namespace App\Livewire\Territori;
use Livewire\Component; use Livewire\Component;
use App\Models\Territorio; use App\Models\Territorio;
use App\Models\Assegnazione; use App\Models\Assegnazione;
use App\Models\AnnoTeocratico; use App\Models\Setting;
class TerritorioShow extends Component class TerritorioShow extends Component
{ {
@@ -13,7 +13,7 @@ class TerritorioShow extends Component
public function mount(Territorio $territorio) public function mount(Territorio $territorio)
{ {
$this->territorio = $territorio->load(['zona', 'tipologia']); $this->territorio = $territorio->load(['zona', 'tipologia', 'assegnazioneCorrente.proclamatore', 'ultimaAssegnazione']);
} }
public function render() public function render()
@@ -25,6 +25,8 @@ class TerritorioShow extends Component
->groupBy(fn($a) => $a->annoTeocratico->label); ->groupBy(fn($a) => $a->annoTeocratico->label);
return view('livewire.territori.territorio-show', [ return view('livewire.territori.territorio-show', [
'activeAssignment' => $this->territorio->assegnazioneCorrente,
'assignmentLinkTtlMonths' => (int) Setting::getValue('assignment_link_ttl_hours', 1),
'assegnazioniPerAnno' => $assegnazioni, 'assegnazioniPerAnno' => $assegnazioni,
]); ]);
} }

View File

@@ -4,6 +4,8 @@ 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;
class Assegnazione extends Model class Assegnazione extends Model
{ {
@@ -17,6 +19,7 @@ class Assegnazione extends Model
'returned_at', 'returned_at',
'counted_in_campaign', 'counted_in_campaign',
'campaign_id', 'campaign_id',
'pdf_access_code',
'note', 'note',
'created_by', 'created_by',
'returned_by', 'returned_by',
@@ -79,6 +82,39 @@ class Assegnazione extends Model
return is_null($this->returned_at); return is_null($this->returned_at);
} }
public function ensurePdfAccessCode(): string
{
if ($this->pdf_access_code) {
return $this->pdf_access_code;
}
do {
$code = strtoupper(Str::random(12));
} while (static::query()->where('pdf_access_code', $code)->exists());
$this->forceFill(['pdf_access_code' => $code])->saveQuietly();
return $code;
}
public function temporaryPdfViewerUrl(): ?string
{
if (! $this->is_aperta || ! $this->territorio?->pdf_path) {
return null;
}
$months = max(1, (int) Setting::getValue('assignment_link_ttl_hours', 1));
return URL::temporarySignedRoute(
'assignments.pdf.viewer',
now()->addMonths($months),
[
'assignment' => $this->id,
'code' => $this->ensurePdfAccessCode(),
]
);
}
// ─── Scopes ───────────────────────────────────────────────── // ─── Scopes ─────────────────────────────────────────────────
public function scopeAperte($query) public function scopeAperte($query)

View File

@@ -6,6 +6,8 @@ use Illuminate\Database\Eloquent\Model;
class Setting extends Model class Setting extends Model
{ {
protected static ?self $cachedInstance = null;
protected $fillable = [ protected $fillable = [
'congregazione_nome', 'congregazione_nome',
'logo_path', 'logo_path',
@@ -13,6 +15,7 @@ class Setting extends Model
'giorni_giacenza_prioritari', 'giorni_giacenza_prioritari',
'giorni_per_smarrito', 'giorni_per_smarrito',
'home_limit_list', 'home_limit_list',
'assignment_link_ttl_hours',
'audit_retention_days', 'audit_retention_days',
'setup_completed', 'setup_completed',
]; ];
@@ -25,22 +28,41 @@ class Setting extends Model
'giorni_giacenza_prioritari' => 'integer', 'giorni_giacenza_prioritari' => 'integer',
'giorni_per_smarrito' => 'integer', 'giorni_per_smarrito' => 'integer',
'home_limit_list' => 'integer', 'home_limit_list' => 'integer',
'assignment_link_ttl_hours' => 'integer',
'audit_retention_days' => 'integer', 'audit_retention_days' => 'integer',
]; ];
} }
protected static function booted(): void
{
static::saved(function (): void {
static::$cachedInstance = null;
});
static::deleted(function (): void {
static::$cachedInstance = null;
});
}
/** /**
* Get the singleton settings instance (first row). * Get the singleton settings instance (first row).
*/ */
public static function instance(): static public static function instance(): static
{ {
return static::firstOrCreate([], [ if (static::$cachedInstance instanceof static) {
return static::$cachedInstance;
}
static::$cachedInstance = static::firstOrCreate([], [
'giorni_giacenza_da_assegnare' => 120, 'giorni_giacenza_da_assegnare' => 120,
'giorni_giacenza_prioritari' => 180, 'giorni_giacenza_prioritari' => 180,
'giorni_per_smarrito' => 120, 'giorni_per_smarrito' => 120,
'home_limit_list' => 10, 'home_limit_list' => 10,
'assignment_link_ttl_hours' => 1,
'audit_retention_days' => 730, 'audit_retention_days' => 730,
]); ]);
return static::$cachedInstance;
} }
public static function isSetupComplete(): bool public static function isSetupComplete(): bool

View File

@@ -107,11 +107,11 @@ class Territorio extends Model
$ultima = $this->ultimaAssegnazione; $ultima = $this->ultimaAssegnazione;
if ($ultima && $ultima->returned_at) { if ($ultima && $ultima->returned_at) {
return Carbon::parse($ultima->returned_at)->diffInDays(now()); return Carbon::parse($ultima->returned_at)->startOfDay()->diffInDays(today());
} }
if (!$ultima) { if (!$ultima) {
return $this->created_at->diffInDays(now()); return $this->created_at->startOfDay()->diffInDays(today());
} }
// Currently assigned, no giacenza concept // Currently assigned, no giacenza concept

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Services;
use App\Jobs\ImportTerritoryPdfFolder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use RuntimeException;
use ZipArchive;
class TerritorioPdfImportDispatcher
{
public function __construct(
protected TerritorioPdfImportState $stateService,
) {
}
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog): array
{
$state = $this->stateService->initialize($importId, count($storedFiles));
$this->stateService->appendLog($importId, $initialLog);
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
return $this->stateService->get($importId) ?? $state;
}
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
{
$importId = (string) Str::uuid();
$zipStoredPath = $zipFile->storeAs(
'bulk-territori-imports/' . $importId,
'archivio-' . $importId . '.zip',
'local'
);
$zipAbsolutePath = storage_path('app/' . $zipStoredPath);
$zip = new ZipArchive();
if ($zip->open($zipAbsolutePath) !== true) {
throw new RuntimeException('Impossibile aprire il file ZIP.');
}
$storedFiles = [];
$entryIndex = 0;
try {
for ($index = 0; $index < $zip->numFiles; $index++) {
$entryName = $zip->getNameIndex($index);
if (! $entryName || str_ends_with($entryName, '/')) {
continue;
}
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') {
continue;
}
$content = $zip->getFromIndex($index);
if ($content === false) {
continue;
}
$originalName = basename($entryName);
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
$storedPath = 'bulk-territori-imports/' . $importId . '/zip-' . str_pad((string) $entryIndex, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf';
file_put_contents(storage_path('app/' . $storedPath), $content);
$storedFiles[] = [
'original_name' => $originalName,
'stored_path' => $storedPath,
];
$entryIndex++;
}
} finally {
$zip->close();
}
if ($storedFiles === []) {
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
}
$this->dispatchStoredFiles(
$importId,
$storedFiles,
$actorId,
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.'
);
return $importId;
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
class TerritorioPdfImportState
{
protected int $ttlSeconds = 86400;
public function initialize(string $importId, int $totalFiles): array
{
$state = [
'id' => $importId,
'status' => 'queued',
'stats' => [
'total' => $totalFiles,
'processed' => 0,
'updated' => 0,
'skipped' => 0,
'errors' => 0,
],
'logs' => [
'Import creato. In attesa del worker di coda.',
],
'issues' => [],
'started_at' => null,
'finished_at' => null,
];
$this->put($importId, $state);
return $state;
}
public function get(string $importId): ?array
{
return Cache::get($this->key($importId));
}
public function put(string $importId, array $state): void
{
Cache::put($this->key($importId), $state, $this->ttlSeconds);
}
public function update(string $importId, callable $callback): ?array
{
$state = $this->get($importId);
if (! $state) {
return null;
}
$updatedState = $callback($state) ?? $state;
$this->put($importId, $updatedState);
return $updatedState;
}
public function appendLog(string $importId, string $message): void
{
$this->update($importId, function (array $state) use ($message) {
$timestamp = now()->format('H:i:s');
$state['logs'][] = '[' . $timestamp . '] ' . $message;
return $state;
});
}
public function increment(string $importId, string $metric, int $amount = 1): void
{
$this->update($importId, function (array $state) use ($metric, $amount) {
$state['stats'][$metric] = ($state['stats'][$metric] ?? 0) + $amount;
return $state;
});
}
public function addIssue(string $importId, array $issue): void
{
$this->update($importId, function (array $state) use ($issue) {
$state['issues'][] = $issue;
return $state;
});
}
public function markRunning(string $importId): void
{
$this->update($importId, function (array $state) {
$state['status'] = 'running';
$state['started_at'] = now()->toDateTimeString();
return $state;
});
}
public function markCompleted(string $importId): void
{
$this->update($importId, function (array $state) {
$state['status'] = 'completed';
$state['finished_at'] = now()->toDateTimeString();
return $state;
});
}
public function markFailed(string $importId): void
{
$this->update($importId, function (array $state) {
$state['status'] = 'failed';
$state['finished_at'] = now()->toDateTimeString();
return $state;
});
}
protected function key(string $importId): string
{
return 'territori-pdf-import:' . $importId;
}
}

47
config/livewire.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
return [
'class_namespace' => 'App\\Livewire',
'view_path' => resource_path('views/livewire'),
'layout' => 'components.layouts.app',
'lazy_placeholder' => null,
'temporary_file_upload' => [
'disk' => null,
'rules' => ['required', 'file', 'max:256000'],
'directory' => null,
'middleware' => null,
'preview_mimes' => [
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 15,
'cleanup' => true,
],
'render_on_redirect' => false,
'legacy_model_binding' => false,
'inject_assets' => true,
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#2299dd',
],
'inject_morph_markers' => true,
'smart_wire_keys' => false,
'pagination_theme' => 'tailwind',
'release_token' => env('APP_VERSION'),
'inject_assets_after_styles' => false,
];

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->unsignedInteger('assignment_link_ttl_hours')->default(24)->after('home_limit_list');
});
}
public function down(): void
{
Schema::table('settings', function (Blueprint $table) {
$table->dropColumn('assignment_link_ttl_hours');
});
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->string('pdf_access_code', 32)->nullable()->unique()->after('campaign_id');
});
}
public function down(): void
{
Schema::table('assegnazioni', function (Blueprint $table) {
$table->dropUnique(['pdf_access_code']);
$table->dropColumn('pdf_access_code');
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('settings')
->whereNotNull('assignment_link_ttl_hours')
->update([
'assignment_link_ttl_hours' => DB::raw('GREATEST(1, CEIL(assignment_link_ttl_hours / 720))'),
]);
}
public function down(): void
{
DB::table('settings')
->whereNotNull('assignment_link_ttl_hours')
->update([
'assignment_link_ttl_hours' => DB::raw('assignment_link_ttl_hours * 720'),
]);
}
};

View File

@@ -47,6 +47,33 @@ services:
app: app:
condition: service_healthy condition: service_healthy
queue-worker:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: termanager2_queue
restart: unless-stopped
user: "1000:1000"
working_dir: /var/www/html
volumes:
- ./:/var/www/html
networks:
- termanager2
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
command: ["php", "artisan", "queue:work", "redis", "--queue=default", "--sleep=1", "--tries=1", "--timeout=0"]
environment:
- PHP_OPCACHE_VALIDATE_TIMESTAMPS=1
- SEED_DEV_DATA=${SEED_DEV_DATA:-false}
- RUN_DB_SEED_ON_FIRST_START=${RUN_DB_SEED_ON_FIRST_START:-true}
- ENSURE_INITIAL_ADMIN_ON_EMPTY_DB=${ENSURE_INITIAL_ADMIN_ON_EMPTY_DB:-true}
- INITIAL_ADMIN_NAME=${INITIAL_ADMIN_NAME:-}
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-}
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-}
mariadb: mariadb:
image: mariadb:11 image: mariadb:11
container_name: termanager2_db container_name: termanager2_db

View File

@@ -1,6 +1,6 @@
[PHP] [PHP]
upload_max_filesize = 64M upload_max_filesize = 256M
post_max_size = 64M post_max_size = 256M
memory_limit = 256M memory_limit = 256M
max_execution_time = 120 max_execution_time = 120
max_input_vars = 3000 max_input_vars = 3000

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PDF territorio {{ $assignment->territorio?->numero }}</title>
<style>
html, body {
margin: 0;
height: 100%;
background: #111827;
}
.viewer {
width: 100%;
height: 100%;
border: 0;
display: block;
background: #111827;
}
.fallback {
position: fixed;
right: 12px;
bottom: 12px;
z-index: 10;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 14px;
border-radius: 999px;
background: rgba(17, 24, 39, 0.9);
color: #fff;
text-decoration: none;
font: 600 14px/1 system-ui, sans-serif;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
</style>
</head>
<body>
<iframe class="viewer" src="{{ $pdfUrl }}#toolbar=0&navpanes=0&scrollbar=0" title="PDF territorio {{ $assignment->territorio?->numero }}"></iframe>
<a class="fallback" href="{{ $pdfUrl }}" target="_blank" rel="noopener noreferrer">Apri PDF</a>
</body>
</html>

View File

@@ -140,7 +140,7 @@
<a href="{{ route('xml.exchange') }}" <a href="{{ route('xml.exchange') }}"
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('xml.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}"> class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('xml.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
<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="M12 16v4m0 0l-3-3m3 3l3-3M4 12a8 8 0 1116 0v1a3 3 0 01-3 3h-1M7 16H6a2 2 0 01-2-2v-2"/></svg> <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="M12 16v4m0 0l-3-3m3 3l3-3M4 12a8 8 0 1116 0v1a3 3 0 01-3 3h-1M7 16H6a2 2 0 01-2-2v-2"/></svg>
XML Exchange Import
</a> </a>
</div> </div>
@endcan @endcan

View File

@@ -5,7 +5,7 @@
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna ai territori</a> <a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800">← Torna ai territori</a>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 max-w-2xl">
<form wire:submit="save" class="space-y-4"> <form wire:submit="save" class="space-y-4">
<div> <div>
@if(!$preselectedTerritorioId) @if(!$preselectedTerritorioId)
@@ -31,14 +31,17 @@
@if($territorio_id) @if($territorio_id)
<div class="mt-3"> <div class="mt-3">
<p class="text-xs text-gray-500 mb-1">Anteprima territorio</p> <p class="text-xs text-gray-500 mb-2">Anteprima territorio</p>
@if($this->selectedThumbnailUrl) @if($this->selectedThumbnailUrl)
<img src="{{ $this->selectedThumbnailUrl }}" <div class="overflow-hidden rounded-xl border border-gray-200 bg-gray-50 shadow-sm">
alt="Anteprima territorio" <img src="{{ $this->selectedThumbnailUrl }}"
class="rounded-lg border border-gray-200 shadow-sm max-h-64 w-auto"> alt="Thumbnail territorio selezionato"
class="block w-full h-auto max-h-[70vh] object-contain bg-white">
</div>
<p class="mt-2 text-xs text-gray-500">Miniatura del territorio ottimizzata per consultazione rapida anche da mobile.</p>
@else @else
<div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500"> <div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500">
Nessun PDF disponibile per questo territorio. Nessuna thumbnail disponibile per questo territorio.
</div> </div>
@endif @endif
</div> </div>
@@ -62,7 +65,7 @@
@error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('assigned_at') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </div>
<div class="flex items-center gap-3 pt-4"> <div class="flex flex-col-reverse gap-3 pt-4 sm:flex-row sm:items-center">
<button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Assegna</button> <button type="submit" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Assegna</button>
<a href="{{ route('territori.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a> <a href="{{ route('territori.index') }}" class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition">Annulla</a>
</div> </div>

View File

@@ -46,18 +46,29 @@
</div> </div>
{{-- Quick lists --}} {{-- Quick lists --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Da assegnare --}} {{-- Da assegnare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-green-50 border-b border-green-100"> <div class="px-4 py-3 bg-green-50 border-b border-green-100">
<h3 class="text-sm font-semibold text-green-800">Da Assegnare</h3> <h3 class="text-sm font-semibold text-green-800">Da Assegnare</h3>
<p class="mt-1 text-xs text-green-700">Prima i prioritari, poi i territori con piu tempo in reparto</p>
</div> </div>
<ul class="divide-y divide-gray-100"> <ul class="divide-y divide-gray-100">
@forelse($daAssegnare as $t) @forelse($territoriDaAssegnare as $t)
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50"> <li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
<div> <div>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a> <div class="flex items-center gap-2">
<p class="text-xs text-gray-500">{{ $t->zona?->nome }} {{ $t->tipologia?->nome }}</p> <a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a>
@if($t->home_is_prioritario)
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800">Prioritario</span>
@endif
</div>
<p class="text-xs text-gray-500">
{{ $t->zona?->nome }} {{ $t->tipologia?->nome }}
@if($t->home_giorni_giacenza > 0)
in reparto da {{ $t->home_giorni_giacenza }} giorni
@endif
</p>
</div> </div>
@can('territori.assign') @can('territori.assign')
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="text-xs font-medium text-green-600 hover:text-green-800">Assegna </a> <a href="{{ route('assegnazioni.assegna', ['territorioId' => $t->id]) }}" class="text-xs font-medium text-green-600 hover:text-green-800">Assegna </a>
@@ -67,45 +78,13 @@
<li class="px-4 py-4 text-sm text-gray-400 text-center">Tutti assegnati</li> <li class="px-4 py-4 text-sm text-gray-400 text-center">Tutti assegnati</li>
@endforelse @endforelse
</ul> </ul>
@if($daAssegnare->count() >= 10) @if($territoriDaAssegnare->count() >= $homeLimit)
<div class="px-4 py-2 bg-gray-50 border-t text-center"> <div class="px-4 py-2 bg-gray-50 border-t text-center">
<a href="{{ route('territori.index') }}?filtroStato=in_reparto" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a> <a href="{{ route('territori.index') }}?filtroStato=in_reparto" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a>
</div> </div>
@endif @endif
</div> </div>
{{-- Prioritari --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-amber-50 border-b border-amber-100">
<h3 class="text-sm font-semibold text-amber-800"> Prioritari</h3>
</div>
<ul class="divide-y divide-gray-100">
@forelse($prioritari as $t)
<li class="px-4 py-2.5 flex items-center justify-between hover:bg-gray-50">
<div>
<a href="{{ route('territori.show', $t) }}" class="text-sm font-semibold text-gray-900 hover:text-indigo-600"> {{ $t->numero }}</a>
<p class="text-xs text-gray-500">
{{ $t->zona?->nome }}
@if($t->ultimaAssegnazione?->returned_at)
ultimo rientro {{ $t->ultimaAssegnazione->returned_at->diffForHumans() }}
@endif
</p>
</div>
<span class="text-xs font-medium text-amber-600">
{{ $t->prioritario ? 'Man' : 'Auto' }}
</span>
</li>
@empty
<li class="px-4 py-4 text-sm text-gray-400 text-center">Nessun territorio prioritario</li>
@endforelse
</ul>
@if($prioritari->count() >= 10)
<div class="px-4 py-2 bg-gray-50 border-t text-center">
<a href="{{ route('territori.index') }}?filtroStato=prioritari" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a>
</div>
@endif
</div>
{{-- Da rientrare --}} {{-- Da rientrare --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-4 py-3 bg-red-50 border-b border-red-100"> <div class="px-4 py-3 bg-red-50 border-b border-red-100">
@@ -132,7 +111,7 @@
<li class="px-4 py-4 text-sm text-gray-400 text-center">Nessun territorio da rientrare</li> <li class="px-4 py-4 text-sm text-gray-400 text-center">Nessun territorio da rientrare</li>
@endforelse @endforelse
</ul> </ul>
@if($daRientrare->count() >= 10) @if($daRientrare->count() >= $homeLimit)
<div class="px-4 py-2 bg-gray-50 border-t text-center"> <div class="px-4 py-2 bg-gray-50 border-t text-center">
<a href="{{ route('territori.index') }}?filtroStato=da_rientrare" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a> <a href="{{ route('territori.index') }}?filtroStato=da_rientrare" class="text-xs text-indigo-600 hover:text-indigo-800">Vedi tutti </a>
</div> </div>

View File

@@ -65,9 +65,19 @@
<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-gray-50"> <tr class="hover:bg-gray-50">
@php($temporaryPdfUrl = !$a->returned_at ? $a->temporaryPdfViewerUrl() : null)
<td class="px-3 py-2"> <td class="px-3 py-2">
<a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-medium"> {{ $a->territorio?->numero }}</a> <div class="flex items-center gap-2">
<span class="text-xs text-gray-400 ml-1">{{ $a->territorio?->zona?->nome }}</span> @if($a->territorio?->thumbnail_path)
<img src="{{ asset('storage/' . $a->territorio->thumbnail_path) }}"
alt="Thumbnail territorio {{ $a->territorio?->numero }}"
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none">
@endif
<div class="min-w-0">
<a href="{{ route('territori.show', $a->territorio_id) }}" class="text-indigo-600 hover:underline font-medium"> {{ $a->territorio?->numero }}</a>
<span class="text-xs text-gray-400 ml-1">{{ $a->territorio?->zona?->nome }}</span>
</div>
</div>
</td> </td>
<td class="px-3 py-2">{{ $a->proclamatore?->nome_completo ?? 'N/A' }}</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">{{ $a->assigned_at->format('d/m/Y') }}</td>
@@ -89,6 +99,30 @@
</td> </td>
@can('settings.manage') @can('settings.manage')
<td class="px-3 py-2 whitespace-nowrap"> <td class="px-3 py-2 whitespace-nowrap">
@can('territori.assign')
@if($a->territorio?->attivo && !$a->territorio?->assegnazioneCorrente)
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $a->territorio_id]) }}"
class="inline-block text-xs font-medium text-emerald-600 hover:text-emerald-800 mr-3">
Assegna
</a>
@endif
@endcan
@can('territori.return')
@if(!$a->returned_at)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $a->id]) }}"
class="inline-block text-xs font-medium text-red-600 hover:text-red-800 mr-3">
Rientra
</a>
@endif
@endcan
@if($temporaryPdfUrl)
<a href="{{ $temporaryPdfUrl }}"
target="_blank"
rel="noopener noreferrer"
class="inline-block text-xs font-medium text-indigo-600 hover:text-indigo-800 mr-3">
PDF
</a>
@endif
<button wire:click="openEdit({{ $a->id }})" <button wire:click="openEdit({{ $a->id }})"
style="background:#e0e7ff;color:#4338ca;border:none;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;margin-right:4px;"> style="background:#e0e7ff;color:#4338ca;border:none;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;margin-right:4px;">
Modifica Modifica
@@ -102,7 +136,7 @@
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500">Nessuna assegnazione trovata.</td> <td colspan="8" class="px-4 py-8 text-center text-gray-500">Nessuna assegnazione trovata.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>

View File

@@ -4,16 +4,13 @@
</div> </div>
<div class="mb-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4 max-w-lg"> <div class="mb-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4 max-w-lg">
<p class="text-sm font-medium text-gray-700 mb-3">Import / Export dati XML</p> <p class="text-sm font-medium text-gray-700 mb-3">Sezione Import</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<a href="{{ route('xml.exchange') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-amber-600 rounded-lg hover:bg-amber-700 transition"> <a href="{{ route('xml.exchange') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">
Import XML Apri strumenti di import
</a>
<a href="{{ route('xml.exchange') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition">
Export XML
</a> </a>
</div> </div>
<p class="text-xs text-gray-500 mt-2">I pulsanti aprono la sezione XML Exchange con gli strumenti di conversione, import e export.</p> <p class="text-xs text-gray-500 mt-2">Qui trovi import PDF territori, conversione legacy SQL, import XML ed export XML.</p>
</div> </div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
@@ -59,6 +56,13 @@
@error('home_limit_list') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror @error('home_limit_list') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div> </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> <div>
<label for="audit_retention_days" class="block text-sm font-medium text-gray-700">Conservazione Audit (giorni)</label> <label for="audit_retention_days" class="block text-sm font-medium text-gray-700">Conservazione Audit (giorni)</label>
<p class="text-xs text-gray-500 mb-1">I log più vecchi di questo periodo verranno cancellati automaticamente.</p> <p class="text-xs text-gray-500 mb-1">I log più vecchi di questo periodo verranno cancellati automaticamente.</p>

View File

@@ -1,7 +1,245 @@
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900">Import/Export XML</h1> <h1 class="text-2xl font-bold text-gray-900">Import</h1>
<p class="text-sm text-gray-500 mt-1">Converti dump SQL legacy in XML, importa XML nell'app ed esporta i dati correnti in XML.</p> <p class="text-sm text-gray-500 mt-1">Centro importazioni: PDF territori, conversione legacy SQL, import XML ed export XML.</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
x-data="{
uploadLog: '',
uploadProgress: 0,
uploading: false,
zipUploading: false,
zipProgress: 0,
selectedFiles: 0,
selectedZip: '',
append(message) {
this.uploadLog = this.uploadLog ? this.uploadLog + '\n' + message : message;
},
submitZip(event) {
const form = event.target;
const formData = new FormData(form);
this.uploadLog = '';
this.zipUploading = true;
this.zipProgress = 0;
this.append('Upload ZIP diretto al server avviato...');
const xhr = new XMLHttpRequest();
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.addEventListener('progress', (uploadEvent) => {
if (!uploadEvent.lengthComputable) {
return;
}
this.zipProgress = Math.round((uploadEvent.loaded / uploadEvent.total) * 100);
});
xhr.addEventListener('load', () => {
this.zipUploading = false;
let payload = {};
try {
payload = JSON.parse(xhr.responseText || '{}');
} catch (error) {
payload = {};
}
if (xhr.status >= 200 && xhr.status < 300) {
this.zipProgress = 100;
this.append('Archivio ricevuto. Reindirizzamento alla console import...');
window.location = payload.redirect_url || window.location.href;
return;
}
const message = payload.message || (payload.errors && payload.errors.pdfZip ? payload.errors.pdfZip[0] : 'Errore durante il caricamento dello ZIP.');
this.append(message);
});
xhr.addEventListener('error', () => {
this.zipUploading = false;
this.append('Errore di rete durante il caricamento dello ZIP.');
});
xhr.send(formData);
}
}"
x-on:livewire-upload-start="if ($event.detail.id === 'pdfFolder') { uploading = true; uploadProgress = 0; append('Upload cartella avviato...'); }"
x-on:livewire-upload-progress="if ($event.detail.id === 'pdfFolder') { uploadProgress = $event.detail.progress; append('Upload Livewire in corso: ' + $event.detail.progress + '%'); }"
x-on:livewire-upload-finish="if ($event.detail.id === 'pdfFolder') { uploading = false; uploadProgress = 100; append('Upload completato. Avvio preparazione import lato server...'); }"
x-on:livewire-upload-error="if ($event.detail.id === 'pdfFolder') { uploading = false; append('Errore durante l\'upload temporaneo dei file.'); }">
<h2 class="text-lg font-semibold text-gray-900">Import PDF territori</h2>
<p class="text-xs text-gray-500">Puoi importare una cartella di PDF oppure, meglio per archivi grandi, un file ZIP contenente i PDF. Il nome file puo variare: basta che contenga il numero di un territorio gia presente nell'app. I PDF verranno associati ai territori esistenti e verra generata anche la thumbnail.</p>
<form wire:submit.prevent="importTerritoryPdfFolder" class="space-y-4">
<div>
<input wire:model="pdfFolder"
x-on:change="selectedFiles = $event.target.files.length; uploadLog = ''; if (selectedFiles > 0) { append('Cartella selezionata: ' + selectedFiles + ' file.'); append('In attesa dell\'upload temporaneo Livewire...'); }"
type="file"
multiple
webkitdirectory
directory
accept=".pdf,application/pdf"
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('pdfFolder') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@error('pdfFolder.*') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div x-show="selectedFiles > 0 || uploading" x-cloak class="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-indigo-800">
<span x-text="uploading ? 'Upload file in corso...' : 'Upload file pronto'"></span>
<span x-text="selectedFiles > 0 ? selectedFiles + ' file selezionati' : ''"></span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-indigo-100">
<div class="h-full rounded-full bg-indigo-600 transition-all duration-300" :style="'width:' + uploadProgress + '%' "></div>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
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;"
wire:loading.attr="disabled"
@disabled(empty($pdfFolder))
>
Importa PDF territori
</button>
<div wire:loading wire:target="importTerritoryPdfFolder" class="text-sm text-indigo-700">Preparazione import in corso...</div>
</div>
</form>
<div class="border-t border-gray-200 pt-4">
<h3 class="text-sm font-semibold text-gray-900">Import da archivio ZIP</h3>
<p class="mt-1 text-xs text-gray-500">Consigliato per grandi volumi: carichi un solo file e il server estrae automaticamente tutti i PDF.</p>
<form action="{{ route('imports.territori.pdf-zip') }}" method="POST" enctype="multipart/form-data" class="mt-3 space-y-4" @submit.prevent="submitZip">
@csrf
<div>
<input name="pdfZip"
x-on:change="selectedZip = $event.target.files[0] ? $event.target.files[0].name : ''; uploadLog = ''; zipProgress = 0; if (selectedZip) { append('Archivio ZIP selezionato: ' + selectedZip); append('Pronto per upload diretto al server.'); }"
type="file"
accept=".zip,application/zip"
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-emerald-50 file:text-emerald-700 hover:file:bg-emerald-100">
@error('pdfZip') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div x-show="selectedZip || zipUploading" x-cloak class="rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-3">
<div class="flex items-center justify-between gap-3 text-xs text-emerald-800">
<span x-text="zipUploading ? 'Upload ZIP in corso...' : 'ZIP pronto per l\'invio' "></span>
<span x-text="selectedZip"></span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-emerald-100">
<div class="h-full rounded-full bg-emerald-600 transition-all duration-300" :style="'width:' + zipProgress + '%' "></div>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition border border-emerald-700"
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;"
x-bind:disabled="!selectedZip || zipUploading"
>
Importa ZIP PDF
</button>
<div x-show="zipUploading" x-cloak class="text-sm text-emerald-700">Caricamento ZIP diretto in corso...</div>
</div>
</form>
</div>
<div x-show="uploadLog || uploading || zipUploading || selectedZip" x-cloak>
<div class="mb-2 text-sm font-medium text-gray-800">Console upload</div>
<textarea readonly rows="8" x-bind:value="uploadLog" class="block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-3 font-mono text-xs leading-5 text-gray-100 focus:border-indigo-500 focus:ring-indigo-500"></textarea>
</div>
@if($currentPdfImportId && (!empty($pdfImportStats) || !empty($pdfImportLogs)))
<div class="rounded-xl border border-indigo-200 bg-indigo-50/40 p-4" @if(in_array($pdfImportStatus, ['queued', 'running'], true)) wire:poll.1000ms="refreshPdfImportStatus" @endif>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-sm font-medium text-gray-800">Stato import PDF</div>
<div class="mt-1 text-xs text-gray-500">ID import: {{ $currentPdfImportId }}</div>
</div>
<span class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold
{{ match($pdfImportStatus) {
'queued' => 'bg-amber-100 text-amber-800',
'running' => 'bg-blue-100 text-blue-800',
'completed' => 'bg-green-100 text-green-800',
'failed' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-700',
} }}">
{{ match($pdfImportStatus) {
'queued' => 'In coda',
'running' => 'In esecuzione',
'completed' => 'Completato',
'failed' => 'Fallito',
default => 'Inattivo',
} }}
</span>
</div>
@if(!empty($pdfImportStats))
<div class="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div class="rounded-lg bg-white px-3 py-2 border border-gray-200">
<div class="text-xs text-gray-500">Processati</div>
<div class="font-semibold text-gray-900">{{ $pdfImportStats['processed'] ?? 0 }} / {{ $pdfImportStats['total'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-green-50 px-3 py-2 border border-green-100">
<div class="text-xs text-green-700">Aggiornati</div>
<div class="font-semibold text-green-900">{{ $pdfImportStats['updated'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-amber-50 px-3 py-2 border border-amber-100">
<div class="text-xs text-amber-700">Saltati</div>
<div class="font-semibold text-amber-900">{{ $pdfImportStats['skipped'] ?? 0 }}</div>
</div>
<div class="rounded-lg bg-red-50 px-3 py-2 border border-red-100">
<div class="text-xs text-red-700">Errori</div>
<div class="font-semibold text-red-900">{{ $pdfImportStats['errors'] ?? 0 }}</div>
</div>
</div>
@endif
<div class="mt-4">
<div class="mb-2 flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-800">Log import PDF</div>
<button type="button" wire:click="refreshPdfImportStatus" class="inline-flex items-center justify-center rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-white transition">
Aggiorna log
</button>
</div>
<textarea readonly rows="12" class="block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-3 font-mono text-xs leading-5 text-gray-100 focus:border-indigo-500 focus:ring-indigo-500 sm:rows-14">{{ $pdfImportLogText }}</textarea>
</div>
@if(!empty($pdfImportIssues))
<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">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">File</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Motivo</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500">Territori rilevati</th>
</tr>
</thead>
<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>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
</div>
@endif
</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">

View File

@@ -12,8 +12,8 @@
{{-- Filters --}} {{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7 gap-3">
<input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca numero..." <input wire:model.live.debounce.300ms="search" type="text" placeholder="Cerca numero, zona, tipologia, note..."
class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500"> class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<select wire:model.live="filterZona" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500"> <select wire:model.live="filterZona" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Tutte le zone</option> <option value="">Tutte le zone</option>
@@ -30,10 +30,46 @@
<select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500"> <select wire:model.live="filterStato" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Tutti gli stati</option> <option value="">Tutti gli stati</option>
<option value="in_reparto">In reparto</option> <option value="in_reparto">In reparto</option>
<option value="prioritari">Prioritari</option>
<option value="assegnato">Assegnato</option> <option value="assegnato">Assegnato</option>
<option value="da_rientrare">Da rientrare</option> <option value="da_rientrare">Da rientrare</option>
<option value="inattivo">Inattivo</option> <option value="inattivo">Inattivo</option>
</select> </select>
<select wire:model.live="filterPriorita" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Tutte le priorita</option>
<option value="prioritari">Solo prioritari</option>
<option value="manuali">Prioritari manuali</option>
<option value="automatici">Prioritari automatici</option>
<option value="non_prioritari">Solo non prioritari</option>
</select>
<select wire:model.live="filterPdf" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">PDF e thumbnail</option>
<option value="con_pdf">Con PDF</option>
<option value="senza_pdf">Senza PDF</option>
<option value="con_thumbnail">Con thumbnail</option>
<option value="senza_thumbnail">Senza thumbnail</option>
</select>
<select wire:model.live="filterContenuti" class="rounded-lg border-gray-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<option value="">Note e confini</option>
<option value="con_note">Con note</option>
<option value="senza_note">Senza note</option>
<option value="con_confini">Con confini</option>
<option value="senza_confini">Senza confini</option>
</select>
</div>
<div class="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-gray-500">
@if($usesPriorityOrdering)
Ordinamento attivo: prioritari prima, poi territori con piu tempo in reparto.
@else
Ordinamento predefinito: numero territorio dal piu piccolo al piu grande.
@endif
</p>
<button wire:click="clearFilters"
type="button"
class="inline-flex items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition">
Azzera filtri
</button>
</div> </div>
</div> </div>
@@ -57,7 +93,14 @@
@forelse($territori as $territorio) @forelse($territori as $territorio)
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-semibold text-gray-900"> <td class="px-4 py-3 text-sm font-semibold text-gray-900">
<a href="{{ route('territori.show', $territorio) }}" class="hover:text-indigo-600">{{ $territorio->numero }}</a> <div class="flex items-center gap-2">
@if($territorio->thumbnail_path)
<img src="{{ asset('storage/' . $territorio->thumbnail_path) }}"
alt="Thumbnail territorio {{ $territorio->numero }}"
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none">
@endif
<a href="{{ route('territori.show', $territorio) }}" class="hover:text-indigo-600">{{ $territorio->numero }}</a>
</div>
</td> </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->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 text-gray-600">{{ $territorio->tipologia?->nome ?? '-' }}</td>
@@ -85,6 +128,16 @@
</td> </td>
<td class="px-4 py-3 text-sm text-right space-x-1"> <td class="px-4 py-3 text-sm text-right space-x-1">
<a href="{{ route('territori.show', $territorio) }}" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium">Dettaglio</a> <a href="{{ route('territori.show', $territorio) }}" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium">Dettaglio</a>
@can('territori.assign')
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="text-emerald-600 hover:text-emerald-800 text-xs font-medium">Assegna</a>
@endif
@endcan
@can('territori.return')
@if($territorio->assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="text-red-600 hover:text-red-800 text-xs font-medium">Rientra</a>
@endif
@endcan
<a href="{{ route('territori.edit', $territorio) }}" class="text-gray-600 hover:text-gray-800 text-xs font-medium">Modifica</a> <a href="{{ route('territori.edit', $territorio) }}" class="text-gray-600 hover:text-gray-800 text-xs font-medium">Modifica</a>
<button wire:click="toggleActive({{ $territorio->id }})" class="text-xs font-medium {{ $territorio->attivo ? 'text-amber-600 hover:text-amber-800' : 'text-green-600 hover:text-green-800' }}"> <button wire:click="toggleActive({{ $territorio->id }})" class="text-xs font-medium {{ $territorio->attivo ? 'text-amber-600 hover:text-amber-800' : 'text-green-600 hover:text-green-800' }}">
{{ $territorio->attivo ? 'Disattiva' : 'Attiva' }} {{ $territorio->attivo ? 'Disattiva' : 'Attiva' }}

View File

@@ -4,7 +4,19 @@
<h1 class="text-2xl font-bold text-gray-900">Territorio {{ $territorio->numero }}</h1> <h1 class="text-2xl font-bold text-gray-900">Territorio {{ $territorio->numero }}</h1>
<a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a> <a href="{{ route('territori.index') }}" class="text-sm text-indigo-600 hover:text-indigo-800"> Torna alla lista</a>
</div> </div>
<a href="{{ route('territori.edit', $territorio) }}" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Modifica</a> <div class="flex items-center gap-2">
@can('territori.assign')
@if(!$territorio->assegnazioneCorrente && $territorio->attivo)
<a href="{{ route('assegnazioni.assegna', ['territorioId' => $territorio->id]) }}" class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition">Assegna</a>
@endif
@endcan
@can('territori.return')
@if($territorio->assegnazioneCorrente)
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $territorio->assegnazioneCorrente->id]) }}" class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition">Rientra</a>
@endif
@endcan
<a href="{{ route('territori.edit', $territorio) }}" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition">Modifica</a>
</div>
</div> </div>
{{-- Info card --}} {{-- Info card --}}
@@ -56,6 +68,48 @@
@endif @endif
</div> </div>
@if($activeAssignment)
@php($temporaryPdfUrl = $activeAssignment->temporaryPdfViewerUrl())
<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>
<h2 class="text-lg font-semibold text-gray-900">Assegnazione attiva</h2>
<p class="mt-1 text-sm text-gray-600">
{{ $activeAssignment->proclamatore?->nome_completo ?? 'N/A' }}
assegnato il {{ $activeAssignment->assigned_at->format('d/m/Y') }}
{{ $activeAssignment->giorni }} giorni
</p>
</div>
<div class="flex flex-wrap gap-2">
@can('territori.return')
<a href="{{ route('assegnazioni.rientra', ['assegnazione' => $activeAssignment->id]) }}" class="inline-flex items-center rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition">Rientra</a>
@endcan
</div>
</div>
@if($temporaryPdfUrl)
<div class="mt-4 rounded-xl border border-indigo-100 bg-indigo-50/70 p-4">
<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>
<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>
<div class="mt-3 flex flex-col gap-2 sm:flex-row">
<input x-ref="assignmentPdfLink" type="text" readonly value="{{ $temporaryPdfUrl }}" class="block w-full rounded-lg border border-indigo-200 bg-white px-3 py-2 text-sm text-gray-700 focus:border-indigo-500 focus:ring-indigo-500">
<button type="button" @click="navigator.clipboard.writeText($refs.assignmentPdfLink.value); copied = true; setTimeout(() => copied = false, 1800);" class="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition">Copia link</button>
</div>
<p x-show="copied" x-cloak class="mt-2 text-xs font-medium text-green-700">Link copiato.</p>
</div>
@elseif($territorio->pdf_path)
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Il link PDF temporaneo è disponibile solo per assegnazioni attive con PDF associato.
</div>
@endif
</div>
@endif
{{-- PDF viewer --}} {{-- PDF viewer --}}
@if($territorio->pdf_path) @if($territorio->pdf_path)
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">

View File

@@ -1,6 +1,8 @@
<?php <?php
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Settings\TerritoryPdfImportController;
use App\Http\Controllers\AssignmentPdfController;
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;
@@ -46,6 +48,11 @@ Route::post('logout', function () {
return redirect('/login'); return redirect('/login');
})->middleware('auth')->name('logout'); })->middleware('auth')->name('logout');
Route::get('assegnazioni/pdf/{assignment}/{code}', [AssignmentPdfController::class, 'viewer'])
->name('assignments.pdf.viewer');
Route::get('assegnazioni/pdf/{assignment}/{code}/file', [AssignmentPdfController::class, 'file'])
->name('assignments.pdf.file');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Authenticated Routes | Authenticated Routes
@@ -107,6 +114,7 @@ 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('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
}); });
// Privacy / Informativa GDPR // Privacy / Informativa GDPR

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB