diff --git a/app/Console/Commands/GesconAutoSyncAnagrafiche.php b/app/Console/Commands/GesconAutoSyncAnagrafiche.php index db058f4..8eec05c 100644 --- a/app/Console/Commands/GesconAutoSyncAnagrafiche.php +++ b/app/Console/Commands/GesconAutoSyncAnagrafiche.php @@ -817,7 +817,7 @@ private function normalizePhone(?string $phone): ?string * Recupera identità tenant da altre righe condomin della stessa unità, quando la riga corrente è incompleta. * @return array{cf:?string,email:?string,telefono:?string,cellulare:?string} */ - private function lookupTenantIdentityFromStaging(string $conn, string $codStabile, string $scala, string $interno, mixed $piano, mixed $tenantNameHint): array + private function lookupTenantIdentityFromStaging(string $conn, string $codStabile, ?string $scala, ?string $interno, mixed $piano, mixed $tenantNameHint): array { try { if (! Schema::connection($conn)->hasTable('condomin')) { diff --git a/app/Console/Commands/ImportLegacyFornitoriTagsCommand.php b/app/Console/Commands/ImportLegacyFornitoriTagsCommand.php index 8533144..c1a33ae 100644 --- a/app/Console/Commands/ImportLegacyFornitoriTagsCommand.php +++ b/app/Console/Commands/ImportLegacyFornitoriTagsCommand.php @@ -14,6 +14,8 @@ class ImportLegacyFornitoriTagsCommand extends Command protected $description = 'Importa e normalizza i tag fornitori dalla colonna legacy descrizione (gescon_import.fornitori).'; + private bool $hasLegacyDescrizioneColumn = false; + public function handle(): int { if (! config('database.connections.gescon_import')) { @@ -22,30 +24,38 @@ public function handle(): int } $dryRun = (bool) $this->option('dry-run'); + $this->hasLegacyDescrizioneColumn = Schema::hasColumn('fornitori', 'legacy_descrizione'); $filterIds = collect((array) $this->option('fornitore-id')) ->map(fn($v) => (int) $v) ->filter(fn(int $v): bool => $v > 0) ->values() ->all(); - [$legacyTable, $descrColumn] = $this->resolveLegacySource(); - if ($legacyTable === null || $descrColumn === null) { + $source = $this->resolveLegacySource(); + if ($source === null) { $this->warn('Sorgente legacy non trovata: manca tabella/colonna descrizione su gescon_import.'); return self::FAILURE; } $legacyRows = DB::connection('gescon_import') - ->table($legacyTable) + ->table($source['table']) ->select([ - 'id', - 'cod_forn', - 'ragione_sociale', - 'partita_iva', - 'codice_fiscale', - DB::raw($descrColumn . ' as descrizione_legacy'), + $this->selectColumnOrNull($source['id_column'] ?? null, 'id'), + $this->selectColumnOrNull($source['code_column'] ?? null, 'cod_forn'), + $this->selectCoalescedColumnOrNull($source['name_columns'] ?? [], 'ragione_sociale'), + $this->selectColumnOrNull($source['piva_column'] ?? null, 'partita_iva'), + $this->selectColumnOrNull($source['cf_column'] ?? null, 'codice_fiscale'), + $this->selectColumnOrNull($source['description_column'] ?? null, 'descrizione_legacy'), + $this->selectColumnOrNull($source['notes_column'] ?? null, 'note_legacy'), ]) - ->whereNotNull($descrColumn) - ->where($descrColumn, '<>', '') + ->where(function ($query) use ($source): void { + foreach (($source['text_columns'] ?? []) as $column) { + $query->orWhere(function ($inner) use ($column): void { + $inner->whereNotNull($column) + ->where($column, '<>', ''); + }); + } + }) ->get(); if ($legacyRows->isEmpty()) { @@ -67,7 +77,8 @@ public function handle(): int continue; } - $incoming = $this->extractTags((string) ($legacy->descrizione_legacy ?? '')); + $legacyDescrizione = $this->buildLegacyDescription($legacy); + $incoming = $this->extractTags($legacyDescrizione); if (count($incoming) === 0) { $skipped++; continue; @@ -77,7 +88,6 @@ public function handle(): int $merged = array_values(array_unique(array_merge($current, $incoming))); sort($merged, SORT_NATURAL | SORT_FLAG_CASE); - $legacyDescrizione = trim((string) ($legacy->descrizione_legacy ?? '')); $newTagsValue = implode(', ', $merged); $changed = ((string) ($fornitore->tags ?? '')) !== $newTagsValue || ((string) ($fornitore->legacy_descrizione ?? '')) !== $legacyDescrizione; @@ -93,8 +103,10 @@ public function handle(): int continue; } - $fornitore->tags = $newTagsValue; - $fornitore->legacy_descrizione = $legacyDescrizione !== '' ? $legacyDescrizione : null; + $fornitore->tags = $newTagsValue; + if ($this->hasLegacyDescrizioneColumn) { + $fornitore->legacy_descrizione = $legacyDescrizione !== '' ? $legacyDescrizione : null; + } $fornitore->save(); $updated++; } @@ -105,24 +117,129 @@ public function handle(): int return self::SUCCESS; } - private function resolveLegacySource(): array + private function resolveLegacySource(): ?array { - $tables = ['fornitori', 'fornitori_gescon']; - $columns = ['descrizione', 'descr', 'note']; + $sources = [ + [ + 'table' => 'mdb_fornitori', + 'id_column' => 'id_fornitore', + 'code_column' => 'cod_forn', + 'name_columns' => ['denominazione', 'ragione_sociale', 'cognome', 'nome'], + 'piva_column' => 'p_iva', + 'cf_column' => 'cod_fisc', + 'description_column' => 'descrizione', + 'notes_column' => 'note', + 'text_columns' => ['descrizione', 'note'], + ], + [ + 'table' => 'fornitori', + 'id_column' => 'id', + 'code_column' => 'cod_forn', + 'name_columns' => ['ragione_sociale', 'cognome', 'nome'], + 'piva_column' => 'partita_iva', + 'cf_column' => 'codice_fiscale', + 'description_column' => 'descrizione', + 'notes_column' => 'note', + 'text_columns' => ['descrizione', 'note'], + ], + [ + 'table' => 'fornitori_gescon', + 'id_column' => 'id', + 'code_column' => 'cod_forn', + 'name_columns' => ['ragione_sociale'], + 'piva_column' => 'partita_iva', + 'cf_column' => 'codice_fiscale', + 'description_column' => 'descr', + 'notes_column' => 'note', + 'text_columns' => ['descr', 'note'], + ], + ]; - foreach ($tables as $table) { - if (! Schema::connection('gescon_import')->hasTable($table)) { + foreach ($sources as $source) { + if (! Schema::connection('gescon_import')->hasTable($source['table'])) { continue; } - foreach ($columns as $column) { - if (Schema::connection('gescon_import')->hasColumn($table, $column)) { - return [$table, $column]; + $hasColumn = fn(string $column): bool => Schema::connection('gescon_import')->hasColumn($source['table'], $column); + + $source['id_column'] = isset($source['id_column']) && $hasColumn($source['id_column']) + ? $source['id_column'] + : null; + $source['code_column'] = isset($source['code_column']) && $hasColumn($source['code_column']) + ? $source['code_column'] + : null; + $source['piva_column'] = isset($source['piva_column']) && $hasColumn($source['piva_column']) + ? $source['piva_column'] + : null; + $source['cf_column'] = isset($source['cf_column']) && $hasColumn($source['cf_column']) + ? $source['cf_column'] + : null; + $source['name_columns'] = array_values(array_filter( + $source['name_columns'] ?? [], + fn(string $column): bool => $hasColumn($column) + )); + + $availableTextColumns = []; + foreach (($source['text_columns'] ?? []) as $column) { + if ($hasColumn($column)) { + $availableTextColumns[] = $column; } } + + if ($availableTextColumns === []) { + continue; + } + + $source['text_columns'] = $availableTextColumns; + $source['description_column'] = in_array((string) ($source['description_column'] ?? ''), $availableTextColumns, true) + ? $source['description_column'] + : null; + $source['notes_column'] = in_array((string) ($source['notes_column'] ?? ''), $availableTextColumns, true) + ? $source['notes_column'] + : null; + + return $source; + } + + return null; + } + + private function selectColumnOrNull(?string $column, string $alias): \Illuminate\Database\Query\Expression + { + if ($column === null) { + return DB::raw('NULL as ' . $alias); + } + + return DB::raw($column . ' as ' . $alias); + } + + /** + * @param array $columns + */ + private function selectCoalescedColumnOrNull(array $columns, string $alias): \Illuminate\Database\Query\Expression + { + $usable = array_values(array_filter($columns, fn(string $column): bool => $column !== '')); + if ($usable === []) { + return DB::raw('NULL as ' . $alias); + } + + $segments = array_map(fn(string $column): string => 'NULLIF(TRIM(' . $column . '), \'\')', $usable); + + return DB::raw('COALESCE(' . implode(', ', $segments) . ') as ' . $alias); + } + + private function buildLegacyDescription(object $legacy): string + { + $parts = []; + + foreach (['descrizione_legacy', 'note_legacy'] as $field) { + $value = trim((string) ($legacy->{$field} ?? '')); + if ($value !== '') { + $parts[] = $value; + } } - return [null, null]; + return implode(' | ', array_values(array_unique($parts))); } private function resolveLocalFornitore(object $legacy): ?Fornitore @@ -165,6 +282,15 @@ private function resolveLocalFornitore(object $legacy): ?Fornitore return Fornitore::query()->where('ragione_sociale', $ragione)->first(); } + $normalizedRagione = $this->normalizeComparableString($ragione); + if ($normalizedRagione !== '') { + return Fornitore::query() + ->get(['id', 'ragione_sociale']) + ->first(function (Fornitore $fornitore) use ($normalizedRagione): bool { + return $this->normalizeComparableString((string) ($fornitore->ragione_sociale ?? '')) === $normalizedRagione; + }); + } + return null; } @@ -179,7 +305,7 @@ private function extractTags(string $input): array } $parts = preg_split('/[,;|\n\r\/]+/', $input) ?: []; - $tags = []; + $tags = $this->extractKeywordTags($input); foreach ($parts as $part) { $normalized = $this->canonicalizeTag($part); @@ -195,14 +321,93 @@ private function extractTags(string $input): array return $tags; } + /** + * @return array + */ + private function extractKeywordTags(string $input): array + { + $haystack = mb_strtolower(trim($input)); + if ($haystack === '') { + return []; + } + + $keywords = [ + 'idr' => 'idraulico', + 'termoidraul' => 'termoidraulico', + 'elettric' => 'elettricista', + 'energia elet' => 'energia elettrica', + 'ascens' => 'ascensorista', + 'puliz' => 'pulizie', + 'giardin' => 'giardiniere', + 'spurgh' => 'spurgo', + 'edil' => 'edile', + 'murator' => 'edile', + 'fabbro' => 'fabbro', + 'ferrament' => 'ferramenta', + 'serrament' => 'serramenti', + 'portier' => 'portierato', + 'citofon' => 'citofonia', + 'videosorv' => 'videosorveglianza', + 'sicurezz' => 'sicurezza', + 'antincend' => 'antincendio', + 'estintor' => 'antincendio', + 'calda' => 'caldaia', + 'riscald' => 'riscaldamento', + 'condizion' => 'condizionamento', + 'gas' => 'gas', + 'acqua' => 'acqua', + 'fogna' => 'fognatura', + 'antenna' => 'antenna', + 'telecom' => 'telefonia', + 'pec' => 'pec', + 'aruba' => 'pec', + 'posta' => 'postali', + 'raccomand' => 'postali', + 'spediz' => 'spedizioni', + 'infest' => 'disinfestazione', + 'amminist' => 'amministrazione', + 'assicur' => 'assicurazione', + 'avvocat' => 'legale', + 'legale' => 'legale', + 'commercial' => 'commercialista', + 'geometr' => 'geometra', + 'privacy' => 'privacy', + 'timbri' => 'timbri', + 'targhe' => 'targhe', + 'paghe' => 'paghe contributi', + 'contribut' => 'paghe contributi', + 'utenz' => 'utenze', + ]; + + $tags = []; + foreach ($keywords as $needle => $canonical) { + if (str_contains($haystack, $needle)) { + $tags[] = $canonical; + } + } + + $tags = array_values(array_unique($tags)); + sort($tags, SORT_NATURAL | SORT_FLAG_CASE); + + return $tags; + } + private function canonicalizeTag(string $raw): ?string { - $clean = trim(mb_strtolower($raw)); + $clean = trim(mb_strtolower($raw), " \t\n\r\0\x0B-_,.;:"); $clean = preg_replace('/\s+/', ' ', $clean) ?? ''; if ($clean === '' || mb_strlen($clean) < 3) { return null; } + if (str_contains($clean, '@') || preg_match('/\d{2,}/', $clean)) { + return null; + } + + if (preg_match('/\b(tel|telefono|fax|cell|cellulare|pec|email|codice cliente|parlare con|sig\.?|sig\.ra)\b/u', $clean)) { + return null; + } + $map = [ 'idr' => 'idraulico', 'idraul' => 'idraulico', @@ -213,14 +418,31 @@ private function canonicalizeTag(string $raw): ?string 'giardin' => 'giardiniere', 'assicur' => 'assicurazione', 'manut' => 'manutenzione', + 'murator' => 'edile', + 'privacy' => 'privacy', + 'ferrament'=> 'ferramenta', ]; foreach ($map as $needle => $canonical) { - if (str_starts_with($clean, $needle)) { + if (str_contains($clean, $needle)) { return $canonical; } } + $lettersOnly = preg_replace('/[^[:alpha:] ]+/u', '', $clean) ?? ''; + $wordCount = count(array_filter(explode(' ', $lettersOnly), fn(string $word): bool => $word !== '')); + if (mb_strlen(trim($lettersOnly)) < 3 || $wordCount > 3 || mb_strlen($clean) > 28) { + return null; + } + return $clean; } + + private function normalizeComparableString(string $value): string + { + $normalized = mb_strtolower(trim($value)); + $normalized = preg_replace('/[^[:alnum:]]+/u', '', $normalized) ?? ''; + + return $normalized; + } } diff --git a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php index 4757d9b..b3abbd6 100644 --- a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php +++ b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php @@ -128,6 +128,7 @@ public function mount(int | string $record): void } $this->rubrica = RubricaUniversale::query()->findOrFail((int) $record); + $this->applyLegacyIdentityDefaults(); $activeStabile = StabileContext::getActiveStabile($user); $adminId = (int) ($activeStabile?->amministratore_id ?: 0); @@ -301,6 +302,17 @@ public function mount(int | string $record): void $this->hydrateStudioCollaboratoreWorkspace(); } + private function applyLegacyIdentityDefaults(): void + { + $legacyIdentity = $this->extractLegacyIdentityFromNote($this->rubrica->note ?? null); + + if (! $this->rubrica->titolo_id && ! empty($legacyIdentity['titolo_id'])) { + $this->rubrica->titolo_id = (int) $legacyIdentity['titolo_id']; + $this->rubrica->saveQuietly(); + $this->rubrica->refresh(); + } + } + public function startInlineEdit(): void { $this->fillInlineForm(); diff --git a/app/Filament/Pages/Strumenti/PostItGestione.php b/app/Filament/Pages/Strumenti/PostItGestione.php index 5b88b69..c7a8539 100644 --- a/app/Filament/Pages/Strumenti/PostItGestione.php +++ b/app/Filament/Pages/Strumenti/PostItGestione.php @@ -34,6 +34,8 @@ class PostItGestione extends Page protected string $view = 'filament.pages.strumenti.post-it-gestione'; + public ?int $focusPostItId = null; + public string $activeTab = 'storico'; public string $tecnicoCallView = 'esterne'; @@ -53,6 +55,16 @@ class PostItGestione extends Page /** @var array */ private array $pbxExtensionCache = []; + public function mount(): void + { + $focus = (int) request()->query('focus_post_it', 0); + $this->focusPostItId = $focus > 0 ? $focus : null; + + if (request()->query('tab') === 'storico') { + $this->activeTab = 'storico'; + } + } + public function getPostItInserimentoUrl(): string { return PostIt::getUrl(panel: 'admin-filament'); @@ -196,11 +208,16 @@ public function getRecentiProperty() } try { - return ChiamataPostIt::query() + $query = ChiamataPostIt::query() ->with(['rubrica', 'stabile', 'ticket', 'creatoDa']) ->orderByDesc('chiamata_il') - ->limit(50) - ->get(); + ->orderByDesc('id'); + + if ($this->focusPostItId) { + $query->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [$this->focusPostItId]); + } + + return $query->limit(50)->get(); } catch (QueryException) { return collect(); } diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index 7d003dc..b80fb1c 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -3,6 +3,7 @@ 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; @@ -68,9 +69,17 @@ class TicketMobile extends Page /** @var array */ public array $newTicketCameraShots = []; + /** @var array */ + public array $pendingTicketAttachments = []; + + /** @var array */ + public array $pendingTicketCameraShots = []; + /** @var array */ public array $newTicketAttachmentDescriptions = []; + public string $newTicketDraftProtocol = ''; + public ?string $newTicketFotoNote = null; /** @var \Illuminate\Support\Collection */ @@ -122,6 +131,7 @@ public function mount(): void $this->callerMatches = collect(); $this->newTicketPriorita = 'Media'; $this->newTicketTipoIntervento = 'altro'; + $this->resetDraftUploadState(); $this->loadCategorieTicketOptions(); if (request()->query('status') === 'all') { @@ -160,7 +170,7 @@ public function refreshLiveCallBanner(): void return; } - $this->liveIncomingCall = app(LiveIncomingCallService::class)->getForUser($authUser); + $this->liveIncomingCall = app(LiveIncomingCallService::class)->getForUser($authUser); $this->liveCallCanClickToCall = (bool) ($this->liveIncomingCall['can_click_to_call'] ?? false); } @@ -188,7 +198,7 @@ public function creaPostItDaChiamataInIngresso(): void $line = (string) ($this->liveIncomingCall['line'] ?? ''); $this->prefillTicketFromLiveCall($phone, $line); - ChiamataPostIt::query()->create([ + $postIt = ChiamataPostIt::query()->create([ 'stabile_id' => (int) $stabileId, 'creato_da_user_id' => (int) $user->id, 'rubrica_id' => (int) ($this->selectedCallerId ?? 0) ?: null, @@ -208,6 +218,14 @@ public function creaPostItDaChiamataInIngresso(): void ->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 @@ -365,15 +383,15 @@ private function loadTickets(): void return; } - $stabileId = StabileContext::resolveActiveStabileId($user); - if (! $stabileId) { + $stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all'); + if ($stabileIds === []) { $this->tickets = collect(); return; } $query = Ticket::query() ->with(['categoriaTicket']) - ->where('stabile_id', $stabileId); + ->whereIn('stabile_id', $stabileIds); if ($this->status === 'open') { $query->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']); @@ -395,13 +413,13 @@ private function loadCounters(): void return; } - $stabileId = StabileContext::resolveActiveStabileId($user); - if (! $stabileId) { + $stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all'); + if ($stabileIds === []) { $this->ticketCounters = ['open' => 0, 'urgent' => 0, 'closed' => 0, 'all' => 0]; return; } - $base = Ticket::query()->where('stabile_id', $stabileId); + $base = Ticket::query()->whereIn('stabile_id', $stabileIds); $this->ticketCounters = [ 'open' => (clone $base)->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])->count(), @@ -484,22 +502,22 @@ public function creaTicketRapido(): void '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.', + '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', @@ -595,27 +613,25 @@ public function creaTicketRapido(): void ->success() ->send(); - $this->newTicketTitolo = null; - $this->newTicketDescrizione = null; - $this->newTicketLuogo = null; - $this->newTicketPriorita = 'Media'; - $this->newTicketCategoriaId = null; - $this->newTicketTipoIntervento = 'altro'; - $this->newTicketCameraShots = []; - $this->newTicketAttachments = []; - $this->newTicketAttachmentDescriptions = []; - $this->newTicketFotoNote = null; + $this->newTicketTitolo = null; + $this->newTicketDescrizione = null; + $this->newTicketLuogo = null; + $this->newTicketPriorita = 'Media'; + $this->newTicketCategoriaId = null; + $this->newTicketTipoIntervento = 'altro'; + $this->newTicketFotoNote = null; + $this->resetDraftUploadState(); $this->refreshData(); } - public function updatedNewTicketCameraShots(): void + public function updatedPendingTicketCameraShots(): void { - $this->syncAttachmentDescriptionSlots(); + $this->appendPendingUploads('camera'); } - public function updatedNewTicketAttachments(): void + public function updatedPendingTicketAttachments(): void { - $this->syncAttachmentDescriptionSlots(); + $this->appendPendingUploads('attachment'); } public function removeNewTicketFile(int $index): void @@ -635,7 +651,7 @@ public function removeNewTicketFile(int $index): void } /** - * @return array + * @return array */ public function getSelectedUploadsProperty(): array { @@ -647,9 +663,10 @@ public function getSelectedUploadsProperty(): array 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/'); + $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->buildDraftProtocolCode((int) $index, $isImage); $previewUrl = null; if ($isImage && method_exists($file, 'temporaryUrl')) { @@ -661,12 +678,14 @@ public function getSelectedUploadsProperty(): array } $rows[] = [ - 'index' => (int) $index, - 'name' => $name, - 'mime' => $mime, - 'is_image' => $isImage, - 'preview_url' => $previewUrl, - 'description' => (string) ($this->newTicketAttachmentDescriptions[$index] ?? ''), + 'index' => (int) $index, + 'name' => $this->buildDraftUploadDisplayName((int) $index, $name, $isImage), + 'original_name' => $name, + 'protocol' => $protocol, + 'mime' => $mime, + 'is_image' => $isImage, + 'preview_url' => $previewUrl, + 'description' => (string) ($this->newTicketAttachmentDescriptions[$index] ?? ''), ]; } @@ -702,7 +721,11 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int } try { - $stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-mobile/' . $ticket->id); + $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, + ]); TicketAttachment::query()->create([ 'ticket_id' => (int) $ticket->id, @@ -712,7 +735,12 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int 'original_file_name' => $stored['original_name'], 'mime_type' => $stored['mime'], 'size' => $stored['size'], - 'description' => $this->resolveAttachmentDescription((int) $index), + 'description' => $this->resolveAttachmentDescription( + (int) $index, + is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [], + $stored['original_name'] ?? null, + $stored['source_original_name'] ?? null, + ), ]); $saved++; @@ -739,18 +767,152 @@ private function syncAttachmentDescriptionSlots(): void $this->newTicketAttachmentDescriptions = $next; } - private function resolveAttachmentDescription(int $index): string + private function resolveAttachmentDescription(int $index, array $metadata = [], ?string $displayName = null, ?string $sourceOriginalName = null): string { + $parts = []; $specific = trim((string) ($this->newTicketAttachmentDescriptions[$index] ?? '')); if ($specific !== '') { - return $specific; + $parts[] = $specific; + } elseif (filled($this->newTicketFotoNote)) { + $parts[] = 'Nota foto: ' . trim((string) $this->newTicketFotoNote); + } else { + $parts[] = 'Allegato da Ticket Mobile'; } - if (filled($this->newTicketFotoNote)) { - return 'Nota foto: ' . trim((string) $this->newTicketFotoNote); + if (filled($displayName)) { + $parts[] = 'File: ' . trim((string) $displayName); } - return 'Allegato da Ticket Mobile'; + $sourceName = trim((string) $sourceOriginalName); + if ($sourceName !== '' && $sourceName !== trim((string) $displayName)) { + $parts[] = 'Orig: ' . $sourceName; + } + + $exifDate = trim((string) ($metadata['exif_datetime'] ?? '')); + if ($exifDate !== '') { + $parts[] = 'EXIF: ' . $exifDate; + } + + $device = trim((string) ($metadata['device'] ?? '')); + if ($device !== '') { + $parts[] = 'Device: ' . $device; + } + + $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; + } + } + + 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->syncAttachmentDescriptionSlots(); + + $discarded = count($pending) - count($accepted); + 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->newTicketDraftProtocol = 'TM-' . now()->format('Ymd-His'); + } + + private function buildDraftProtocolCode(int $index, bool $isImage): string + { + return $this->newTicketDraftProtocol . '-' . ($isImage ? 'F' : 'A') . str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT); + } + + private function buildDraftUploadDisplayName(int $index, string $originalName, bool $isImage): string + { + $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); + + return $this->buildDraftProtocolCode($index, $isImage) . ($extension !== '' ? '.' . $extension : ''); + } + + 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 + */ + 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 @@ -822,14 +984,14 @@ private function aggiornaStatoTicket(int $ticketId, string $nuovoStato, bool $as return; } - $stabileId = StabileContext::resolveActiveStabileId($user); - if (! $stabileId) { + $stabileIds = $this->resolveTicketScopeStabileIds(true); + if ($stabileIds === []) { return; } $ticket = Ticket::query() ->where('id', $ticketId) - ->where('stabile_id', $stabileId) + ->whereIn('stabile_id', $stabileIds) ->first(); if (! $ticket) { diff --git a/app/Livewire/Filament/TopbarLiveCall.php b/app/Livewire/Filament/TopbarLiveCall.php index 907346b..060ac60 100644 --- a/app/Livewire/Filament/TopbarLiveCall.php +++ b/app/Livewire/Filament/TopbarLiveCall.php @@ -1,7 +1,7 @@ create([ + $postIt = ChiamataPostIt::query()->create([ 'stabile_id' => (int) $stabileId, 'creato_da_user_id' => (int) $user->id, 'rubrica_id' => (int) ($this->liveIncomingCall['rubrica_id'] ?? 0) ?: null, @@ -61,6 +61,14 @@ public function createPostIt(): void ]); Notification::make()->title('Post-it creato dalla testata')->success()->send(); + + $this->redirect( + PostItGestione::getUrl([ + 'tab' => 'storico', + 'focus_post_it' => (int) $postIt->id, + ], panel: 'admin-filament'), + navigate: true, + ); } public function openTicketMobile() @@ -76,4 +84,4 @@ public function render() { return view('livewire.filament.topbar-live-call'); } -} \ No newline at end of file +} diff --git a/resources/views/filament/pages/strumenti/post-it-gestione.blade.php b/resources/views/filament/pages/strumenti/post-it-gestione.blade.php index 6bf0573..f18e188 100644 --- a/resources/views/filament/pages/strumenti/post-it-gestione.blade.php +++ b/resources/views/filament/pages/strumenti/post-it-gestione.blade.php @@ -15,9 +15,12 @@ - + @@ -32,10 +35,16 @@
@forelse($this->recenti as $riga) -
+
(int) ($this->focusPostItId ?? 0) === (int) $riga->id, + ])>
{{ $riga->oggetto ?: 'Senza oggetto' }}
+ @if((int) ($this->focusPostItId ?? 0) === (int) $riga->id) +
Post-it appena creato
+ @endif
{{ $riga->nome_chiamante ?: 'Chiamante non identificato' }} @if($riga->telefono) @@ -93,10 +102,59 @@ @endforelse
- @else + @elseif(in_array($activeTab, ['smdr', 'csta'], true))
-

