netgescon-day0/app/Filament/Pages/Supporto/TicketGestione.php

1643 lines
62 KiB
PHP

<?php
namespace App\Filament\Pages\Supporto;
use App\Models\CategoriaTicket;
use App\Models\Documento;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\InsuranceClaim;
use App\Models\InsurancePolicy;
use App\Models\Ticket;
use App\Models\TicketAttachment;
use App\Models\TicketIntervento;
use App\Models\User;
use App\Services\Documenti\ImageTextExtractionService;
use App\Services\Documenti\PdfTextExtractionService;
use App\Services\Support\TicketAttachmentUploadService;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\WithFileUploads;
use UnitEnum;
class TicketGestione extends Page
{
use WithFileUploads;
/** @var array<int,string>|null */
private ?array $documentiColumnsCache = null;
protected static ?string $navigationLabel = 'Ticket Gestione';
protected static ?string $title = 'Gestione Ticket';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static UnitEnum|string|null $navigationGroup = 'Supporto';
protected static ?int $navigationSort = 26;
protected static ?string $slug = 'supporto/ticket-gestione';
protected string $view = 'filament.pages.supporto.ticket-gestione';
public string $status = 'all';
public string $activeTab = 'elenco';
public ?int $selectedTicketId = null;
public ?string $notaInterna = null;
public ?int $fornitoreId = null;
public string $fornitoreSearch = '';
public ?string $noteAssegnazione = null;
public ?string $insurancePolicyReference = null;
public ?int $insurancePolicyId = null;
public ?string $insuranceClaimNumber = null;
public ?string $insuranceStatus = null;
public ?string $insuranceTakenInChargeAt = null;
public ?string $insuranceEmailSentAt = null;
public ?string $insuranceInsurerContactedAt = null;
public ?string $insuranceDocumentsSentAt = null;
public ?string $insuranceAppointmentAt = null;
public ?string $insuranceClosedAt = null;
public ?string $insuranceNextAction = null;
public ?string $insuranceNotes = null;
/** @var array<int,mixed> */
public array $nuoviAllegati = [];
/** @var array<int,mixed> */
public array $nuoveFoto = [];
public ?string $descrizioneAllegati = null;
public ?string $lastGeneratedPassword = null;
public ?int $selectedCategoriaId = null;
public ?string $categoriaNome = null;
public ?string $categoriaDescrizione = null;
/** @var array<int,array{id:int,nome:string,descrizione:string}> */
public array $categorieOptions = [];
/** @var array<int,array<string,mixed>> */
public array $fornitoreMatches = [];
/** @var array<int,array<string,mixed>> */
public array $fornitoriAttiviRows = [];
/** @var array<string,mixed>|null */
public ?array $attachmentPreview = null;
/** @var array<string,mixed>|null */
public ?array $attachmentMapPreview = null;
/** @var \Illuminate\Support\Collection<int, Ticket> */
public $tickets;
/** @var array<string,int> */
public array $ticketCounters = [
'open' => 0,
'urgent' => 0,
'closed' => 0,
'all' => 0,
];
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void
{
$this->tickets = collect();
$this->selectedTicketId = (int) request()->query('ticket', 0) ?: null;
$queryTab = (string) request()->query('tab', '');
if ($queryTab === 'scheda') {
$this->activeTab = 'scheda';
} elseif ($queryTab === 'assicurazione') {
$this->activeTab = 'assicurazione';
}
if (request()->query('status') === 'all') {
$this->status = 'all';
}
$this->loadCategorieTicketOptions();
$this->refreshFornitoriAttiviRows();
if ($this->selectedTicket) {
$this->fornitoreId = (int) ($this->selectedTicket->assegnato_a_fornitore_id ?? 0) ?: null;
}
$this->refreshData();
}
public function updatedSelectedCategoriaId($value): void
{
$id = (int) $value;
if ($id <= 0) {
$this->categoriaNome = null;
$this->categoriaDescrizione = null;
return;
}
$categoria = CategoriaTicket::query()->find($id);
if (! $categoria instanceof CategoriaTicket) {
return;
}
$this->categoriaNome = (string) $categoria->nome;
$this->categoriaDescrizione = (string) ($categoria->descrizione ?? '');
}
public function creaCategoriaTicket(): void
{
$this->validate([
'categoriaNome' => ['required', 'string', 'max:255', Rule::unique('categorie_ticket', 'nome')],
'categoriaDescrizione' => ['nullable', 'string', 'max:2000'],
]);
$categoria = CategoriaTicket::query()->create([
'nome' => trim((string) $this->categoriaNome),
'descrizione' => filled($this->categoriaDescrizione) ? trim((string) $this->categoriaDescrizione) : null,
]);
$this->loadCategorieTicketOptions();
$this->selectedCategoriaId = (int) $categoria->id;
$this->updatedSelectedCategoriaId($this->selectedCategoriaId);
Notification::make()->title('Categoria creata')->success()->send();
}
public function aggiornaCategoriaTicket(): void
{
$id = (int) ($this->selectedCategoriaId ?? 0);
if ($id <= 0) {
Notification::make()->title('Seleziona una categoria da modificare')->warning()->send();
return;
}
$this->validate([
'categoriaNome' => ['required', 'string', 'max:255', Rule::unique('categorie_ticket', 'nome')->ignore($id)],
'categoriaDescrizione' => ['nullable', 'string', 'max:2000'],
]);
$categoria = CategoriaTicket::query()->find($id);
if (! $categoria instanceof CategoriaTicket) {
Notification::make()->title('Categoria non trovata')->danger()->send();
return;
}
$categoria->update([
'nome' => trim((string) $this->categoriaNome),
'descrizione' => filled($this->categoriaDescrizione) ? trim((string) $this->categoriaDescrizione) : null,
]);
$this->loadCategorieTicketOptions();
Notification::make()->title('Categoria aggiornata')->success()->send();
$this->refreshData();
}
public function updatedStatus(): void
{
$this->refreshData();
}
public function updatedFornitoreSearch(): void
{
$this->searchFornitori();
}
public function refreshData(): void
{
$this->loadCounters();
$this->loadTickets();
$this->refreshFornitoriAttiviRows();
$this->syncSelectedTicketState();
}
public function getTicketInserimentoUrl(): string
{
return TicketMobile::getUrl(panel: 'admin-filament');
}
public function apriScheda(int $ticketId): void
{
$this->selectedTicketId = $ticketId;
$this->activeTab = 'scheda';
$ticket = $this->selectedTicket;
$this->fornitoreId = $ticket ? ((int) ($ticket->assegnato_a_fornitore_id ?? 0) ?: null): null;
$this->syncSelectedTicketState();
}
public function apriAssicurazioneTicket(?int $ticketId = null): void
{
if (($ticketId ?? 0) > 0) {
$this->selectedTicketId = (int) $ticketId;
}
$this->activeTab = 'assicurazione';
$this->syncSelectedTicketState();
}
public function apriElenco(): void
{
$this->activeTab = 'elenco';
}
public function getFornitoreOperativoUrl(?int $fornitoreId = null): string
{
$base = \App\Filament\Pages\Fornitore\TicketOperativi::getUrl(panel: 'admin-filament');
if ($fornitoreId) {
return $base . '?fornitore=' . $fornitoreId;
}
return $base;
}
public function getFornitoreAnagraficaUrl(int $fornitoreId): string
{
return \App\Filament\Pages\Gescon\FornitoreScheda::getUrl(['record' => $fornitoreId], panel: 'admin-filament');
}
public function getInsuranceClaimsUrl(): string
{
$parameters = [];
if ((int) ($this->selectedTicketId ?? 0) > 0) {
$parameters['ticket'] = (int) $this->selectedTicketId;
}
return GestioneAssicurazioni::getUrl($parameters, panel: 'admin-filament');
}
public function getAttachmentPublicUrl(TicketAttachment $attachment): string
{
return route('filament.tickets.attachments.view', ['attachment' => (int) $attachment->id]);
}
public function openAttachmentPreview(int $attachmentId): void
{
$ticket = $this->selectedTicket;
if (! $ticket) {
return;
}
$attachment = $ticket->attachments->firstWhere('id', $attachmentId);
if (! $attachment instanceof TicketAttachment) {
return;
}
$name = (string) ($attachment->original_file_name ?? 'allegato');
$mime = $attachment->resolvedMimeType();
$isImage = $attachment->isImage();
$isPdf = $attachment->isPdf();
$details = $this->getAttachmentDetails($attachment);
$this->attachmentPreview = [
'id' => (int) $attachment->id,
'name' => $name,
'description' => (string) ($attachment->description ?? ''),
'url' => $this->getAttachmentPublicUrl($attachment),
'is_image' => $isImage,
'is_pdf' => $isPdf,
'mime' => $mime,
'details' => $details,
];
}
public function openAttachmentMapPreview(int $attachmentId): void
{
$ticket = $this->selectedTicket;
if (! $ticket) {
return;
}
$attachment = $ticket->attachments->firstWhere('id', $attachmentId);
if (! $attachment instanceof TicketAttachment) {
return;
}
$details = $this->getAttachmentDetails($attachment);
$gps = $details['gps'] ?? [];
$lat = $gps['lat'] ?? null;
$lng = $gps['lng'] ?? null;
if (! is_numeric($lat) || ! is_numeric($lng)) {
Notification::make()->title('Coordinate GPS non disponibili per questo allegato')->warning()->send();
return;
}
$this->attachmentMapPreview = [
'attachment_id' => (int) $attachment->id,
'name' => (string) ($attachment->original_file_name ?? 'Allegato'),
'label' => (string) ($gps['label'] ?? (number_format((float) $lat, 5, '.', '') . ', ' . number_format((float) $lng, 5, '.', ''))),
'maps_url' => (string) ($details['maps_url'] ?? ('https://maps.google.com/?q=' . $lat . ',' . $lng)),
'embed_url' => (string) ($details['maps_embed_url'] ?? ('https://maps.google.com/maps?q=' . $lat . ',' . $lng . '&z=17&output=embed')),
];
}
public function closeAttachmentPreview(): void
{
$this->attachmentPreview = null;
$this->attachmentMapPreview = null;
}
public function closeAttachmentMapPreview(): void
{
$this->attachmentMapPreview = null;
}
/**
* @return array<string,mixed>
*/
public function getAttachmentDetails(TicketAttachment $attachment): array
{
$details = $this->parseAttachmentDescription((string) ($attachment->description ?? ''));
$metadata = is_array($attachment->metadata ?? null) ? $attachment->metadata : [];
$lat = $metadata['gps_decimal']['lat'] ?? null;
$lng = $metadata['gps_decimal']['lng'] ?? null;
if ($details['gps'] === null && is_numeric($lat) && is_numeric($lng)) {
$details['gps'] = [
'lat' => (float) $lat,
'lng' => (float) $lng,
'label' => number_format((float) $lat, 5, '.', '') . ', ' . number_format((float) $lng, 5, '.', ''),
];
$details['maps_embed_url'] = 'https://maps.google.com/maps?q=' . $lat . ',' . $lng . '&z=17&output=embed';
}
if ($details['maps_url'] === null && filled($metadata['google_maps_url'] ?? null)) {
$details['maps_url'] = trim((string) $metadata['google_maps_url']);
}
if ($details['exif_datetime'] === null && filled($metadata['exif_datetime'] ?? null)) {
$details['exif_datetime'] = trim((string) $metadata['exif_datetime']);
}
if ($details['device'] === null && filled($metadata['device'] ?? null)) {
$details['device'] = trim((string) $metadata['device']);
}
if ($details['file_label'] === null && filled($metadata['stored_name'] ?? null)) {
$details['file_label'] = trim((string) $metadata['stored_name']);
}
return $details;
}
public function getSelectedTicketProperty(): ?Ticket
{
if (! $this->selectedTicketId) {
return null;
}
$user = Auth::user();
if (! $user instanceof User) {
return null;
}
$stabileIds = $this->resolveTicketScopeStabileIds(true);
if ($stabileIds === []) {
return null;
}
return Ticket::query()
->with([
'stabile:id,denominazione',
'categoriaTicket:id,nome',
'assegnatoAFornitore:id,ragione_sociale,nome,cognome',
'insuranceClaim',
'attachments.user:id,name',
'messages.user:id,name',
'interventi.fornitore:id,ragione_sociale,nome,cognome',
'interventi.creatoDaUser:id,name',
])
->whereIn('stabile_id', $stabileIds)
->find($this->selectedTicketId);
}
public function assegnaFornitore(): void
{
$ticket = $this->selectedTicket;
if (! $ticket) {
Notification::make()->title('Ticket non trovato')->danger()->send();
return;
}
$this->validate([
'fornitoreId' => ['required', 'integer', 'exists:fornitori,id'],
'noteAssegnazione' => ['nullable', 'string', 'max:4000'],
]);
$ticket->update([
'assegnato_a_fornitore_id' => $this->fornitoreId,
'stato' => in_array($ticket->stato, ['Aperto'], true) ? 'Preso in Carico' : $ticket->stato,
]);
if (Schema::hasTable('ticket_interventi')) {
$ultimo = TicketIntervento::query()
->where('ticket_id', $ticket->id)
->where('fornitore_id', $this->fornitoreId)
->latest('id')
->first();
if (! $ultimo) {
TicketIntervento::query()->create([
'ticket_id' => $ticket->id,
'fornitore_id' => $this->fornitoreId,
'creato_da_user_id' => Auth::id(),
'stato' => 'assegnato',
'note_amministratore' => $this->noteAssegnazione,
]);
}
}
if (filled($this->noteAssegnazione)) {
$ticket->messages()->create([
'user_id' => Auth::id(),
'messaggio' => 'Assegnazione fornitore: ' . trim((string) $this->noteAssegnazione),
]);
}
$this->noteAssegnazione = null;
Notification::make()->title('Fornitore assegnato')->success()->send();
$this->refreshData();
}
public function selezionaFornitore(int $fornitoreId): void
{
$match = collect($this->fornitoreMatches)->firstWhere('id', $fornitoreId);
if (! is_array($match)) {
$fornitore = $this->resolveFornitoriBaseQuery()->find($fornitoreId);
if (! $fornitore instanceof Fornitore) {
return;
}
$match = $this->mapFornitoreSearchRow($fornitore);
}
$this->fornitoreId = (int) $match['id'];
$this->fornitoreSearch = (string) $match['nome'];
$this->fornitoreMatches = [];
}
public function resetFornitoreSelection(): void
{
$this->fornitoreId = null;
$this->fornitoreSearch = '';
$this->fornitoreMatches = [];
}
public function aggiungiNotaInterna(): void
{
$ticket = $this->selectedTicket;
if (! $ticket) {
Notification::make()->title('Ticket non trovato')->danger()->send();
return;
}
$this->validate([
'notaInterna' => ['required', 'string', 'max:5000'],
]);
$stamp = now()->format('d/m/Y H:i');
$ticket->messages()->create([
'user_id' => Auth::id(),
'messaggio' => '[' . $stamp . '] ' . trim((string) $this->notaInterna),
]);
$this->notaInterna = null;
// Force refresh of selected ticket relations so the new note is visible immediately.
$this->refreshData();
Notification::make()->title('Nota aggiunta')->success()->send();
}
public function salvaSinistroAssicurativo(): void
{
$ticket = $this->selectedTicket;
if (! $ticket) {
Notification::make()->title('Ticket non trovato')->danger()->send();
return;
}
$this->validate([
'insurancePolicyId' => ['nullable', 'integer', 'exists:insurance_policies,id'],
'insurancePolicyReference' => ['nullable', 'string', 'max:255'],
'insuranceClaimNumber' => ['nullable', 'string', 'max:255'],
'insuranceStatus' => ['nullable', 'string', 'max:50'],
'insuranceTakenInChargeAt' => ['nullable', 'date'],
'insuranceEmailSentAt' => ['nullable', 'date'],
'insuranceInsurerContactedAt' => ['nullable', 'date'],
'insuranceDocumentsSentAt' => ['nullable', 'date'],
'insuranceAppointmentAt' => ['nullable', 'date'],
'insuranceClosedAt' => ['nullable', 'date'],
'insuranceNextAction' => ['nullable', 'string', 'max:1000'],
'insuranceNotes' => ['nullable', 'string', 'max:4000'],
]);
$claimMetadata = array_merge((array) ($ticket->insuranceClaim?->metadata ?? []), [
'taken_in_charge_at' => $this->insuranceTakenInChargeAt ?: null,
'email_sent_at' => $this->insuranceEmailSentAt ?: null,
'insurer_contacted_at' => $this->insuranceInsurerContactedAt ?: null,
'documents_sent_at' => $this->insuranceDocumentsSentAt ?: null,
'appointment_at' => $this->insuranceAppointmentAt ?: null,
'closed_at' => $this->insuranceClosedAt ?: null,
'next_action' => filled($this->insuranceNextAction) ? trim((string) $this->insuranceNextAction) : null,
]);
$claim = InsuranceClaim::query()->updateOrCreate(
['ticket_id' => (int) $ticket->id],
[
'insurance_policy_id' => (int) ($this->insurancePolicyId ?? 0) ?: null,
'stabile_id' => (int) $ticket->stabile_id,
'policy_reference' => filled($this->insurancePolicyReference) ? trim((string) $this->insurancePolicyReference) : null,
'claim_number' => filled($this->insuranceClaimNumber) ? trim((string) $this->insuranceClaimNumber) : null,
'status' => filled($this->insuranceStatus) ? trim((string) $this->insuranceStatus) : 'aperta',
'opened_at' => $ticket->insuranceClaim?->opened_at ?? now(),
'notes' => filled($this->insuranceNotes) ? trim((string) $this->insuranceNotes) : null,
'metadata' => $claimMetadata,
]
);
$ticket->messages()->create([
'user_id' => Auth::id(),
'messaggio' => 'Sinistro assicurativo aggiornato. Riferimento sinistro: ' . ($claim->claim_number ?: 'da definire') . ' · Stato: ' . ($claim->status ?: 'aperta'),
'canale' => 'assicurazione',
'direzione' => 'outbound',
'inviato_il' => now(),
'metadata' => [
'insurance_claim_id' => (int) $claim->id,
],
]);
$this->refreshData();
Notification::make()->title('Sinistro assicurativo aggiornato')->success()->send();
}
public function caricaAllegati(): void
{
$ticket = $this->selectedTicket;
if (! $ticket) {
Notification::make()->title('Ticket non trovato')->danger()->send();
return;
}
if (! Schema::hasTable('ticket_attachments')) {
Notification::make()->title('Tabella allegati non pronta')->danger()->send();
return;
}
$this->validate([
'nuoviAllegati' => ['nullable', 'array', 'max:8'],
'nuoviAllegati.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,pdf,doc,docx,xls,xlsx,txt'],
'nuoveFoto' => ['nullable', 'array', 'max:8'],
'nuoveFoto.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp'],
'descrizioneAllegati' => ['nullable', 'string', 'max:500'],
]);
$files = array_merge($this->nuoveFoto, $this->nuoviAllegati);
if (count($files) === 0) {
Notification::make()->title('Seleziona almeno un file o una foto')->warning()->send();
return;
}
$saved = 0;
$supportsAttachmentMetadata = Schema::hasColumn('ticket_attachments', 'metadata');
foreach ($files as $file) {
if (! is_object($file) || ! method_exists($file, 'store')) {
continue;
}
$stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-gestione/' . $ticket->id);
$payload = [
'ticket_id' => $ticket->id,
'ticket_update_id' => null,
'user_id' => Auth::id(),
'file_path' => $stored['path'],
'original_file_name' => $stored['original_name'],
'mime_type' => $stored['mime'],
'size' => $stored['size'],
'description' => $this->descrizioneAllegati ?: 'Allegato da gestione ticket',
];
if ($supportsAttachmentMetadata) {
$payload['metadata'] = is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [];
}
$attachment = TicketAttachment::query()->create($payload);
if ($attachment instanceof TicketAttachment) {
$this->archiveTicketAttachmentDocument($ticket, $attachment);
}
$saved++;
}
$this->nuoveFoto = [];
$this->nuoviAllegati = [];
$this->descrizioneAllegati = null;
$this->refreshData();
Notification::make()
->title('Allegati caricati')
->body('Caricati ' . $saved . ' allegati.')
->success()
->send();
}
public function prendiInCarico(int $ticketId): void
{
$this->aggiornaStatoTicket($ticketId, 'Preso in Carico', true);
}
public function avviaLavorazione(int $ticketId): void
{
$this->aggiornaStatoTicket($ticketId, 'In Lavorazione');
}
public function risolviTicket(int $ticketId): void
{
$this->aggiornaStatoTicket($ticketId, 'Risolto');
}
public function chiudiTicket(int $ticketId): void
{
$this->aggiornaStatoTicket($ticketId, 'Chiuso');
}
public function abilitaAccessoFornitoreDaTicket(int $fornitoreId): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Stabile attivo non disponibile')->warning()->send();
return;
}
$stabile = \App\Models\Stabile::query()->find($stabileId);
$adminId = (int) ($stabile->amministratore_id ?? 0);
$fornitore = Fornitore::query()->find($fornitoreId);
if (! $fornitore instanceof Fornitore) {
Notification::make()->title('Fornitore non trovato')->danger()->send();
return;
}
if ($adminId > 0 && (int) ($fornitore->amministratore_id ?? 0) > 0 && (int) ($fornitore->amministratore_id ?? 0) !== $adminId) {
Notification::make()->title('Fornitore non valido per questo stabile')->danger()->send();
return;
}
$email = trim((string) ($fornitore->email ?? ''));
if ($email === '') {
Notification::make()->title('Email fornitore mancante')->warning()->send();
return;
}
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $fornitore->id)
->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])
->first();
if (! $dipendente instanceof FornitoreDipendente) {
$dipendente = FornitoreDipendente::query()->create([
'fornitore_id' => (int) $fornitore->id,
'nome' => (string) ($fornitore->ragione_sociale ?: ($fornitore->nome ?: 'Referente fornitore')),
'cognome' => $fornitore->ragione_sociale ? null : (($fornitore->cognome ?? null) ?: null),
'email' => $email,
'telefono' => $fornitore->telefono ?: $fornitore->cellulare,
'attivo' => true,
'created_by_user_id' => Auth::id(),
'updated_by_user_id' => Auth::id(),
]);
}
$this->abilitaAccessoDipendente($dipendente);
$this->refreshData();
}
public function resetPasswordFornitoreUser(int $userId): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Stabile attivo non disponibile')->warning()->send();
return;
}
$stabile = \App\Models\Stabile::query()->find($stabileId);
$adminId = (int) ($stabile->amministratore_id ?? 0);
$canReset = FornitoreDipendente::query()
->where('user_id', $userId)
->whereHas('fornitore', function ($q) use ($adminId): void {
if ($adminId > 0) {
$q->where('amministratore_id', $adminId);
}
})
->exists();
if (! $canReset) {
Notification::make()->title('Utente non autorizzato per reset')->danger()->send();
return;
}
$target = User::query()->find($userId);
if (! $target instanceof User) {
Notification::make()->title('Utente non trovato')->danger()->send();
return;
}
$newPassword = Str::random(12);
$target->password = Hash::make($newPassword);
$target->save();
$this->lastGeneratedPassword = $newPassword;
Notification::make()
->title('Password fornitore resettata')
->body('Nuova password temporanea: ' . $newPassword)
->success()
->send();
}
private function loadTickets(): void
{
$user = Auth::user();
if (! $user instanceof User) {
$this->tickets = collect();
return;
}
$stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all');
if ($stabileIds === []) {
$this->tickets = collect();
return;
}
$query = Ticket::query()
->with(['categoriaTicket', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome', 'assegnatoAUser:id,name'])
->withCount('attachments')
->whereIn('stabile_id', $stabileIds);
if ($this->status === 'open') {
$query->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']);
} elseif ($this->status === 'urgent') {
$query->where('priorita', 'Urgente')
->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']);
} elseif ($this->status === 'closed') {
$query->whereIn('stato', ['Risolto', 'Chiuso']);
}
$this->tickets = $query->latest('data_apertura')->limit(50)->get();
}
private function refreshFornitoriAttiviRows(): void
{
$user = Auth::user();
if (! $user instanceof User) {
$this->fornitoriAttiviRows = [];
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
$this->fornitoriAttiviRows = [];
return;
}
$ticketApertiStati = ['Aperto', 'Preso in Carico', 'In Lavorazione', 'In Attesa Approvazione', 'In Attesa Ricambi'];
$assignedTickets = Ticket::query()
->where('stabile_id', $stabileId)
->whereIn('stato', $ticketApertiStati)
->whereNotNull('assegnato_a_fornitore_id')
->get(['id', 'assegnato_a_fornitore_id', 'stato', 'updated_at']);
$assignedByFornitore = $assignedTickets
->groupBy('assegnato_a_fornitore_id')
->map(fn($rows) => $rows->count());
$ticketSummaries = $assignedTickets
->groupBy('assegnato_a_fornitore_id')
->map(function ($rows) {
return $rows->sortByDesc('updated_at')->take(6)->map(function ($ticket): string {
return '#' . ((int) $ticket->id) . ' (' . (string) $ticket->stato . ')';
})->values()->all();
});
$openInterventiByFornitore = collect();
$interventoStatiByFornitore = collect();
$lastInterventoAtByFornitore = collect();
if (Schema::hasTable('ticket_interventi')) {
$openInterventi = TicketIntervento::query()
->whereIn('stato', ['assegnato', 'in_corso', 'in_verifica'])
->whereHas('ticket', function ($q) use ($stabileId, $ticketApertiStati): void {
$q->where('stabile_id', $stabileId)->whereIn('stato', $ticketApertiStati);
})
->orderByDesc('updated_at')
->get(['fornitore_id', 'stato', 'updated_at']);
$openInterventiByFornitore = $openInterventi
->groupBy('fornitore_id')
->map(fn($rows) => $rows->count());
$interventoStatiByFornitore = $openInterventi
->groupBy('fornitore_id')
->map(function ($rows): string {
return $rows->pluck('stato')
->map(fn($stato) => str_replace('_', ' ', (string) $stato))
->unique()
->take(3)
->implode(', ');
});
$lastInterventoAtByFornitore = $openInterventi
->groupBy('fornitore_id')
->map(function ($rows): ?string {
$last = $rows->sortByDesc('updated_at')->first();
return $last && $last->updated_at ? $last->updated_at->format('d/m/Y H:i') : null;
});
}
$fornitoriIds = collect($assignedByFornitore->keys())
->merge($openInterventiByFornitore->keys())
->map(fn($v) => (int) $v)
->filter(fn(int $v) => $v > 0)
->unique()
->values();
if ($fornitoriIds->isEmpty()) {
$this->fornitoriAttiviRows = [];
return;
}
$rows = [];
$accessRows = FornitoreDipendente::query()
->whereIn('fornitore_id', $fornitoriIds->all())
->get(['fornitore_id', 'user_id']);
$dipCountByFornitore = $accessRows
->groupBy('fornitore_id')
->map(fn($r) => $r->count());
$linkedUserByFornitore = $accessRows
->filter(fn($r) => (int) ($r->user_id ?? 0) > 0)
->groupBy('fornitore_id')
->map(fn($r) => (int) ($r->first()->user_id ?? 0));
foreach (Fornitore::query()->whereIn('id', $fornitoriIds->all())->orderBy('ragione_sociale')->orderBy('cognome')->orderBy('nome')->get() as $fornitore) {
$rows[] = [
'id' => (int) $fornitore->id,
'nome' => trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? '')))),
'email' => (string) ($fornitore->email ?? ''),
'telefono' => (string) ($fornitore->telefono ?: $fornitore->cellulare),
'ticket_attivi' => (int) ($assignedByFornitore->get((string) $fornitore->id) ?? $assignedByFornitore->get((int) $fornitore->id) ?? 0),
'interventi_aperti' => (int) ($openInterventiByFornitore->get((string) $fornitore->id) ?? $openInterventiByFornitore->get((int) $fornitore->id) ?? 0),
'ticket_focus' => (array) ($ticketSummaries->get((string) $fornitore->id) ?? $ticketSummaries->get((int) $fornitore->id) ?? []),
'intervento_stati' => (string) ($interventoStatiByFornitore->get((string) $fornitore->id) ?? $interventoStatiByFornitore->get((int) $fornitore->id) ?? ''),
'ultimo_aggiornamento' => (string) ($lastInterventoAtByFornitore->get((string) $fornitore->id) ?? $lastInterventoAtByFornitore->get((int) $fornitore->id) ?? ''),
'dipendenti_count' => (int) ($dipCountByFornitore->get((string) $fornitore->id) ?? $dipCountByFornitore->get((int) $fornitore->id) ?? 0),
'linked_user_id' => (int) ($linkedUserByFornitore->get((string) $fornitore->id) ?? $linkedUserByFornitore->get((int) $fornitore->id) ?? 0),
];
}
usort($rows, function (array $a, array $b): int {
$lhs = ((int) $a['interventi_aperti']) * 1000 + (int) $a['ticket_attivi'];
$rhs = ((int) $b['interventi_aperti']) * 1000 + (int) $b['ticket_attivi'];
return $rhs <=> $lhs;
});
$this->fornitoriAttiviRows = $rows;
}
private function abilitaAccessoDipendente(FornitoreDipendente $dipendente): void
{
$email = trim((string) ($dipendente->email ?? ''));
if ($email === '') {
Notification::make()->title('Email dipendente mancante')->warning()->send();
return;
}
$existingUser = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first();
$created = false;
if (! $existingUser instanceof User) {
$generatedPassword = Str::random(12);
$existingUser = User::query()->create([
'name' => trim((string) ($dipendente->nome_completo ?: 'Utente fornitore')),
'email' => $email,
'password' => Hash::make($generatedPassword),
'email_verified_at' => now(),
'is_active' => true,
]);
$this->lastGeneratedPassword = $generatedPassword;
$created = true;
}
$existingUser->assignRole('fornitore');
$dipendente->user_id = (int) $existingUser->id;
$dipendente->updated_by_user_id = Auth::id();
$dipendente->save();
$body = $created
? 'Accesso creato. Password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)')
: 'Accesso attivato/aggiornato per utente esistente.';
Notification::make()->title('Accesso fornitore abilitato')->body($body)->success()->send();
}
private function searchFornitori(): void
{
$raw = trim($this->fornitoreSearch);
if ($raw === '') {
if ($this->fornitoreId) {
$selected = $this->resolveFornitoriBaseQuery()->find((int) $this->fornitoreId);
$this->fornitoreMatches = $selected instanceof Fornitore ? [$this->mapFornitoreSearchRow($selected)] : [];
return;
}
$this->fornitoreMatches = [];
return;
}
$digits = preg_replace('/\D+/', '', $raw) ?: '';
$needleText = '%' . mb_strtolower($raw) . '%';
$needleDigits = '%' . $digits . '%';
$canonicalNeedles = $this->normalizeSearchTags($raw);
$matches = $this->resolveFornitoriBaseQuery()
->where(function ($query) use ($needleText, $needleDigits, $digits, $canonicalNeedles): void {
$query->orWhereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(tags, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(note, '')) LIKE ?", [$needleText]);
foreach ($canonicalNeedles as $canonicalNeedle) {
$query->orWhereRaw("LOWER(COALESCE(tags, '')) LIKE ?", ['%' . $canonicalNeedle . '%']);
}
if ($digits !== '') {
$query->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needleDigits])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needleDigits]);
}
})
->limit(60)
->get();
$this->fornitoreMatches = $matches
->map(function (Fornitore $fornitore) use ($raw, $digits, $canonicalNeedles): array {
$row = $this->mapFornitoreSearchRow($fornitore);
$row['score'] = $this->scoreFornitoreMatch($row, $raw, $digits, $canonicalNeedles);
return $row;
})
->sortByDesc('score')
->take(12)
->values()
->map(function (array $row): array {
unset($row['score']);
return $row;
})
->all();
if ($this->fornitoreId && ! collect($this->fornitoreMatches)->contains('id', $this->fornitoreId)) {
$selected = $this->resolveFornitoriBaseQuery()->find((int) $this->fornitoreId);
if ($selected instanceof Fornitore) {
array_unshift($this->fornitoreMatches, $this->mapFornitoreSearchRow($selected));
$this->fornitoreMatches = array_slice($this->fornitoreMatches, 0, 12);
}
}
}
private function resolveFornitoriBaseQuery()
{
$user = Auth::user();
if (! $user instanceof User) {
return Fornitore::query()->whereRaw('1 = 0');
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
return Fornitore::query()->whereRaw('1 = 0');
}
$stabile = \App\Models\Stabile::query()->find($stabileId);
$adminId = (int) ($stabile->amministratore_id ?? 0);
$query = Fornitore::query()->orderBy('ragione_sociale')->orderBy('cognome')->orderBy('nome');
if ($adminId > 0) {
$query->where(function ($builder) use ($adminId): void {
$builder->where('amministratore_id', $adminId)->orWhereNull('amministratore_id');
});
}
return $query;
}
/**
* @return array<int, int>
*/
private function resolveTicketScopeStabileIds(bool $includeAllAccessible = false): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$accessibleIds = StabileContext::accessibleStabili($user)
->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn(int $value) => $value > 0)
->values()
->all();
if ($accessibleIds === []) {
return [];
}
if ($includeAllAccessible) {
return $accessibleIds;
}
$activeId = StabileContext::resolveActiveStabileId($user);
return $activeId ? [$activeId] : [(int) $accessibleIds[0]];
}
/**
* @return array<string,mixed>
*/
private function mapFornitoreSearchRow(Fornitore $fornitore): array
{
$tags = $this->splitFornitoreTags((string) ($fornitore->tags ?? ''));
$label = trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? ''))));
return [
'id' => (int) $fornitore->id,
'nome' => $label !== '' ? $label : ('Fornitore #' . $fornitore->id),
'email' => (string) ($fornitore->email ?? ''),
'telefono' => (string) ($fornitore->telefono ?: $fornitore->cellulare),
'tags' => $tags,
'tags_label' => $tags !== [] ? implode(', ', $tags) : '',
'note' => Str::limit(trim((string) ($fornitore->note ?? '')), 120),
];
}
/**
* @param array<string,mixed> $row
* @param array<int,string> $canonicalNeedles
*/
private function scoreFornitoreMatch(array $row, string $raw, string $digits, array $canonicalNeedles): int
{
$score = (int) (($row['id'] ?? 0) === (int) ($this->fornitoreId ?? 0) ? 1000 : 0);
$haystack = mb_strtolower(implode(' ', array_filter([
(string) ($row['nome'] ?? ''),
(string) ($row['email'] ?? ''),
(string) ($row['telefono'] ?? ''),
(string) ($row['tags_label'] ?? ''),
(string) ($row['note'] ?? ''),
])));
if ($raw !== '' && str_contains($haystack, mb_strtolower($raw))) {
$score += 60;
}
if ($digits !== '') {
$phoneDigits = preg_replace('/\D+/', '', (string) ($row['telefono'] ?? '')) ?: '';
if ($phoneDigits !== '' && str_contains($phoneDigits, $digits)) {
$score += 45;
}
}
$rowTags = array_map(fn(string $tag): string => mb_strtolower($tag), (array) ($row['tags'] ?? []));
foreach ($canonicalNeedles as $canonicalNeedle) {
foreach ($rowTags as $rowTag) {
if ($rowTag === $canonicalNeedle) {
$score += 90;
} elseif (str_contains($rowTag, $canonicalNeedle) || str_contains($canonicalNeedle, $rowTag)) {
$score += 55;
}
}
}
return $score;
}
/**
* @return array<int,string>
*/
private function normalizeSearchTags(string $input): array
{
$tags = [];
foreach ($this->splitFornitoreTags($input) as $tag) {
$normalized = $this->canonicalizeFornitoreTag($tag);
if ($normalized !== null) {
$tags[] = $normalized;
}
}
return array_values(array_unique($tags));
}
/**
* @return array<int,string>
*/
private function splitFornitoreTags(string $value): array
{
$parts = preg_split('/[,;|\n\r\/]+/', $value) ?: [];
return array_values(array_filter(array_map(function (string $part): string {
$clean = trim($part);
$clean = preg_replace('/\s+/', ' ', $clean) ?? '';
return $clean;
}, $parts), fn(string $part): bool => $part !== ''));
}
private function canonicalizeFornitoreTag(string $raw): ?string
{
$clean = trim(mb_strtolower($raw));
if ($clean === '' || mb_strlen($clean) < 3) {
return null;
}
$map = [
'idr' => 'idraulico',
'idraul' => 'idraulico',
'elett' => 'elettricista',
'elettric' => 'elettricista',
'ascens' => 'ascensorista',
'puliz' => 'pulizie',
'giardin' => 'giardiniere',
'assicur' => 'assicurazione',
'manut' => 'manutenzione',
'spurgh' => 'spurghi',
'fogn' => 'spurghi',
'serr' => 'serrature',
'cald' => 'caldaia',
];
foreach ($map as $prefix => $normalized) {
if (str_starts_with($clean, $prefix)) {
return $normalized;
}
}
return $clean;
}
private function loadCategorieTicketOptions(): void
{
if (! Schema::hasTable('categorie_ticket')) {
$this->categorieOptions = [];
return;
}
$this->categorieOptions = CategoriaTicket::query()
->orderBy('nome')
->get(['id', 'nome', 'descrizione'])
->map(fn(CategoriaTicket $c): array=> [
'id' => (int) $c->id,
'nome' => (string) $c->nome,
'descrizione' => (string) ($c->descrizione ?? ''),
])
->all();
}
public function getTipoInterventoLabel(Ticket $ticket): string
{
$categoria = trim((string) optional($ticket->categoriaTicket)->nome);
$titolo = trim((string) ($ticket->titolo ?? ''));
if ($categoria !== '' && $titolo !== '') {
return $categoria . ' - ' . $titolo;
}
if ($categoria !== '') {
return $categoria;
}
if ($titolo !== '') {
return $titolo;
}
return 'N/D';
}
private function syncSelectedTicketState(): void
{
$ticket = $this->selectedTicket;
if (! $ticket) {
$this->fornitoreSearch = '';
$this->fornitoreMatches = [];
$this->insurancePolicyId = null;
$this->insurancePolicyReference = null;
$this->insuranceClaimNumber = null;
$this->insuranceStatus = null;
$this->insuranceTakenInChargeAt = null;
$this->insuranceEmailSentAt = null;
$this->insuranceInsurerContactedAt = null;
$this->insuranceDocumentsSentAt = null;
$this->insuranceAppointmentAt = null;
$this->insuranceClosedAt = null;
$this->insuranceNextAction = null;
$this->insuranceNotes = null;
return;
}
$this->fornitoreId = (int) ($ticket->assegnato_a_fornitore_id ?? 0) ?: null;
if ($this->fornitoreId) {
$selected = $this->resolveFornitoriBaseQuery()->find((int) $this->fornitoreId);
if ($selected instanceof Fornitore) {
$this->fornitoreSearch = $this->mapFornitoreSearchRow($selected)['nome'];
$this->fornitoreMatches = [$this->mapFornitoreSearchRow($selected)];
}
} else {
$this->fornitoreSearch = '';
$this->fornitoreMatches = [];
}
$this->insurancePolicyId = (int) ($ticket->insuranceClaim?->insurance_policy_id ?? 0) ?: null;
$this->insurancePolicyReference = (string) ($ticket->insuranceClaim?->policy_reference ?? '');
$this->insuranceClaimNumber = (string) ($ticket->insuranceClaim?->claim_number ?? '');
$this->insuranceStatus = (string) ($ticket->insuranceClaim?->status ?? 'aperta');
$this->insuranceTakenInChargeAt = filled(data_get($ticket->insuranceClaim?->metadata, 'taken_in_charge_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'taken_in_charge_at') : null;
$this->insuranceEmailSentAt = filled(data_get($ticket->insuranceClaim?->metadata, 'email_sent_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'email_sent_at') : null;
$this->insuranceInsurerContactedAt = filled(data_get($ticket->insuranceClaim?->metadata, 'insurer_contacted_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'insurer_contacted_at') : null;
$this->insuranceDocumentsSentAt = filled(data_get($ticket->insuranceClaim?->metadata, 'documents_sent_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'documents_sent_at') : null;
$this->insuranceAppointmentAt = filled(data_get($ticket->insuranceClaim?->metadata, 'appointment_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'appointment_at') : null;
$this->insuranceClosedAt = filled(data_get($ticket->insuranceClaim?->metadata, 'closed_at')) ? (string) data_get($ticket->insuranceClaim?->metadata, 'closed_at') : null;
$this->insuranceNextAction = (string) (data_get($ticket->insuranceClaim?->metadata, 'next_action') ?? '');
$this->insuranceNotes = (string) ($ticket->insuranceClaim?->notes ?? '');
$this->syncTicketAttachmentsArchive($ticket);
}
/**
* @return array<int, string>
*/
public function getInsurancePolicyOptionsProperty(): array
{
$ticket = $this->selectedTicket;
if (! $ticket || ! class_exists(InsurancePolicy::class)) {
return [];
}
return InsurancePolicy::query()
->where('stabile_id', (int) $ticket->stabile_id)
->orderByDesc('renewal_at')
->orderByDesc('expires_at')
->orderBy('policy_number')
->get()
->mapWithKeys(function (InsurancePolicy $policy): array {
$label = trim(implode(' · ', array_filter([
$policy->display_name,
$policy->policy_number,
$policy->expires_at?->format('d/m/Y'),
])));
return [(int) $policy->id => ($label !== '' ? $label : ('Polizza #' . (int) $policy->id))];
})
->all();
}
private function syncTicketAttachmentsArchive(Ticket $ticket): void
{
foreach ($ticket->attachments as $attachment) {
if ($attachment instanceof TicketAttachment) {
$this->archiveTicketAttachmentDocument($ticket, $attachment);
}
}
}
/**
* @return array<string,mixed>
*/
private function parseAttachmentDescription(string $description): array
{
$parts = preg_split('/\s*\|\s*/', trim($description)) ?: [];
$details = [
'primary_description' => '',
'gps' => null,
'maps_url' => null,
'maps_embed_url' => null,
'exif_datetime' => null,
'device' => null,
'file_label' => null,
'original_label' => null,
'other' => [],
'full_description' => trim($description),
];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part === '') {
continue;
}
if (str_starts_with($part, 'GPS:')) {
$gpsValue = trim((string) Str::after($part, 'GPS:'));
if (preg_match('/^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/', $gpsValue, $matches) === 1) {
$lat = (float) $matches[1];
$lng = (float) $matches[2];
$details['gps'] = [
'lat' => $lat,
'lng' => $lng,
'label' => number_format($lat, 5, '.', '') . ', ' . number_format($lng, 5, '.', ''),
];
$details['maps_embed_url'] = 'https://maps.google.com/maps?q=' . $lat . ',' . $lng . '&z=17&output=embed';
}
continue;
}
if (str_starts_with($part, 'Maps:')) {
$details['maps_url'] = trim((string) Str::after($part, 'Maps:'));
continue;
}
if (str_starts_with($part, 'EXIF:')) {
$details['exif_datetime'] = trim((string) Str::after($part, 'EXIF:'));
continue;
}
if (str_starts_with($part, 'Device:')) {
$details['device'] = trim((string) Str::after($part, 'Device:'));
continue;
}
if (str_starts_with($part, 'File:')) {
$details['file_label'] = trim((string) Str::after($part, 'File:'));
continue;
}
if (str_starts_with($part, 'Orig:')) {
$details['original_label'] = trim((string) Str::after($part, 'Orig:'));
continue;
}
if ($details['primary_description'] === '') {
$details['primary_description'] = $part;
continue;
}
$details['other'][] = $part;
}
if ($details['maps_url'] === null && is_array($details['gps'])) {
$details['maps_url'] = 'https://maps.google.com/?q=' . $details['gps']['lat'] . ',' . $details['gps']['lng'];
}
if ($details['primary_description'] === '') {
$details['primary_description'] = $details['full_description'] !== ''
? $details['full_description']
: 'Allegato ticket';
}
return $details;
}
private function archiveTicketAttachmentDocument(Ticket $ticket, TicketAttachment $attachment): void
{
if (! Schema::hasTable('documenti')) {
return;
}
$publicPath = trim((string) ($attachment->file_path ?? ''));
if ($publicPath === '' || ! Storage::disk('public')->exists($publicPath)) {
return;
}
$extension = strtolower((string) pathinfo((string) ($attachment->original_file_name ?: $publicPath), PATHINFO_EXTENSION));
$safeName = Str::slug(pathinfo((string) ($attachment->original_file_name ?: 'allegato'), PATHINFO_FILENAME));
$safeName = $safeName !== '' ? $safeName : ('attachment-' . (int) $attachment->id);
$archiveReference = $this->buildTicketArchiveReference($ticket, $attachment);
$localPath = $this->buildTicketArchivePath($ticket, $attachment, $safeName, $extension, $archiveReference);
if (! Storage::disk('local')->exists($localPath)) {
Storage::disk('local')->put($localPath, Storage::disk('public')->get($publicPath));
}
$contents = Storage::disk('local')->get($localPath);
$payload = [
'stabile_id' => (int) $ticket->stabile_id,
'utente_id' => Auth::id(),
'nome' => (string) ($attachment->original_file_name ?: ('Allegato ticket #' . (int) $attachment->id)),
'nome_file' => (string) ($attachment->original_file_name ?: basename($localPath)),
'percorso_file' => $localPath,
'tipo_documento' => 'ticket_allegato',
'tipologia' => $attachment->isImage() ? 'foto' : ($attachment->isPdf() ? 'comunicazione' : 'altro'),
'mime_type' => $attachment->resolvedMimeType(),
'descrizione' => (string) ($attachment->description ?: 'Allegato ticket #' . (int) $ticket->id),
'note' => 'Ticket #' . (int) $ticket->id . ' · ' . (string) ($ticket->titolo ?: 'senza titolo'),
'dimensione_file' => (int) ($attachment->size ?? strlen($contents)),
'estensione' => $extension !== '' ? $extension : null,
'hash_file' => hash('sha256', $contents),
'xml_data' => [
'ticket_id' => (int) $ticket->id,
'ticket_attachment_id' => (int) $attachment->id,
'source_public_path' => $publicPath,
'archive_reference' => $archiveReference,
'archive_year' => $this->resolveTicketArchiveYear($ticket, $attachment),
'archive_scope' => 'ticket',
'visibility_scope' => 'interno',
'visibility_groups' => ['supporto', 'amministrazione', 'stabile:' . (int) $ticket->stabile_id],
],
'metadati_ocr' => $attachment->isImage()
? ['status' => 'pending_image_ocr', 'source' => 'ticket_attachment']
: ['source' => 'ticket_attachment'],
'data_upload' => now(),
'approvato' => true,
'archiviato' => true,
'urgente' => false,
'is_demo' => false,
'archive_reference' => $archiveReference,
'visibility_scope' => 'interno',
'visibility_groups' => ['supporto', 'amministrazione', 'stabile:' . (int) $ticket->stabile_id],
'visibility_roles' => ['super-admin', 'admin', 'amministratore', 'collaboratore'],
];
$documento = Documento::query()->updateOrCreate(
[
'documentable_type' => Ticket::class,
'documentable_id' => (int) $ticket->id,
'path_file' => $localPath,
],
$this->filterDocumentoPayload($payload)
);
if ($attachment->isPdf()) {
app(PdfTextExtractionService::class)->extractAndStore($documento, false);
} elseif ($attachment->isImage()) {
app(ImageTextExtractionService::class)->extractAndStore($documento, false);
}
}
/**
* @return array<int,string>
*/
private function documentiColumns(): array
{
if ($this->documentiColumnsCache === null) {
$this->documentiColumnsCache = Schema::hasTable('documenti')
? Schema::getColumnListing('documenti')
: [];
}
return $this->documentiColumnsCache;
}
private function filterDocumentoPayload(array $payload): array
{
$allowed = array_flip($this->documentiColumns());
return array_intersect_key($payload, $allowed);
}
private function buildTicketArchiveReference(Ticket $ticket, TicketAttachment $attachment): string
{
return sprintf('TKT-%06d-ATT-%06d', (int) $ticket->id, (int) $attachment->id);
}
private function buildTicketArchivePath(Ticket $ticket, TicketAttachment $attachment, string $safeName, string $extension, string $archiveReference): string
{
$year = $this->resolveTicketArchiveYear($ticket, $attachment);
$suffix = $extension !== '' ? ('.' . $extension) : '';
return 'documenti/' . $year
. '/stabili/stabile-' . (int) $ticket->stabile_id
. '/ticket/ticket-' . str_pad((string) (int) $ticket->id, 6, '0', STR_PAD_LEFT)
. '/' . Str::lower($archiveReference) . '-' . $safeName . $suffix;
}
private function resolveTicketArchiveYear(Ticket $ticket, TicketAttachment $attachment): string
{
$date = $attachment->created_at ?? $ticket->created_at ?? now();
return method_exists($date, 'format') ? $date->format('Y') : now()->format('Y');
}
private function loadCounters(): void
{
$user = Auth::user();
if (! $user instanceof User) {
$this->ticketCounters = ['open' => 0, 'urgent' => 0, 'closed' => 0, 'all' => 0];
return;
}
$stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all');
if ($stabileIds === []) {
$this->ticketCounters = ['open' => 0, 'urgent' => 0, 'closed' => 0, 'all' => 0];
return;
}
$base = Ticket::query()->whereIn('stabile_id', $stabileIds);
$this->ticketCounters = [
'open' => (clone $base)->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])->count(),
'urgent' => (clone $base)->where('priorita', 'Urgente')->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])->count(),
'closed' => (clone $base)->whereIn('stato', ['Risolto', 'Chiuso'])->count(),
'all' => (clone $base)->count(),
];
}
private function aggiornaStatoTicket(int $ticketId, string $nuovoStato, bool $assegnaAUtente = false): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileIds = $this->resolveTicketScopeStabileIds(true);
if ($stabileIds === []) {
return;
}
$ticket = Ticket::query()
->where('id', $ticketId)
->whereIn('stabile_id', $stabileIds)
->first();
if (! $ticket) {
Notification::make()
->title('Ticket non trovato o non accessibile')
->danger()
->send();
return;
}
$payload = ['stato' => $nuovoStato];
if ($assegnaAUtente) {
$payload['assegnato_a_user_id'] = $user->id;
}
if ($nuovoStato === 'Risolto' && ! $ticket->data_risoluzione_effettiva) {
$payload['data_risoluzione_effettiva'] = now()->toDateString();
}
if ($nuovoStato === 'Chiuso' && ! $ticket->data_chiusura_effettiva) {
$payload['data_chiusura_effettiva'] = now()->toDateString();
}
$ticket->update($payload);
Notification::make()
->title('Ticket #' . $ticket->id . ' aggiornato a ' . $nuovoStato)
->success()
->send();
$this->refreshData();
}
}