1444 lines
54 KiB
PHP
1444 lines
54 KiB
PHP
<?php
|
|
namespace App\Filament\Pages\Supporto;
|
|
|
|
use App\Filament\Pages\Contabilita\EstrattoContoSoggetto;
|
|
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
|
|
use App\Filament\Pages\Strumenti\PostItGestione;
|
|
use App\Models\CategoriaTicket;
|
|
use App\Models\ChiamataPostIt;
|
|
use App\Models\PbxClickToCallRequest;
|
|
use App\Models\RubricaUniversale;
|
|
use App\Models\Soggetto;
|
|
use App\Models\Stabile;
|
|
use App\Models\Ticket;
|
|
use App\Models\TicketAttachment;
|
|
use App\Models\UnitaImmobiliare;
|
|
use App\Models\User;
|
|
use App\Services\Cti\LiveIncomingCallService;
|
|
use App\Services\Support\TicketAttachmentUploadService;
|
|
use App\Support\StabileContext;
|
|
use BackedEnum;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Livewire\WithFileUploads;
|
|
use Throwable;
|
|
use UnitEnum;
|
|
|
|
class TicketMobile extends Page
|
|
{
|
|
use WithFileUploads;
|
|
|
|
protected static ?string $navigationLabel = 'Ticket Mobile';
|
|
|
|
protected static ?string $title = 'Ticket Mobile';
|
|
|
|
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-device-phone-mobile';
|
|
|
|
protected static UnitEnum|string|null $navigationGroup = 'Mobile';
|
|
|
|
protected static ?int $navigationSort = 25;
|
|
|
|
protected static ?string $slug = 'supporto/ticket-mobile';
|
|
|
|
protected string $view = 'filament.pages.supporto.ticket-mobile';
|
|
|
|
public string $status = 'all';
|
|
|
|
public string $callerSearch = '';
|
|
|
|
public ?int $selectedCallerId = null;
|
|
|
|
public ?string $newTicketTitolo = null;
|
|
|
|
public ?string $newTicketDescrizione = null;
|
|
|
|
public ?string $newTicketCallNote = null;
|
|
|
|
public ?string $newTicketLuogo = null;
|
|
|
|
public string $newTicketPriorita = 'Media';
|
|
|
|
public ?int $newTicketCategoriaId = null;
|
|
|
|
public ?string $newTicketTipoIntervento = null;
|
|
|
|
/** @var array<int, mixed> */
|
|
public array $newTicketAttachments = [];
|
|
|
|
/** @var array<int, mixed> */
|
|
public array $newTicketCameraShots = [];
|
|
|
|
/** @var array<int, mixed> */
|
|
public array $pendingTicketAttachments = [];
|
|
|
|
/** @var array<int, mixed> */
|
|
public array $pendingTicketCameraShots = [];
|
|
|
|
/** @var array<int, string> */
|
|
public array $newTicketAttachmentDescriptions = [];
|
|
|
|
/** @var array<string, string> */
|
|
public array $newTicketUploadCodes = [];
|
|
|
|
public string $newTicketDraftProtocol = '';
|
|
|
|
public int $newTicketDraftSequence = 1;
|
|
|
|
public ?string $newTicketFotoNote = null;
|
|
|
|
/** @var \Illuminate\Support\Collection<int, Ticket> */
|
|
public $tickets;
|
|
|
|
/** @var \Illuminate\Support\Collection<int, RubricaUniversale> */
|
|
public $callerMatches;
|
|
|
|
/** @var array<string,mixed>|null */
|
|
public ?array $liveIncomingCall = null;
|
|
|
|
public bool $liveCallCanClickToCall = false;
|
|
|
|
/** @var array<string,int> */
|
|
public array $ticketCounters = [
|
|
'open' => 0,
|
|
'urgent' => 0,
|
|
'closed' => 0,
|
|
'all' => 0,
|
|
];
|
|
|
|
/** @var array<int, array{id:int,nome:string}> */
|
|
public array $categorieTicketOptions = [];
|
|
|
|
/** @var array<string,string> */
|
|
public array $interventiPreimpostati = [
|
|
'idraulico_perdita' => 'Idraulico - Perdita acqua',
|
|
'elettrico_luci' => 'Elettrico - Luci/Quadro',
|
|
'ascensore_bloccato' => 'Ascensore - Bloccato',
|
|
'citofono_guasto' => 'Citofono - Guasto',
|
|
'pulizia_straordinaria' => 'Pulizia - Straordinaria',
|
|
'infissi_serrature' => 'Infissi/Serrature',
|
|
'fognatura' => 'Fognatura/Odori',
|
|
'verde_potatura' => 'Verde - Potatura',
|
|
'altro' => 'Altro',
|
|
];
|
|
|
|
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->callerMatches = collect();
|
|
$this->newTicketPriorita = 'Media';
|
|
$this->newTicketTipoIntervento = 'altro';
|
|
$this->resetDraftUploadState();
|
|
$this->loadCategorieTicketOptions();
|
|
|
|
if (request()->query('status') === 'all') {
|
|
$this->status = 'all';
|
|
}
|
|
|
|
$this->refreshLiveCallBanner();
|
|
$this->applyLiveCallQueryPrefill();
|
|
$this->refreshData();
|
|
}
|
|
|
|
public function updatedStatus(): void
|
|
{
|
|
$this->refreshData();
|
|
}
|
|
|
|
public function updatedCallerSearch(): void
|
|
{
|
|
$this->searchCaller();
|
|
}
|
|
|
|
public function refreshData(): void
|
|
{
|
|
$this->loadCounters();
|
|
$this->loadTickets();
|
|
$this->searchCaller();
|
|
$this->refreshLiveCallBanner();
|
|
}
|
|
|
|
public function refreshLiveCallBanner(): void
|
|
{
|
|
$authUser = Auth::user();
|
|
if (! $authUser instanceof User) {
|
|
$this->liveIncomingCall = null;
|
|
$this->liveCallCanClickToCall = false;
|
|
return;
|
|
}
|
|
|
|
$this->liveIncomingCall = app(LiveIncomingCallService::class)->getForUser($authUser);
|
|
$this->liveCallCanClickToCall = (bool) ($this->liveIncomingCall['can_click_to_call'] ?? false);
|
|
}
|
|
|
|
public function creaPostItDaChiamataInIngresso(): void
|
|
{
|
|
if (! $this->liveIncomingCall || empty($this->liveIncomingCall['phone'])) {
|
|
return;
|
|
}
|
|
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$stabileId = StabileContext::resolveActiveStabileId($user);
|
|
if (! $stabileId) {
|
|
Notification::make()
|
|
->title('Seleziona prima uno stabile attivo')
|
|
->warning()
|
|
->send();
|
|
return;
|
|
}
|
|
|
|
$phone = (string) $this->liveIncomingCall['phone'];
|
|
$line = (string) ($this->liveIncomingCall['line'] ?? '');
|
|
$this->prefillTicketFromLiveCall($phone, $line);
|
|
|
|
$postIt = ChiamataPostIt::query()->create([
|
|
'stabile_id' => (int) $stabileId,
|
|
'creato_da_user_id' => (int) $user->id,
|
|
'rubrica_id' => (int) ($this->selectedCallerId ?? 0) ?: null,
|
|
'telefono' => $phone,
|
|
'nome_chiamante' => (string) (($this->liveIncomingCall['rubrica_nome'] ?? '') ?: ('Chiamata ' . $phone)),
|
|
'direzione' => 'in_arrivo',
|
|
'origine' => 'smdr_live_banner',
|
|
'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''),
|
|
'oggetto' => 'Chiamata ricevuta da gestire',
|
|
'nota' => $this->buildLiveCallPostItNote($phone, $line),
|
|
'priorita' => 'Media',
|
|
'stato' => 'post_it',
|
|
'chiamata_il' => now(),
|
|
]);
|
|
|
|
Notification::make()
|
|
->title('Post-it creato dalla chiamata in arrivo')
|
|
->success()
|
|
->send();
|
|
|
|
$this->redirect(
|
|
PostItGestione::getUrl([
|
|
'tab' => 'storico',
|
|
'focus_post_it' => (int) $postIt->id,
|
|
], panel: 'admin-filament'),
|
|
navigate: true,
|
|
);
|
|
}
|
|
|
|
public function usaChiamataInIngresso(): void
|
|
{
|
|
if (! $this->liveIncomingCall || empty($this->liveIncomingCall['phone'])) {
|
|
return;
|
|
}
|
|
|
|
$phone = (string) $this->liveIncomingCall['phone'];
|
|
$line = (string) ($this->liveIncomingCall['line'] ?? '');
|
|
$this->prefillTicketFromLiveCall($phone, $line);
|
|
|
|
Notification::make()
|
|
->title('Ticket precompilato dalla chiamata in arrivo')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
public function richiediClickToCallDaInterno(): void
|
|
{
|
|
if (! $this->liveIncomingCall || empty($this->liveIncomingCall['phone'])) {
|
|
return;
|
|
}
|
|
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$extension = trim((string) ($user->pbx_extension ?? ''));
|
|
if (! (bool) ($user->pbx_click_to_call_enabled ?? false) || $extension === '') {
|
|
Notification::make()->title('Click-to-call non abilitato per il tuo utente')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$phone = $this->normalizePhoneDigits((string) $this->liveIncomingCall['phone']);
|
|
if ($phone === '') {
|
|
Notification::make()->title('Numero non valido')->warning()->send();
|
|
return;
|
|
}
|
|
|
|
$stabileId = StabileContext::resolveActiveStabileId($user);
|
|
|
|
PbxClickToCallRequest::query()->create([
|
|
'requested_by_user_id' => (int) $user->id,
|
|
'assigned_user_id' => (int) $user->id,
|
|
'stabile_id' => $stabileId,
|
|
'communication_message_id' => (int) ($this->liveIncomingCall['message_id'] ?? 0) ?: null,
|
|
'source_extension' => $extension,
|
|
'target_number' => $phone,
|
|
'status' => 'pending',
|
|
'note' => 'Richiesta da banner chiamata in arrivo',
|
|
'requested_at' => now(),
|
|
'metadata' => [
|
|
'from_live_banner' => true,
|
|
'target_extension' => (string) ($this->liveIncomingCall['target_extension'] ?? ''),
|
|
],
|
|
]);
|
|
|
|
Notification::make()
|
|
->title('Richiesta click-to-call registrata')
|
|
->body('Interno ' . $extension . ' -> ' . $phone)
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
public function getLiveRubricaUrl(): ?string
|
|
{
|
|
if (! empty($this->liveIncomingCall['rubrica_url'])) {
|
|
return (string) $this->liveIncomingCall['rubrica_url'];
|
|
}
|
|
|
|
$rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0);
|
|
if ($rubricaId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return RubricaUniversaleScheda::getUrl(['record' => $rubricaId], panel: 'admin-filament');
|
|
}
|
|
|
|
public function getLiveEstrattoContoUrl(): ?string
|
|
{
|
|
if (! empty($this->liveIncomingCall['estratto_conto_url'])) {
|
|
return (string) $this->liveIncomingCall['estratto_conto_url'];
|
|
}
|
|
|
|
$soggettoId = (int) ($this->liveIncomingCall['soggetto_id'] ?? 0);
|
|
if ($soggettoId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return EstrattoContoSoggetto::getUrl(['record' => $soggettoId], panel: 'admin-filament');
|
|
}
|
|
|
|
public function getRubricaFilamentUrl(): string
|
|
{
|
|
return route('filament.admin-filament.pages.gescon.anagrafica.rubrica-universale');
|
|
}
|
|
|
|
public function getAdminMobileHubUrl(): string
|
|
{
|
|
return route('admin.mobile');
|
|
}
|
|
|
|
public function getCondominoMobileTicketUrl(): string
|
|
{
|
|
return route('condomino.tickets.mobile');
|
|
}
|
|
|
|
public function getDashboardFilamentUrl(): string
|
|
{
|
|
return route('filament.admin-filament.pages.dashboard');
|
|
}
|
|
|
|
public function getTicketArchivioUrl(): string
|
|
{
|
|
return TicketGestione::getUrl(panel: 'admin-filament', parameters: ['status' => 'all']);
|
|
}
|
|
|
|
public function getHelpUrl(): string
|
|
{
|
|
return TicketMobileHelp::getUrl(panel: 'admin-filament');
|
|
}
|
|
|
|
public function getTicketDettaglioUrl(int $ticketId): string
|
|
{
|
|
return TicketScheda::getUrl(panel: 'admin-filament') . '?ticket=' . $ticketId;
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
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'])
|
|
->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(30)->get();
|
|
}
|
|
|
|
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 searchCaller(): void
|
|
{
|
|
$raw = trim($this->callerSearch);
|
|
if ($raw === '') {
|
|
$this->callerMatches = collect();
|
|
$this->selectedCallerId = null;
|
|
return;
|
|
}
|
|
|
|
$digits = preg_replace('/\D+/', '', $raw) ?: '';
|
|
$needleText = '%' . mb_strtolower($raw) . '%';
|
|
$needle = '%' . $digits . '%';
|
|
|
|
$matches = RubricaUniversale::query()
|
|
->where(function ($q) use ($needle, $needleText, $digits): void {
|
|
$q->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$needleText])
|
|
->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$needleText])
|
|
->orWhereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$needleText])
|
|
->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$needleText]);
|
|
|
|
if ($digits !== '') {
|
|
$q->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
|
|
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
|
|
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]);
|
|
}
|
|
})
|
|
->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedCallerId ?? 0)])
|
|
->orderBy('nome')
|
|
->orderBy('cognome')
|
|
->limit(50)
|
|
->get();
|
|
|
|
$selectedCallerId = (int) ($this->selectedCallerId ?? 0);
|
|
$this->callerMatches = $matches
|
|
->groupBy(fn(RubricaUniversale $match): string => $this->buildCallerIdentityKey($match))
|
|
->map(function (Collection $group) use ($selectedCallerId): RubricaUniversale {
|
|
return $this->selectCallerRepresentative($group, $selectedCallerId);
|
|
})
|
|
->take(12)
|
|
->values();
|
|
|
|
if ($this->selectedCallerId && ! $this->callerMatches->contains('id', $this->selectedCallerId)) {
|
|
$this->selectedCallerId = null;
|
|
}
|
|
}
|
|
|
|
public function selezionaCaller(int $rubricaId): void
|
|
{
|
|
$match = $this->callerMatches->firstWhere('id', $rubricaId);
|
|
if (! $match) {
|
|
$match = RubricaUniversale::query()->find($rubricaId);
|
|
}
|
|
|
|
if (! $match) {
|
|
return;
|
|
}
|
|
|
|
$this->selectedCallerId = (int) $match->id;
|
|
$this->callerSearch = trim((string) ($match->nome_completo ?: $match->ragione_sociale ?: ''));
|
|
if (blank($this->newTicketTitolo)) {
|
|
$this->newTicketTitolo = 'Segnalazione da ' . ($match->nome_completo ?: $match->ragione_sociale ?: 'contatto');
|
|
}
|
|
$this->searchCaller();
|
|
}
|
|
|
|
public function creaTicketRapido(): void
|
|
{
|
|
$this->validate([
|
|
'newTicketTitolo' => ['required', 'string', 'max:255'],
|
|
'newTicketDescrizione' => ['required', 'string'],
|
|
'newTicketLuogo' => ['nullable', 'string', 'max:255'],
|
|
'newTicketPriorita' => ['required', 'in:Bassa,Media,Alta,Urgente'],
|
|
'newTicketCategoriaId' => ['nullable', 'integer', 'exists:categorie_ticket,id'],
|
|
'newTicketTipoIntervento' => ['nullable', 'string', 'max:80'],
|
|
'newTicketFotoNote' => ['nullable', 'string', 'max:2000'],
|
|
'newTicketCameraShots' => ['nullable', 'array', 'max:10'],
|
|
'newTicketCameraShots.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp'],
|
|
'newTicketAttachments' => ['nullable', 'array', 'max:10'],
|
|
'newTicketAttachments.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,pdf,doc,docx,xls,xlsx,txt'],
|
|
'newTicketAttachmentDescriptions' => ['nullable', 'array'],
|
|
'newTicketAttachmentDescriptions.*' => ['nullable', 'string', 'max:255'],
|
|
], [
|
|
'newTicketTitolo.required' => 'Inserisci il titolo del ticket.',
|
|
'newTicketTitolo.max' => 'Il titolo puo contenere al massimo 255 caratteri.',
|
|
'newTicketDescrizione.required' => 'Inserisci una descrizione del problema.',
|
|
'newTicketLuogo.max' => 'Il luogo intervento puo contenere al massimo 255 caratteri.',
|
|
'newTicketPriorita.in' => 'Seleziona una priorita valida.',
|
|
'newTicketCategoriaId.exists' => 'La categoria selezionata non e valida.',
|
|
'newTicketTipoIntervento.max' => 'Il tipo intervento selezionato non e valido.',
|
|
'newTicketFotoNote.max' => 'La nota generale foto puo contenere al massimo 2000 caratteri.',
|
|
'newTicketCameraShots.max' => 'Puoi caricare al massimo 10 foto per volta.',
|
|
'newTicketCameraShots.*.file' => 'Ogni foto deve essere un file valido.',
|
|
'newTicketCameraShots.*.max' => 'Ogni foto deve pesare al massimo 10 MB.',
|
|
'newTicketCameraShots.*.mimes' => 'Le foto devono essere JPG, PNG o WEBP.',
|
|
'newTicketAttachments.max' => 'Puoi caricare al massimo 10 allegati per volta.',
|
|
'newTicketAttachments.*.file' => 'Ogni allegato deve essere un file valido.',
|
|
'newTicketAttachments.*.max' => 'Ogni allegato deve pesare al massimo 10 MB.',
|
|
'newTicketAttachments.*.mimes' => 'Gli allegati devono essere immagini, PDF o documenti Office/testo.',
|
|
'newTicketAttachmentDescriptions.*.max' => 'Ogni descrizione allegato puo contenere al massimo 255 caratteri.',
|
|
], [
|
|
'newTicketTitolo' => 'titolo',
|
|
'newTicketDescrizione' => 'descrizione',
|
|
'newTicketLuogo' => 'luogo intervento',
|
|
'newTicketPriorita' => 'priorita',
|
|
'newTicketCategoriaId' => 'categoria ticket',
|
|
'newTicketTipoIntervento' => 'tipo intervento',
|
|
'newTicketFotoNote' => 'nota generale foto',
|
|
'newTicketCameraShots' => 'foto',
|
|
'newTicketAttachments' => 'allegati',
|
|
'newTicketAttachmentDescriptions' => 'descrizioni allegati',
|
|
]);
|
|
|
|
$totalUploads = count($this->newTicketCameraShots) + count($this->newTicketAttachments);
|
|
if ($totalUploads > 10) {
|
|
Notification::make()
|
|
->title('Troppi allegati selezionati')
|
|
->body('Puoi caricare al massimo 10 file totali (foto + documenti) per ticket.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$user = Auth::user();
|
|
if (! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$caller = null;
|
|
if ($this->selectedCallerId) {
|
|
$caller = RubricaUniversale::query()->find($this->selectedCallerId);
|
|
}
|
|
|
|
$activeStabileId = StabileContext::resolveActiveStabileId($user);
|
|
$callerContext = $this->resolveCallerTicketContext($caller, $activeStabileId);
|
|
$stabileId = $callerContext['stabile_id'] ?? $activeStabileId;
|
|
|
|
if (! $stabileId) {
|
|
Notification::make()
|
|
->title('Seleziona prima uno stabile attivo oppure un contatto legato a uno stabile')
|
|
->danger()
|
|
->send();
|
|
return;
|
|
}
|
|
|
|
$descrizione = trim((string) $this->newTicketDescrizione);
|
|
if ($caller) {
|
|
$telefono = $caller->telefono_cellulare ?: ($caller->telefono_ufficio ?: $caller->telefono_casa);
|
|
$descrizione .= "\n\nContatto associato: " . ($caller->nome_completo ?: $caller->ragione_sociale ?: ('Rubrica #' . $caller->id));
|
|
if ($telefono) {
|
|
$descrizione .= "\nTelefono richiamabile: {$telefono}";
|
|
}
|
|
if (filled($caller->email)) {
|
|
$descrizione .= "\nEmail richiedente: " . trim((string) $caller->email);
|
|
}
|
|
if (($callerContext['stabile_label'] ?? '') !== '') {
|
|
$descrizione .= "\nRiferimento stabile: " . $callerContext['stabile_label'];
|
|
}
|
|
if (($callerContext['unita_label'] ?? '') !== '') {
|
|
$descrizione .= "\nRiferimento unità: " . $callerContext['unita_label'];
|
|
}
|
|
}
|
|
|
|
if (filled($this->newTicketFotoNote)) {
|
|
$descrizione .= "\n\nNota fotografica: " . trim((string) $this->newTicketFotoNote);
|
|
}
|
|
|
|
if (filled($this->newTicketTipoIntervento)) {
|
|
$label = $this->interventiPreimpostati[(string) $this->newTicketTipoIntervento] ?? (string) $this->newTicketTipoIntervento;
|
|
$descrizione .= "\nTipo intervento: " . $label;
|
|
}
|
|
|
|
$ticket = Ticket::query()->create([
|
|
'stabile_id' => $stabileId,
|
|
'unita_immobiliare_id' => $callerContext['unita_id'] ?? null,
|
|
'soggetto_richiedente_id' => $callerContext['soggetto_id'] ?? null,
|
|
'aperto_da_user_id' => $user->id,
|
|
'titolo' => (string) $this->newTicketTitolo,
|
|
'descrizione' => $descrizione,
|
|
'categoria_ticket_id' => $this->newTicketCategoriaId,
|
|
'luogo_intervento' => $this->newTicketLuogo,
|
|
'data_apertura' => now(),
|
|
'stato' => 'Aperto',
|
|
'priorita' => $this->newTicketPriorita,
|
|
]);
|
|
|
|
$savedAttachments = $this->salvaAllegatiTicket($ticket, $user->id);
|
|
|
|
Notification::make()
|
|
->title('Ticket creato')
|
|
->body(
|
|
$savedAttachments > 0
|
|
? 'Ticket #' . $ticket->id . ' creato con successo con ' . $savedAttachments . ' allegati.'
|
|
: 'Ticket #' . $ticket->id . ' creato con successo'
|
|
)
|
|
->success()
|
|
->send();
|
|
|
|
$this->newTicketTitolo = null;
|
|
$this->newTicketDescrizione = null;
|
|
$this->newTicketCallNote = null;
|
|
$this->newTicketLuogo = null;
|
|
$this->newTicketPriorita = 'Media';
|
|
$this->newTicketCategoriaId = null;
|
|
$this->newTicketTipoIntervento = 'altro';
|
|
$this->newTicketFotoNote = null;
|
|
$this->resetDraftUploadState();
|
|
$this->dispatch('ticket-mobile-draft-reset');
|
|
$this->refreshData();
|
|
}
|
|
|
|
public function updatedPendingTicketCameraShots(): void
|
|
{
|
|
$this->appendPendingUploads('camera');
|
|
}
|
|
|
|
public function updatedPendingTicketAttachments(): void
|
|
{
|
|
$this->appendPendingUploads('attachment');
|
|
}
|
|
|
|
public function removeNewTicketFile(int $index): void
|
|
{
|
|
$file = null;
|
|
$cameraCount = count($this->newTicketCameraShots);
|
|
|
|
if ($index < $cameraCount) {
|
|
$file = $this->newTicketCameraShots[$index] ?? null;
|
|
unset($this->newTicketCameraShots[$index]);
|
|
$this->newTicketCameraShots = array_values($this->newTicketCameraShots);
|
|
} else {
|
|
$docIndex = $index - $cameraCount;
|
|
$file = $this->newTicketAttachments[$docIndex] ?? null;
|
|
unset($this->newTicketAttachments[$docIndex]);
|
|
$this->newTicketAttachments = array_values($this->newTicketAttachments);
|
|
}
|
|
|
|
if (is_object($file)) {
|
|
unset($this->newTicketUploadCodes[$this->resolveUploadQueueKey($file, $index)]);
|
|
}
|
|
|
|
$this->syncAttachmentDescriptionSlots();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{index:int,name:string,original_name:string,protocol:string,mime:string,is_image:bool,preview_url:?string,description:string,size:int,metadata:array<string,mixed>}>
|
|
*/
|
|
public function getSelectedUploadsProperty(): array
|
|
{
|
|
$rows = [];
|
|
$files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments);
|
|
|
|
foreach ($files as $index => $file) {
|
|
if (! is_object($file)) {
|
|
continue;
|
|
}
|
|
|
|
$name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index));
|
|
$mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream');
|
|
$isImage = str_starts_with($mime, 'image/');
|
|
$protocol = $this->resolveDraftUploadCode($file, (int) $index, $isImage);
|
|
$size = (int) (method_exists($file, 'getSize') ? $file->getSize() : 0);
|
|
$metadata = $isImage
|
|
? app(TicketAttachmentUploadService::class)->inspectUpload($file)
|
|
: [];
|
|
|
|
$previewUrl = null;
|
|
if ($isImage && method_exists($file, 'temporaryUrl')) {
|
|
try {
|
|
$previewUrl = (string) $file->temporaryUrl();
|
|
} catch (Throwable) {
|
|
$previewUrl = null;
|
|
}
|
|
}
|
|
|
|
$rows[] = [
|
|
'index' => (int) $index,
|
|
'name' => $this->buildDraftUploadDisplayName($protocol, $name),
|
|
'original_name' => $name,
|
|
'protocol' => $protocol,
|
|
'mime' => $mime,
|
|
'is_image' => $isImage,
|
|
'preview_url' => $previewUrl,
|
|
'description' => (string) ($this->newTicketAttachmentDescriptions[$index] ?? ''),
|
|
'size' => $size,
|
|
'metadata' => is_array($metadata) ? $metadata : [],
|
|
];
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
private function loadCategorieTicketOptions(): void
|
|
{
|
|
$this->categorieTicketOptions = CategoriaTicket::query()
|
|
->orderBy('nome')
|
|
->limit(200)
|
|
->get(['id', 'nome'])
|
|
->map(fn(CategoriaTicket $cat): array=> [
|
|
'id' => (int) $cat->id,
|
|
'nome' => (string) $cat->nome,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private function salvaAllegatiTicket(Ticket $ticket, int $userId): int
|
|
{
|
|
if (! Schema::hasTable('ticket_attachments')) {
|
|
return 0;
|
|
}
|
|
|
|
$saved = 0;
|
|
$supportsAttachmentMetadata = Schema::hasColumn('ticket_attachments', 'metadata');
|
|
|
|
$files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments);
|
|
|
|
foreach ($files as $index => $file) {
|
|
if (! is_object($file) || ! method_exists($file, 'store')) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$displayName = $this->buildStoredUploadDisplayName($ticket, (int) $index, $file);
|
|
$stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-mobile/' . $ticket->id, [
|
|
'stored_basename' => pathinfo($displayName, PATHINFO_FILENAME),
|
|
'display_name' => $displayName,
|
|
]);
|
|
|
|
$payload = [
|
|
'ticket_id' => (int) $ticket->id,
|
|
'ticket_update_id' => null,
|
|
'user_id' => $userId,
|
|
'file_path' => $stored['path'],
|
|
'original_file_name' => $stored['original_name'],
|
|
'mime_type' => $stored['mime'],
|
|
'size' => $stored['size'],
|
|
'description' => $this->resolveAttachmentDescription(
|
|
(int) $index,
|
|
is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [],
|
|
$stored['original_name'] ?? null,
|
|
$stored['source_original_name'] ?? null,
|
|
),
|
|
];
|
|
|
|
if ($supportsAttachmentMetadata) {
|
|
$payload['metadata'] = is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [];
|
|
}
|
|
|
|
TicketAttachment::query()->create($payload);
|
|
|
|
$saved++;
|
|
} catch (Throwable $e) {
|
|
Log::warning('TicketMobile attachment save failed', [
|
|
'ticket_id' => $ticket->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $saved;
|
|
}
|
|
|
|
private function syncAttachmentDescriptionSlots(): void
|
|
{
|
|
$files = array_merge($this->newTicketCameraShots, $this->newTicketAttachments);
|
|
$next = [];
|
|
|
|
foreach ($files as $index => $_file) {
|
|
$next[$index] = (string) ($this->newTicketAttachmentDescriptions[$index] ?? '');
|
|
}
|
|
|
|
$this->newTicketAttachmentDescriptions = $next;
|
|
}
|
|
|
|
private function resolveAttachmentDescription(int $index, array $metadata = [], ?string $displayName = null, ?string $sourceOriginalName = null): string
|
|
{
|
|
$parts = [];
|
|
$specific = trim((string) ($this->newTicketAttachmentDescriptions[$index] ?? ''));
|
|
if ($specific !== '') {
|
|
$parts[] = $specific;
|
|
} elseif (filled($this->newTicketFotoNote)) {
|
|
$parts[] = 'Nota foto: ' . trim((string) $this->newTicketFotoNote);
|
|
} else {
|
|
$parts[] = 'Allegato da Ticket Mobile';
|
|
}
|
|
|
|
$lat = $metadata['gps_decimal']['lat'] ?? null;
|
|
$lng = $metadata['gps_decimal']['lng'] ?? null;
|
|
if (is_numeric($lat) && is_numeric($lng)) {
|
|
$parts[] = 'GPS: ' . number_format((float) $lat, 5, '.', '') . ',' . number_format((float) $lng, 5, '.', '');
|
|
|
|
$mapsUrl = trim((string) ($metadata['google_maps_url'] ?? ''));
|
|
if ($mapsUrl !== '') {
|
|
$parts[] = 'Maps: ' . $mapsUrl;
|
|
}
|
|
}
|
|
|
|
$exifDate = trim((string) ($metadata['exif_datetime'] ?? ''));
|
|
if ($exifDate !== '') {
|
|
$parts[] = 'EXIF: ' . $exifDate;
|
|
}
|
|
|
|
$device = trim((string) ($metadata['device'] ?? ''));
|
|
if ($device !== '') {
|
|
$parts[] = 'Device: ' . $device;
|
|
}
|
|
|
|
if (filled($displayName)) {
|
|
$parts[] = 'File: ' . trim((string) $displayName);
|
|
}
|
|
|
|
$sourceName = trim((string) $sourceOriginalName);
|
|
if ($sourceName !== '' && $sourceName !== trim((string) $displayName)) {
|
|
$parts[] = 'Orig: ' . $sourceName;
|
|
}
|
|
|
|
return Str::limit(implode(' | ', array_filter($parts)), 255);
|
|
}
|
|
|
|
private function appendPendingUploads(string $source): void
|
|
{
|
|
$pendingProperty = $source === 'camera' ? 'pendingTicketCameraShots' : 'pendingTicketAttachments';
|
|
$targetProperty = $source === 'camera' ? 'newTicketCameraShots' : 'newTicketAttachments';
|
|
$pending = array_values(array_filter($this->{$pendingProperty}, fn($file) => is_object($file)));
|
|
|
|
if ($pending === []) {
|
|
return;
|
|
}
|
|
|
|
$currentTotal = count($this->newTicketCameraShots) + count($this->newTicketAttachments);
|
|
$available = max(0, 10 - $currentTotal);
|
|
|
|
if ($available <= 0) {
|
|
$this->{$pendingProperty} = [];
|
|
|
|
Notification::make()
|
|
->title('Limite allegati raggiunto')
|
|
->body('Puoi tenere in coda al massimo 10 file totali per ticket.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$accepted = array_slice($pending, 0, $available);
|
|
$this->{$targetProperty} = array_values(array_merge($this->{$targetProperty}, $accepted));
|
|
$this->{$pendingProperty} = [];
|
|
$this->assignDraftUploadCodes($accepted, $currentTotal);
|
|
$this->syncAttachmentDescriptionSlots();
|
|
|
|
$discarded = count($pending) - count($accepted);
|
|
if (count($accepted) > 0) {
|
|
$queueTotal = count($this->newTicketCameraShots) + count($this->newTicketAttachments);
|
|
|
|
Notification::make()
|
|
->title('File accodati alla bozza ticket')
|
|
->body(count($accepted) . ' file aggiunti. Totale attuale in coda: ' . $queueTotal . '.')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
if ($discarded > 0) {
|
|
Notification::make()
|
|
->title('Coda allegati ridotta')
|
|
->body('Sono stati accodati solo i primi ' . count($accepted) . ' file disponibili: limite massimo 10.')
|
|
->warning()
|
|
->send();
|
|
}
|
|
}
|
|
|
|
private function resetDraftUploadState(): void
|
|
{
|
|
$this->newTicketCameraShots = [];
|
|
$this->newTicketAttachments = [];
|
|
$this->pendingTicketCameraShots = [];
|
|
$this->pendingTicketAttachments = [];
|
|
$this->newTicketAttachmentDescriptions = [];
|
|
$this->newTicketUploadCodes = [];
|
|
$this->newTicketDraftProtocol = 'TM-' . now()->format('Ymd-His');
|
|
$this->newTicketDraftSequence = 1;
|
|
}
|
|
|
|
private function buildDraftProtocolCode(int $sequence, bool $isImage): string
|
|
{
|
|
return $this->newTicketDraftProtocol . '-' . ($isImage ? 'F' : 'A') . str_pad((string) $sequence, 2, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
private function buildDraftUploadDisplayName(string $protocol, string $originalName): string
|
|
{
|
|
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
|
|
|
|
return $protocol . ($extension !== '' ? '.' . $extension : '');
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $files
|
|
*/
|
|
private function assignDraftUploadCodes(array $files, int $fallbackIndexStart): void
|
|
{
|
|
foreach ($files as $offset => $file) {
|
|
if (! is_object($file)) {
|
|
continue;
|
|
}
|
|
|
|
$key = $this->resolveUploadQueueKey($file, $fallbackIndexStart + $offset);
|
|
if (isset($this->newTicketUploadCodes[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream');
|
|
$isImage = str_starts_with($mime, 'image/');
|
|
|
|
$this->newTicketUploadCodes[$key] = $this->buildDraftProtocolCode($this->newTicketDraftSequence, $isImage);
|
|
$this->newTicketDraftSequence++;
|
|
}
|
|
}
|
|
|
|
private function resolveDraftUploadCode(object $file, int $fallbackIndex, bool $isImage): string
|
|
{
|
|
$key = $this->resolveUploadQueueKey($file, $fallbackIndex);
|
|
|
|
if (! isset($this->newTicketUploadCodes[$key])) {
|
|
$this->newTicketUploadCodes[$key] = $this->buildDraftProtocolCode($this->newTicketDraftSequence, $isImage);
|
|
$this->newTicketDraftSequence++;
|
|
}
|
|
|
|
return $this->newTicketUploadCodes[$key];
|
|
}
|
|
|
|
private function resolveUploadQueueKey(object $file, int $fallbackIndex): string
|
|
{
|
|
if (method_exists($file, 'getFilename')) {
|
|
$filename = trim((string) $file->getFilename());
|
|
if ($filename !== '') {
|
|
return $filename;
|
|
}
|
|
}
|
|
|
|
return spl_object_hash($file) ?: 'upload-' . $fallbackIndex;
|
|
}
|
|
|
|
private function buildStoredUploadDisplayName(Ticket $ticket, int $index, object $file): string
|
|
{
|
|
$originalName = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index));
|
|
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
|
|
$mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream');
|
|
$isImage = str_starts_with($mime, 'image/');
|
|
$baseName = 'ticket-' . str_pad((string) $ticket->id, 6, '0', STR_PAD_LEFT) . '-' . ($isImage ? 'foto-' : 'all-') . str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT);
|
|
|
|
return $baseName . ($extension !== '' ? '.' . $extension : '');
|
|
}
|
|
|
|
/**
|
|
* @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]];
|
|
}
|
|
|
|
private function prefillTicketFromLiveCall(string $phone, string $line): void
|
|
{
|
|
$this->callerSearch = $phone;
|
|
$this->searchCaller();
|
|
$this->newTicketCallNote = trim($line) !== '' ? trim($line) : null;
|
|
|
|
$rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0);
|
|
if ($rubricaId > 0) {
|
|
$this->selezionaCaller($rubricaId);
|
|
} elseif ($this->callerMatches->count() === 1) {
|
|
$only = $this->callerMatches->first();
|
|
if ($only) {
|
|
$this->selezionaCaller((int) $only->id);
|
|
}
|
|
}
|
|
|
|
if (blank($this->newTicketTitolo)) {
|
|
$this->newTicketTitolo = 'Segnalazione telefonica in arrivo';
|
|
}
|
|
|
|
if (blank($this->newTicketDescrizione)) {
|
|
$this->newTicketDescrizione = 'Segnalazione aperta durante chiamata ricevuta dal numero ' . $phone . '.';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{phone:?string,target_extension:?string,received_at:?string,caller_name:?string,caller_profile:?string,riferimento_stabile:?string,riferimento_unita:?string,stabile_label:?string,call_note:?string}
|
|
*/
|
|
public function getTicketCallContextProperty(): array
|
|
{
|
|
$caller = $this->selectedCallerId
|
|
? RubricaUniversale::query()->find($this->selectedCallerId)
|
|
: null;
|
|
|
|
$phone = null;
|
|
if (! empty($this->liveIncomingCall['phone'])) {
|
|
$phone = (string) $this->liveIncomingCall['phone'];
|
|
} else {
|
|
$digits = $this->normalizePhoneDigits((string) $this->callerSearch);
|
|
$phone = $digits !== '' ? $digits : null;
|
|
}
|
|
|
|
return [
|
|
'phone' => $phone,
|
|
'target_extension' => ! empty($this->liveIncomingCall['target_extension']) ? (string) $this->liveIncomingCall['target_extension'] : null,
|
|
'received_at' => ! empty($this->liveIncomingCall['received_at']) ? (string) $this->liveIncomingCall['received_at'] : null,
|
|
'caller_name' => $caller ? (string) ($caller->nome_completo ?: $caller->ragione_sociale ?: ''): (! empty($this->liveIncomingCall['rubrica_nome']) ? (string) $this->liveIncomingCall['rubrica_nome'] : null),
|
|
'caller_profile' => $caller ? ((string) ($caller->tipo_utenza_call ?: '') ?: null): null,
|
|
'riferimento_stabile' => $caller ? ((string) ($caller->riferimento_stabile ?: '') ?: null): null,
|
|
'riferimento_unita' => $caller ? ((string) ($caller->riferimento_unita ?: '') ?: null): null,
|
|
'stabile_label' => $caller ? $this->getCallerStabileLabel((int) $caller->id) : null,
|
|
'call_note' => $this->newTicketCallNote,
|
|
];
|
|
}
|
|
|
|
private function buildLiveCallPostItNote(string $phone, string $line): string
|
|
{
|
|
$parts = [
|
|
'Chiamata ricevuta dal numero ' . $phone,
|
|
];
|
|
|
|
$targetExtension = trim((string) ($this->liveIncomingCall['target_extension'] ?? ''));
|
|
if ($targetExtension !== '') {
|
|
$parts[] = 'Interno/gruppo chiamato: ' . $targetExtension;
|
|
}
|
|
|
|
$callerName = trim((string) ($this->liveIncomingCall['rubrica_nome'] ?? ''));
|
|
if ($callerName !== '') {
|
|
$parts[] = 'Contatto riconosciuto: ' . $callerName;
|
|
}
|
|
|
|
$cleanLine = trim($line);
|
|
if ($cleanLine !== '') {
|
|
$parts[] = 'Nota operativa: ' . $cleanLine;
|
|
}
|
|
|
|
return implode("\n", $parts);
|
|
}
|
|
|
|
private function normalizePhoneDigits(string $value): string
|
|
{
|
|
return preg_replace('/\D+/', '', $value) ?: '';
|
|
}
|
|
|
|
private function buildCallerIdentityKey(RubricaUniversale $match): string
|
|
{
|
|
$fiscalCode = strtoupper(trim((string) ($match->codice_fiscale ?? '')));
|
|
if ($fiscalCode !== '') {
|
|
return 'cf:' . $fiscalCode;
|
|
}
|
|
|
|
$vatNumber = strtoupper(trim((string) ($match->partita_iva ?? '')));
|
|
if ($vatNumber !== '') {
|
|
return 'piva:' . $vatNumber;
|
|
}
|
|
|
|
$email = mb_strtolower(trim((string) ($match->email ?? '')));
|
|
if ($email !== '') {
|
|
return 'email:' . $email;
|
|
}
|
|
|
|
$phone = $this->normalizePhoneDigits((string) ($match->telefono_cellulare ?: $match->telefono_ufficio ?: $match->telefono_casa ?: ''));
|
|
$name = mb_strtolower(trim(preg_replace('/\s+/', ' ', (string) ($match->nome_completo ?: $match->ragione_sociale ?: ''))));
|
|
|
|
if ($phone !== '') {
|
|
return 'phone:' . $phone . '|name:' . $name;
|
|
}
|
|
|
|
if ($name !== '') {
|
|
return 'name:' . $name;
|
|
}
|
|
|
|
return 'id:' . (string) $match->id;
|
|
}
|
|
|
|
private function selectCallerRepresentative(Collection $group, int $selectedCallerId): RubricaUniversale
|
|
{
|
|
$selected = $selectedCallerId > 0
|
|
? $group->firstWhere('id', $selectedCallerId)
|
|
: null;
|
|
|
|
$representative = $selected instanceof RubricaUniversale
|
|
? $selected
|
|
: $group
|
|
->sortByDesc(fn(RubricaUniversale $match): array=> [
|
|
(int) (! empty($match->amministratore_id)),
|
|
(int) (! empty($match->riferimento_stabile)),
|
|
(int) (! empty($match->riferimento_unita)),
|
|
(int) (($match->categoria ?? '') === 'condomino'),
|
|
-1 * (int) $match->id,
|
|
])
|
|
->first();
|
|
|
|
$duplicateCategories = $group
|
|
->pluck('categoria')
|
|
->filter(fn($value) => is_string($value) && trim($value) !== '')
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
$representative->setAttribute('duplicate_count', $group->count());
|
|
$representative->setAttribute('duplicate_categories', $duplicateCategories);
|
|
|
|
return $representative;
|
|
}
|
|
|
|
private function applyLiveCallQueryPrefill(): void
|
|
{
|
|
$phone = $this->normalizePhoneDigits((string) request()->query('live_phone', ''));
|
|
if ($phone === '') {
|
|
return;
|
|
}
|
|
|
|
if (! $this->liveIncomingCall) {
|
|
$this->liveIncomingCall = [
|
|
'message_id' => (int) request()->query('live_message_id', 0),
|
|
'phone' => $phone,
|
|
'line' => (string) request()->query('live_note', ''),
|
|
];
|
|
}
|
|
|
|
$this->prefillTicketFromLiveCall($phone, (string) request()->query('live_note', ''));
|
|
}
|
|
|
|
public function excerpt(?string $text, int $limit = 160): string
|
|
{
|
|
return Str::limit((string) $text, $limit);
|
|
}
|
|
|
|
public function getCallerStabileLabel(int $rubricaId): ?string
|
|
{
|
|
$caller = RubricaUniversale::query()->find($rubricaId);
|
|
$stabile = $caller instanceof RubricaUniversale
|
|
? $this->resolveCallerStabile($caller)
|
|
: null;
|
|
|
|
if (! $stabile) {
|
|
return null;
|
|
}
|
|
|
|
return $stabile->denominazione ?: ('Stabile #' . $stabile->id);
|
|
}
|
|
|
|
/**
|
|
* @return array{stabile_id:?int,stabile_label:string,soggetto_id:?int,unita_id:?int,unita_label:string}
|
|
*/
|
|
private function resolveCallerTicketContext(?RubricaUniversale $caller, ?int $fallbackStabileId = null): array
|
|
{
|
|
$fallbackStabile = $fallbackStabileId ? Stabile::query()->find($fallbackStabileId) : null;
|
|
|
|
$callerStabile = $caller instanceof RubricaUniversale
|
|
? $this->resolveCallerStabile($caller)
|
|
: null;
|
|
|
|
$soggetto = $caller instanceof RubricaUniversale
|
|
? $this->resolveCallerSoggetto($caller, $callerStabile?->id)
|
|
: null;
|
|
|
|
$unita = $soggetto instanceof Soggetto
|
|
? $this->resolveCallerUnita($soggetto, $callerStabile?->id, (string) ($caller?->riferimento_unita ?? ''))
|
|
: null;
|
|
|
|
$stabile = $unita instanceof UnitaImmobiliare
|
|
? $unita->stabile
|
|
: $callerStabile;
|
|
|
|
if (! $stabile && $soggetto instanceof Soggetto) {
|
|
$unita = $this->resolveCallerUnita($soggetto, null, (string) ($caller?->riferimento_unita ?? ''));
|
|
if ($unita instanceof UnitaImmobiliare) {
|
|
$stabile = $unita->stabile;
|
|
}
|
|
}
|
|
|
|
if (! $stabile && ! $unita && ! $soggetto && $fallbackStabile instanceof Stabile) {
|
|
$stabile = $fallbackStabile;
|
|
}
|
|
|
|
return [
|
|
'stabile_id' => $stabile?->id,
|
|
'stabile_label' => $stabile ? (string) ($stabile->denominazione ?: ('Stabile #' . $stabile->id)): '',
|
|
'soggetto_id' => $soggetto?->id,
|
|
'unita_id' => $unita?->id,
|
|
'unita_label' => $unita ? $this->formatUnitaLabel($unita) : '',
|
|
];
|
|
}
|
|
|
|
private function resolveCallerStabile(RubricaUniversale $caller, ?int $fallbackStabileId = null): ?Stabile
|
|
{
|
|
$stabile = Stabile::query()->where('rubrica_id', (int) $caller->id)->first();
|
|
if ($stabile instanceof Stabile) {
|
|
return $stabile;
|
|
}
|
|
|
|
$reference = trim((string) ($caller->riferimento_stabile ?? ''));
|
|
if ($reference !== '') {
|
|
$query = Stabile::query();
|
|
if ((int) ($caller->amministratore_id ?? 0) > 0) {
|
|
$query->where('amministratore_id', (int) $caller->amministratore_id);
|
|
}
|
|
|
|
$exact = (clone $query)
|
|
->where(function ($builder) use ($reference): void {
|
|
$builder->where('denominazione', $reference)
|
|
->orWhere('codice_stabile', $reference);
|
|
})
|
|
->first();
|
|
|
|
if ($exact instanceof Stabile) {
|
|
return $exact;
|
|
}
|
|
|
|
$loose = (clone $query)
|
|
->where(function ($builder) use ($reference): void {
|
|
$builder->where('denominazione', 'like', '%' . $reference . '%')
|
|
->orWhere('indirizzo', 'like', '%' . $reference . '%');
|
|
})
|
|
->orderBy('id')
|
|
->first();
|
|
|
|
if ($loose instanceof Stabile) {
|
|
return $loose;
|
|
}
|
|
}
|
|
|
|
return $fallbackStabileId ? Stabile::query()->find($fallbackStabileId) : null;
|
|
}
|
|
|
|
private function resolveCallerSoggetto(RubricaUniversale $caller, ?int $stabileId = null): ?Soggetto
|
|
{
|
|
$queries = [];
|
|
|
|
$fiscalCode = strtoupper(trim((string) ($caller->codice_fiscale ?? '')));
|
|
if ($fiscalCode !== '') {
|
|
$queries[] = fn() => Soggetto::query()->where('codice_fiscale', $fiscalCode);
|
|
}
|
|
|
|
$email = mb_strtolower(trim((string) ($caller->email ?? '')));
|
|
if ($email !== '') {
|
|
$queries[] = fn() => Soggetto::query()->whereRaw('LOWER(email) = ?', [$email]);
|
|
}
|
|
|
|
$phone = $this->normalizePhoneDigits((string) ($caller->telefono_cellulare ?: $caller->telefono_ufficio ?: $caller->telefono_casa ?: ''));
|
|
if ($phone !== '') {
|
|
$queries[] = fn() => Soggetto::query()->whereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') = ?", [$phone]);
|
|
}
|
|
|
|
$name = trim((string) ($caller->ragione_sociale ?: $caller->nome_completo ?: ''));
|
|
if ($name !== '') {
|
|
$queries[] = fn() => Soggetto::query()->where(function ($builder) use ($caller, $name): void {
|
|
$builder->whereRaw("LOWER(COALESCE(ragione_sociale, '')) = ?", [mb_strtolower($name)]);
|
|
|
|
if (filled($caller->nome) || filled($caller->cognome)) {
|
|
$builder->orWhere(function ($inner) use ($caller): void {
|
|
if (filled($caller->nome)) {
|
|
$inner->whereRaw("LOWER(COALESCE(nome, '')) = ?", [mb_strtolower(trim((string) $caller->nome))]);
|
|
}
|
|
if (filled($caller->cognome)) {
|
|
$inner->whereRaw("LOWER(COALESCE(cognome, '')) = ?", [mb_strtolower(trim((string) $caller->cognome))]);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
foreach ($queries as $factory) {
|
|
$query = $factory();
|
|
if ($stabileId) {
|
|
$match = (clone $query)
|
|
->whereHas('unitaImmobiliari', fn($builder) => $builder->where('stabile_id', (int) $stabileId))
|
|
->first();
|
|
|
|
if ($match instanceof Soggetto) {
|
|
return $match;
|
|
}
|
|
}
|
|
|
|
$match = $query->first();
|
|
if ($match instanceof Soggetto) {
|
|
return $match;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function resolveCallerUnita(Soggetto $soggetto, ?int $stabileId = null, string $reference = ''): ?UnitaImmobiliare
|
|
{
|
|
$query = $soggetto->unitaImmobiliari();
|
|
if ($stabileId) {
|
|
$query->where('unita_immobiliari.stabile_id', (int) $stabileId);
|
|
}
|
|
|
|
$unita = $query->with('stabile')->get();
|
|
if ($unita->count() <= 1) {
|
|
return $unita->first();
|
|
}
|
|
|
|
$normalizedReference = $this->normalizeReferenceText($reference);
|
|
if ($normalizedReference === '') {
|
|
return $unita->first();
|
|
}
|
|
|
|
return $unita->first(function (UnitaImmobiliare $item) use ($normalizedReference): bool {
|
|
foreach ([$item->codice_unita, $item->denominazione, $item->scala, $item->interno, $item->numero_interno, $item->descrizione] as $candidate) {
|
|
if ($this->normalizeReferenceText((string) $candidate) === '') {
|
|
continue;
|
|
}
|
|
|
|
if (str_contains($this->normalizeReferenceText((string) $candidate), $normalizedReference)
|
|
|| str_contains($normalizedReference, $this->normalizeReferenceText((string) $candidate))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}) ?? $unita->first();
|
|
}
|
|
|
|
private function formatUnitaLabel(UnitaImmobiliare $unita): string
|
|
{
|
|
$parts = array_filter([
|
|
$unita->denominazione ?: null,
|
|
$unita->scala ? 'Scala ' . $unita->scala : null,
|
|
$unita->interno ? 'Int. ' . $unita->interno : null,
|
|
$unita->piano ? 'Piano ' . $unita->piano : null,
|
|
]);
|
|
|
|
return implode(' · ', $parts) ?: ('Unità #' . $unita->id);
|
|
}
|
|
|
|
private function normalizeReferenceText(string $value): string
|
|
{
|
|
return preg_replace('/[^A-Z0-9]+/', '', strtoupper(trim($value))) ?: '';
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|