Elenco tecnico chiamate (1 riga = 1 evento)

-

Qui vedi in colonne i dati disponibili dal centralino: interno, linea/trunk, numero, durata, costo, utente assegnato, stabile, raw line, ecc.

+ @php($isCstaTab = $activeTab === 'csta') +

{{ $isCstaTab ? 'Elenco tecnico CTI / CSTA' : 'Elenco tecnico chiamate SMDR' }}

+

+ {{ $isCstaTab + ? 'Vista test PBX per eventi CSTA, provider, interno coinvolto e richieste click-to-call. Il provider viene letto dai metadati così il cruscotto resta riusabile anche con FreePBX o altri centralini.' + : 'Qui vedi in colonne i dati disponibili dal centralino via SMDR: interno, linea/trunk, numero, durata, costo, utente assegnato, stabile e raw line.' }} +

+ + @if($isCstaTab) +
+
Controllo chiamate PBX
+
Da questo tab puoi verificare gli eventi CSTA e registrare richieste click-to-call verso il PBX dell'interno assegnato. La UI non e vincolata a Panasonic: il provider e esposto come metadato.
+
+ +
+
+
Ultime richieste click-to-call
+
Fonte: coda locale PBX
+
+
+ + + + + + + + + + + + + @forelse($this->recentClickToCallRequests as $req) + + + + + + + + + @empty + + + + @endforelse + +
RichiestaInternoNumeroStatoProviderQuando
#{{ $req->id }}{{ $req->source_extension ?: '-' }}{{ $req->target_number ?: '-' }}{{ $req->status ?: '-' }}{{ strtoupper(str_replace('_', ' ', (string) data_get($req->metadata, 'provider', data_get($req->metadata, 'source_channel', '-')))) }}{{ optional($req->requested_at)->format('d/m/Y H:i:s') ?: optional($req->created_at)->format('d/m/Y H:i:s') }}
Nessuna richiesta click-to-call registrata per il contesto utente corrente.
+
+
+ @endif
@@ -137,10 +195,11 @@ ID Data/Ora + Provider Direzione Ambito Interno - Linea/CO + Linea / Evento Numero Nominativo Durata @@ -162,10 +221,11 @@ {{ $m->id }} {{ optional($m->received_at)->format('d/m/Y H:i:s') }} + {{ $this->getTecnicoProviderLabel($m) }} {{ $m->direction }} {{ $this->getTecnicoScopeLabel($m) }} {{ $this->getTecnicoInternoLabel($m) }} - {{ (string) ($smdr['co'] ?? '-') }} + {{ $this->getTecnicoLineaLabel($m) }} {{ (string) ($m->phone_number ?: ($smdr['dial_number'] ?? '-')) }} {{ $rubricaNomeTech ?: '-' }} {{ $duration !== '' ? $duration : '-' }} @@ -179,6 +239,9 @@ @if($rubricaUrlTech && ! $this->isInternalSmdrMessage($m)) Apri Rubrica @endif + @if($activeTab === 'csta') + Click-to-call + @endif @if(!$m->post_it_id) Crea Post-it @else @@ -188,7 +251,7 @@ @empty - Nessun evento SMDR disponibile. + {{ $isCstaTab ? 'Nessun evento CSTA disponibile.' : 'Nessun evento SMDR disponibile.' }} @endforelse