*/ public array $collaboratorePbxExtension = []; /** @var array */ public array $collaboratorePbxExtensions = []; /** @var array */ public array $collaboratorePbxGroups = []; /** @var array */ public array $collaboratorePbxLines = []; /** @var array */ public array $collaboratorePbxPopupEnabled = []; /** @var array */ public array $collaboratorePbxClickToCallEnabled = []; public Amministratore $amministratore; public static function canAccess(): bool { $user = Auth::user(); return $user instanceof User && $user->hasAnyRole(['super-admin', 'admin', 'amministratore']); } public function mount(): void { $user = Auth::user(); if (! $user instanceof User) { abort(403); } if (! $user->hasAnyRole(['super-admin', 'admin', 'amministratore'])) { abort(403); } $amministratore = $user->amministratore; if (! $amministratore instanceof Amministratore) { // Per admin/super-admin, se l'utente non è "amministratore" diretto, // usiamo l'amministratore dello stabile attivo (così si configurano i servizi per lo stabile selezionato). if ($user->hasAnyRole(['super-admin', 'admin'])) { $stabile = StabileContext::getActiveStabile($user); if ($stabile instanceof Stabile && $stabile->amministratore) { $amministratore = $stabile->amministratore; } } } if (! $amministratore instanceof Amministratore) { abort(403); } $this->amministratore = $amministratore; $isSuperAdmin = $user->hasAnyRole(['super-admin', 'admin']); $this->getSchema('form')?->fill([ ...$this->amministratore->only([ 'nome', 'cognome', 'denominazione_studio', 'partita_iva', 'codice_fiscale_studio', 'indirizzo_studio', 'cap_studio', 'citta_studio', 'provincia_studio', 'telefono_studio', 'cellulare', 'email_studio', 'pec_studio', 'cartella_dati', 'database_attivo', 'fe_cassetto_enabled', 'fe_cassetto_base_url', ]), // Per sicurezza non precompiliamo mai i segreti nel form. // La configurazione completa è gestita dal Super Admin. 'fe_cassetto_password_script' => $isSuperAdmin ? null : null, 'fe_cassetto_username' => $isSuperAdmin ? null : null, 'fe_cassetto_password' => $isSuperAdmin ? null : null, 'fe_cassetto_pin' => $isSuperAdmin ? null : null, 'impostazioni' => $this->amministratore->impostazioni ?? [], ]); } public function form(FilamentSchema $schema): FilamentSchema { return $schema ->statePath('data') ->components([ Tabs::make('scheda_amministratore') ->tabs([ Tab::make('Anagrafica') ->schema([ Section::make('Dati studio') ->columns(2) ->schema([ TextInput::make('denominazione_studio') ->label('Denominazione studio') ->required() ->maxLength(255), TextInput::make('partita_iva') ->label('Partita IVA') ->maxLength(32), TextInput::make('codice_fiscale_studio') ->label('Codice fiscale studio') ->maxLength(32), TextInput::make('nome') ->label('Nome') ->maxLength(100), TextInput::make('cognome') ->label('Cognome') ->maxLength(100), ]), Section::make('Recapiti') ->columns(2) ->schema([ TextInput::make('indirizzo_studio') ->label('Indirizzo') ->maxLength(255), TextInput::make('cap_studio') ->label('CAP') ->maxLength(10), TextInput::make('citta_studio') ->label('Città') ->maxLength(120), TextInput::make('provincia_studio') ->label('Provincia') ->maxLength(10), TextInput::make('telefono_studio') ->label('Telefono') ->maxLength(30), TextInput::make('cellulare') ->label('Cellulare') ->maxLength(30), TextInput::make('email_studio') ->label('Email') ->email() ->maxLength(255), TextInput::make('pec_studio') ->label('PEC') ->email() ->maxLength(255), ]), Section::make('Centralino studio') ->columns(3) ->schema([ TextInput::make('impostazioni.centralino.numero_principale') ->label('Numero principale centralino') ->maxLength(30), TextInput::make('impostazioni.centralino.numero_backup') ->label('Numero secondario/backup') ->maxLength(30), TextInput::make('impostazioni.centralino.numero_emergenza') ->label('Numero emergenza') ->maxLength(30), TextInput::make('impostazioni.centralino.interno_centralino') ->label('Interno centralino') ->maxLength(20) ->placeholder('Es. 201'), TextInput::make('impostazioni.centralino.interno_operatore') ->label('Interno operatore studio') ->maxLength(20) ->placeholder('Es. 205'), TextInput::make('impostazioni.centralino.interno_amministratore') ->label('Interno amministratore') ->maxLength(20) ->placeholder('Es. 206'), TextInput::make('impostazioni.centralino.interno_gruppo_giorno') ->label('Interno gruppo giorno') ->maxLength(20) ->placeholder('Es. 601'), TextInput::make('impostazioni.centralino.interno_gruppo_notte') ->label('Interno gruppo notte') ->maxLength(20) ->placeholder('Es. 603'), TextInput::make('impostazioni.centralino.note_instradamento') ->label('Note instradamento') ->maxLength(255) ->columnSpanFull(), ]), ]), Tab::make('Documento') ->schema([ Section::make('Intestazione e loghi') ->columns(2) ->schema([ FileUpload::make('impostazioni.documento.logo_1') ->label('Logo 1') ->disk('public') ->directory('amministratori/loghi') ->image() ->imageEditor(), FileUpload::make('impostazioni.documento.logo_2') ->label('Logo 2 (opzionale)') ->disk('public') ->directory('amministratori/loghi') ->image() ->imageEditor(), Textarea::make('impostazioni.documento.cappello') ->label('Cappello (testo intestazione)') ->rows(4) ->columnSpanFull() ->helperText('Se vuoto, usa automaticamente denominazione/indirizzo/contatti dello studio.'), ]), Section::make('Dati fiscali e note documento') ->schema([ Textarea::make('impostazioni.documento.dati_fiscali') ->label('Dati amministrativi/fiscali (testo libero)') ->rows(4) ->helperText('Esempio: P.IVA, CF, REA, SDI, PEC, estremi professionali, ecc.'), Textarea::make('impostazioni.documento.piede') ->label('Piè di pagina (testo libero)') ->rows(3) ->helperText('Testo che apparirà in basso in tutte le stampe dello studio.'), Textarea::make('impostazioni.documento.firma') ->label('Firma (testo libero)') ->rows(3) ->helperText('Sarà riutilizzata nelle stampe dove prevista.'), ]), Section::make('Compensi (stampa)') ->columns(3) ->schema([ TextInput::make('impostazioni.compensi.base') ->label('Compenso base') ->numeric() ->inputMode('decimal'), TextInput::make('impostazioni.compensi.cassa_percento') ->label('Cassa (%)') ->numeric() ->inputMode('decimal'), TextInput::make('impostazioni.compensi.iva_percento') ->label('IVA (%)') ->numeric() ->inputMode('decimal'), ]), ]), Tab::make('Banche') ->schema([ Section::make('Conto corrente studio') ->columns(2) ->schema([ TextInput::make('impostazioni.banche.iban') ->label('IBAN') ->maxLength(64), TextInput::make('impostazioni.banche.banca') ->label('Banca') ->maxLength(255), TextInput::make('impostazioni.banche.intestatario') ->label('Intestatario') ->maxLength(255) ->columnSpanFull(), ]), ]), Tab::make('Posta / API') ->schema([ Section::make('Posta (SMTP)') ->columns(2) ->schema([ TextInput::make('impostazioni.posta.smtp_host') ->label('SMTP host') ->maxLength(255), TextInput::make('impostazioni.posta.smtp_port') ->label('SMTP porta') ->numeric(), TextInput::make('impostazioni.posta.username') ->label('Username') ->maxLength(255), TextInput::make('impostazioni.posta.password') ->label('Password') ->password() ->revealable() ->maxLength(255), ]), Section::make('Google Workspace (Gmail / Calendar / Rubrica)') ->columns(2) ->schema([ Toggle::make('impostazioni.google.enabled') ->label('Abilita integrazione Google') ->default(false) ->columnSpanFull(), TextInput::make('impostazioni.google.project_id') ->label('Google Project ID') ->maxLength(255), TextInput::make('impostazioni.google.client_id') ->label('OAuth Client ID') ->maxLength(255), TextInput::make('impostazioni.google.client_secret') ->label('OAuth Client Secret') ->password() ->revealable() ->maxLength(255), TextInput::make('impostazioni.google.redirect_uri') ->label('Redirect URI') ->maxLength(255) ->helperText('Es: https://tuo-dominio/oauth/google/callback'), TextInput::make('impostazioni.google.workspace_email') ->label('Email Google da collegare') ->email() ->maxLength(255), TextInput::make('impostazioni.google.calendar_id') ->label('Calendar ID') ->maxLength(255) ->helperText('Es: primary o id calendario condiviso'), TextInput::make('impostazioni.google.contacts_group') ->label('Gruppo rubrica Google') ->maxLength(255) ->helperText('Es: NetGesCon / Condomini'), Toggle::make('impostazioni.google.mail_enabled') ->label('Abilita lettura Gmail') ->default(false), TextInput::make('impostazioni.google.gmail_label') ->label('Label Gmail da leggere') ->maxLength(255) ->helperText('Es: NetGescon/Assicurazioni'), TextInput::make('impostazioni.google.gmail_query') ->label('Query Gmail') ->maxLength(255) ->helperText('Es: from:assicurazione@example.it has:attachment newer_than:30d'), Toggle::make('impostazioni.google.archive_after_import') ->label('Archivia dopo l\'acquisizione') ->default(false), Toggle::make('impostazioni.google.import_attachments') ->label('Importa allegati') ->default(true), TextInput::make('impostazioni.google.allowed_senders') ->label('Mittenti ammessi') ->maxLength(500) ->helperText('Email separate da virgola; utile per assicurazioni o inoltri FE.'), ]), Section::make('Caselle studio / PEC da collegare') ->description('Caselle dello studio amministratore. Per le caselle specifiche del condominio usa la nuova voce Posta stabile nella scheda Stabile.') ->schema([ Repeater::make('impostazioni.posta.caselle') ->label('Caselle collegate') ->default([]) ->schema([ Toggle::make('enabled') ->label('Attiva') ->default(true), Select::make('tipo') ->label('Tipo') ->options([ 'gmail' => 'Gmail / Google Workspace', 'imap' => 'IMAP ordinaria', 'pec' => 'PEC via IMAP', ]) ->default('imap') ->required(), TextInput::make('label') ->label('Etichetta') ->required() ->maxLength(120), TextInput::make('email') ->label('Email casella') ->email() ->maxLength(255), TextInput::make('host') ->label('Host IMAP') ->maxLength(255), TextInput::make('port') ->label('Porta') ->numeric(), Select::make('encryption') ->label('Cifratura') ->options([ 'ssl' => 'SSL', 'tls' => 'TLS', 'none' => 'Nessuna', ]) ->default('ssl'), TextInput::make('username') ->label('Username') ->maxLength(255), TextInput::make('password') ->label('Password') ->password() ->revealable() ->maxLength(255), TextInput::make('folder') ->label('Cartella da leggere') ->maxLength(120) ->default('INBOX'), ]) ->columns(2) ->columnSpanFull(), ]), Section::make('Acquisizione messaggi e allegati') ->columns(2) ->schema([ Toggle::make('impostazioni.posta.routing.salva_documenti') ->label('Archivia allegati nei documenti') ->default(true), Toggle::make('impostazioni.posta.routing.salva_contabilita') ->label('Rendi disponibili gli allegati in contabilità') ->default(true), TextInput::make('impostazioni.posta.routing.mittenti_assicurazione') ->label('Mittenti assicurazione') ->maxLength(500) ->helperText('Email separate da virgola per filtrare i messaggi dell’assicuratore.'), TextInput::make('impostazioni.posta.routing.descrizione_default') ->label('Descrizione default allegato') ->maxLength(255), ]), Section::make('Operativita posta / Google') ->schema([ Placeholder::make('mail_ops_panel') ->hiddenLabel() ->content(fn() => view('filament.pages.impostazioni.partials.scheda-amministratore-posta-operativita')), ]), Section::make('WhatsApp Cloud API (Meta)') ->columns(2) ->schema([ Toggle::make('impostazioni.whatsapp.enabled') ->label('Abilita integrazione WhatsApp') ->default(false) ->columnSpanFull(), TextInput::make('impostazioni.whatsapp.business_account_id') ->label('WhatsApp Business Account ID') ->maxLength(255), TextInput::make('impostazioni.whatsapp.phone_number_id') ->label('Phone Number ID') ->maxLength(255), TextInput::make('impostazioni.whatsapp.access_token') ->label('Access Token') ->password() ->revealable() ->maxLength(255), TextInput::make('impostazioni.whatsapp.webhook_verify_token') ->label('Webhook Verify Token') ->maxLength(255), TextInput::make('impostazioni.whatsapp.webhook_url') ->label('Webhook URL (ricezione)') ->maxLength(255) ->helperText('Endpoint pubblico HTTPS per ricevere messaggi/eventi da Meta'), TextInput::make('impostazioni.whatsapp.default_country_prefix') ->label('Prefisso default numeri') ->maxLength(8) ->helperText('Es: +39'), TextInput::make('impostazioni.whatsapp.template_namespace') ->label('Namespace template (se richiesto)') ->maxLength(255), ]), Section::make('Servizi esterni / API') ->columns(2) ->schema([ TextInput::make('impostazioni.api.endpoint') ->label('Endpoint') ->maxLength(255), TextInput::make('impostazioni.api.token') ->label('Token/API key') ->password() ->revealable() ->maxLength(255), Textarea::make('impostazioni.api.note') ->label('Note') ->rows(3) ->columnSpanFull(), ]), ]), Tab::make('Stampe') ->schema([ Section::make('Stampanti') ->columns(2) ->schema([ TextInput::make('impostazioni.stampe.stampante_etichette') ->label('Stampante etichette (nome/descrizione)') ->maxLength(255) ->helperText('Campo informativo: su web non possiamo selezionare la stampante in modo affidabile; serve per ricordare quale usare.'), Textarea::make('impostazioni.stampe.note') ->label('Note stampa') ->rows(3) ->columnSpanFull(), ]), Section::make('Calibrazione DYMO 11354 (57×32)') ->columns(4) ->schema([ Toggle::make('impostazioni.stampe.dymo.11354.fix') ->label('Attiva workaround driver') ->default(false) ->columnSpanFull(), Select::make('impostazioni.stampe.dymo.11354.rot') ->label('Rotazione') ->options([ -90 => '-90°', 90 => '90°', ]) ->default(-90) ->required(), TextInput::make('impostazioni.stampe.dymo.11354.dx') ->label('dx (mm)') ->numeric() ->inputMode('decimal') ->default(0), TextInput::make('impostazioni.stampe.dymo.11354.dy') ->label('dy (mm)') ->numeric() ->inputMode('decimal') ->default(0), ]), Section::make('Calibrazione DYMO 99014 (101×54)') ->columns(3) ->schema([ TextInput::make('impostazioni.stampe.dymo.99014.dx') ->label('dx (mm)') ->numeric() ->inputMode('decimal') ->default(0), TextInput::make('impostazioni.stampe.dymo.99014.dy') ->label('dy (mm)') ->numeric() ->inputMode('decimal') ->default(0), Select::make('impostazioni.stampe.dymo.99014.rot') ->label('Rotazione') ->options([ 0 => '0°', -90 => '-90°', 90 => '90°', ]) ->default(0) ->required(), ]), ]), Tab::make('Archivi') ->schema([ Section::make('Archivi (operatività)') ->columns(2) ->schema([ TextInput::make('cartella_dati') ->label('Cartella dati') ->disabled() ->dehydrated(false), TextInput::make('database_attivo') ->label('Database attivo') ->disabled() ->dehydrated(false), TextInput::make('impostazioni.archivi.documenti_path') ->label('Percorso documenti (override)') ->helperText('Opzionale: se impostato, verrà preferito come base per documenti/stampe.') ->columnSpanFull(), ]), ]), Tab::make('Distribuzione / Backup / Ripristino') ->schema([ Section::make('Aggiornamento produzione') ->columns(2) ->schema([ TextInput::make('impostazioni.ops.release_tag') ->label('Tag release') ->placeholder('v1.3.0') ->helperText('Tag git da pubblicare in produzione.'), TextInput::make('impostazioni.ops.production_app_dir') ->label('Percorso app produzione') ->default('/var/www/netgescon') ->maxLength(255), TextInput::make('impostazioni.ops.production_backup_dir') ->label('Percorso backup produzione') ->default('/var/backups/netgescon') ->maxLength(255), Toggle::make('impostazioni.ops.skip_migrations') ->label('Salta migrazioni DB (solo emergenza)') ->default(false), ]), Section::make('Backup / Restore dati (file)') ->columns(2) ->schema([ TextInput::make('impostazioni.ops.dev_data_dir') ->label('Sorgente dati sviluppo') ->default('/home/michele/netgescon/netgescon-laravel/storage/app/amministratori') ->maxLength(255), TextInput::make('impostazioni.ops.prod_data_dir') ->label('Destinazione dati produzione') ->default('/var/www/netgescon/storage/app/amministratori') ->maxLength(255) ->helperText('Restore non distruttivo: aggiunge/aggiorna, non elimina file destinazione.'), Toggle::make('impostazioni.ops.restore_dry_run') ->label('Restore in dry-run (consigliato prima esecuzione)') ->default(true), TextInput::make('impostazioni.ops.backup_remote') ->label('Remote backup (rclone)') ->default('gdrive:NetGesconBackups/prod') ->maxLength(255), ]), Section::make('Sync aggiornamenti e archivi') ->columns(2) ->schema([ TextInput::make('impostazioni.ops.sync_source_dir') ->label('Sorgente codice (sviluppo)') ->default('/home/michele/netgescon/netgescon-laravel') ->maxLength(255), TextInput::make('impostazioni.ops.sync_target_dir') ->label('Destinazione codice (produzione)') ->default('/var/www/netgescon') ->maxLength(255), TextInput::make('impostazioni.ops.sync_archive_source') ->label('Sorgente archivi') ->default('/home/michele/backup-completo') ->maxLength(255), TextInput::make('impostazioni.ops.sync_archive_target') ->label('Destinazione archivi') ->default('/mnt/nas-backup-produzione') ->maxLength(255), Toggle::make('impostazioni.ops.sync_dry_run') ->label('Sync in dry-run') ->default(true), ]), Section::make('Pacchetto aggiornamento scaricabile (solo differenze codice)') ->columns(2) ->schema([ TextInput::make('impostazioni.ops.package_source_dir') ->label('Sorgente codice sviluppo') ->default('/home/michele/netgescon/netgescon-laravel') ->maxLength(255), TextInput::make('impostazioni.ops.package_compare_dir') ->label('Confronta con destinazione') ->default('/var/www/netgescon') ->helperText('Il pacchetto contiene solo file nuovi/modificati rispetto alla destinazione.') ->maxLength(255), TextInput::make('impostazioni.ops.package_output_dir') ->label('Cartella output pacchetti') ->default('/var/www/netgescon/storage/app/public/ops-packages') ->maxLength(255) ->columnSpanFull(), ]), Section::make('Backup incrementale dati su Google Drive') ->columns(2) ->schema([ TextInput::make('impostazioni.ops.data_backup_source') ->label('Sorgente dati (file)') ->default('/var/www/netgescon/storage/app/amministratori') ->helperText('Invia su Drive solo file nuovi/modificati; non cancella sul remoto.') ->maxLength(255), TextInput::make('impostazioni.ops.data_backup_remote') ->label('Remote dati (rclone)') ->default('gdrive:NetGesconBackups/prod/data-incremental') ->maxLength(255), ]), Section::make('Azioni operative') ->schema([ Placeholder::make('ops_distribution_panel') ->hiddenLabel() ->content(fn() => view('filament.pages.impostazioni.partials.scheda-amministratore-distribuzione-backup')), ]), ]), Tab::make('PBX / TAPI') ->schema([ Section::make('Profilo centralino e bridge') ->columns(3) ->schema([ Select::make('impostazioni.pbx.provider') ->label('Tipo centralino') ->options([ 'panasonic_ns1000' => 'Panasonic NS1000', 'panasonic_generic' => 'Panasonic generico', 'wildix' => 'Wildix', '3cx' => '3CX', 'custom' => 'Altro / custom', ]) ->default('panasonic_ns1000'), Select::make('impostazioni.pbx.channel') ->label('Canale CRM') ->options([ 'panasonic_csta' => 'Panasonic CSTA / CTI', 'tapi' => 'Windows TAPI', 'hybrid' => 'Ibrido TAPI + CSTA', ]) ->default('hybrid'), Select::make('impostazioni.pbx.bridge_mode') ->label('Modalita bridge') ->options([ 'group-first' => 'Group-first', 'extension-first' => 'Extension-first', 'none' => 'Solo monitoraggio', ]) ->default('group-first'), TextInput::make('impostazioni.pbx.tapi_host') ->label('Host Windows watcher') ->maxLength(255) ->placeholder('Es. ws-panasonic-01'), TextInput::make('impostazioni.pbx.tapi_provider') ->label('Provider TAPI / TSP') ->maxLength(255) ->default('CTSP0000.tsp'), TextInput::make('impostazioni.pbx.watch_extensions') ->label('Interni monitorati') ->maxLength(255) ->placeholder('201,206,601,603,0003') ->helperText('Lista CSV degli interni/gruppi che il watcher Windows deve osservare.'), Toggle::make('impostazioni.pbx.popup_enabled') ->label('Popup CRM attivi') ->default(true), Toggle::make('impostazioni.pbx.click_to_call_enabled') ->label('Click-to-call attivo') ->default(true), Textarea::make('impostazioni.pbx.routing_notes') ->label('Note routing PBX') ->rows(3) ->columnSpanFull(), ]), Section::make('Gruppi di risposta') ->schema([ Repeater::make('impostazioni.pbx.response_groups') ->label('Gruppi di risposta Panasonic') ->defaultItems(0) ->schema([ TextInput::make('extension') ->label('Interno gruppo') ->maxLength(20) ->placeholder('601'), TextInput::make('label') ->label('Descrizione') ->maxLength(120) ->placeholder('Gruppo giorno'), TextInput::make('mode') ->label('Modalita') ->maxLength(60) ->placeholder('giorno / notte / backup'), TextInput::make('target_extension') ->label('Interno di fallback') ->maxLength(20) ->placeholder('201 oppure 206'), ]) ->columns(4) ->columnSpanFull(), ]), Section::make('Linee entranti e DID') ->schema([ Repeater::make('impostazioni.pbx.incoming_lines') ->label('Linee in entrata') ->defaultItems(0) ->schema([ TextInput::make('number') ->label('Numero / DID') ->maxLength(40) ->placeholder('0003'), TextInput::make('label') ->label('Descrizione linea') ->maxLength(120) ->placeholder('Linea amministratore'), TextInput::make('route_group') ->label('Gruppo target') ->maxLength(20) ->placeholder('601 o 603'), TextInput::make('target_extension') ->label('Interno target') ->maxLength(20) ->placeholder('interno amministratore'), Textarea::make('notes') ->label('Note') ->rows(2) ->columnSpanFull(), ]) ->columns(4) ->columnSpanFull(), ]), Section::make('Cruscotto chiamate Panasonic / TAPI') ->schema([ Placeholder::make('pbx_tapi_panel') ->hiddenLabel() ->content(fn() => view('filament.pages.impostazioni.partials.scheda-amministratore-pbx-tapi')), ]), ]), Tab::make('Operativita / Accessi') ->schema([ Section::make('Azioni operative e gestione accessi') ->schema([ Placeholder::make('ops_access_panel') ->hiddenLabel() ->content(fn() => view('filament.pages.impostazioni.partials.scheda-amministratore-operativita-accessi')), ]), ]), ]), ]); } public function runProductionUpgrade(): void { $tag = trim((string) Arr::get($this->data, 'impostazioni.ops.release_tag', '')); if ($tag === '') { Notification::make() ->title('Tag release mancante') ->warning() ->body('Imposta un tag release (es. v1.3.0) prima di aggiornare la produzione.') ->send(); return; } $appDir = trim((string) Arr::get($this->data, 'impostazioni.ops.production_app_dir', '/var/www/netgescon')); $backupDir = trim((string) Arr::get($this->data, 'impostazioni.ops.production_backup_dir', '/var/backups/netgescon')); $skipMigrations = (bool) Arr::get($this->data, 'impostazioni.ops.skip_migrations', false); $cmd = [ 'bash', 'scripts/ops/netgescon-upgrade-apply.sh', '--tag', $tag, '--app-dir', $appDir, '--backup-dir', $backupDir, ]; if ($skipMigrations) { $cmd[] = '--skip-migrations'; } $this->runOpsProcess($cmd, 'Aggiornamento produzione completato', 'Aggiornamento produzione fallito', 3600); } public function reviewUsersToEnable(): void { $this->runOpsProcess( ['bash', 'scripts/ops/netgescon-users-review.sh', '--limit', '200'], 'Controllo utenti completato', 'Controllo utenti fallito', 300, ); } public function runBackupProductionData(): void { $appDir = trim((string) Arr::get($this->data, 'impostazioni.ops.production_app_dir', '/var/www/netgescon')); $remote = trim((string) Arr::get($this->data, 'impostazioni.ops.backup_remote', 'gdrive:NetGesconBackups/prod')); $this->runOpsProcess( ['bash', 'scripts/ops/netgescon-backup-gdrive.sh', '--app-dir', $appDir, '--remote', $remote], 'Backup produzione completato', 'Backup produzione fallito', 3600, ); } public function runRestoreDataNonDistruttivo(): void { $from = trim((string) Arr::get($this->data, 'impostazioni.ops.dev_data_dir', '/home/michele/netgescon/netgescon-laravel/storage/app/amministratori')); $to = trim((string) Arr::get($this->data, 'impostazioni.ops.prod_data_dir', '/var/www/netgescon/storage/app/amministratori')); $dryRun = (bool) Arr::get($this->data, 'impostazioni.ops.restore_dry_run', true); $cmd = ['bash', 'scripts/ops/netgescon-data-sync-nondestructive.sh', '--from', $from, '--to', $to]; if ($dryRun) { $cmd[] = '--dry-run'; } $this->runOpsProcess( $cmd, $dryRun ? 'Restore dry-run completato' : 'Restore non distruttivo completato', 'Restore non distruttivo fallito', 3600, ); } public function runSyncProdUpdate(): void { $src = trim((string) Arr::get($this->data, 'impostazioni.ops.sync_source_dir', '/home/michele/netgescon/netgescon-laravel')); $dst = trim((string) Arr::get($this->data, 'impostazioni.ops.sync_target_dir', '/var/www/netgescon')); $archiveSrc = trim((string) Arr::get($this->data, 'impostazioni.ops.sync_archive_source', '/home/michele/backup-completo')); $archiveDst = trim((string) Arr::get($this->data, 'impostazioni.ops.sync_archive_target', '/mnt/nas-backup-produzione')); $dryRun = (bool) Arr::get($this->data, 'impostazioni.ops.sync_dry_run', true); $cmd = [ 'bash', 'scripts/ops/netgescon-sync-prod-update.sh', '--src', $src, '--dst', $dst, '--archive-src', $archiveSrc, '--archive-dst', $archiveDst, ]; if ($dryRun) { $cmd[] = '--dry-run'; } else { $cmd[] = '--post-deploy'; } $this->runOpsProcess( $cmd, $dryRun ? 'Sync produzione dry-run completato' : 'Sync produzione completato', 'Sync produzione fallito', 7200, ); } public function runBuildProdUpdatePackage(): void { $src = trim((string) Arr::get($this->data, 'impostazioni.ops.package_source_dir', '/home/michele/netgescon/netgescon-laravel')); $compare = trim((string) Arr::get($this->data, 'impostazioni.ops.package_compare_dir', '/var/www/netgescon')); $outDir = trim((string) Arr::get($this->data, 'impostazioni.ops.package_output_dir', '/var/www/netgescon/storage/app/public/ops-packages')); $process = new Process([ 'bash', 'scripts/ops/netgescon-build-update-package.sh', '--src', $src, '--compare-dir', $compare, '--out-dir', $outDir, ], base_path()); $process->setTimeout(3600); $process->run(); $output = trim((string) $process->getOutput()); $error = trim((string) $process->getErrorOutput()); $merged = trim($output . PHP_EOL . $error); $this->opsLastOutput = $merged !== '' ? $merged : '(nessun output)'; if (! $process->isSuccessful()) { Notification::make() ->title('Creazione pacchetto fallita') ->danger() ->body($error !== '' ? mb_substr($error, 0, 400) : 'Controlla output operazioni nella pagina.') ->send(); return; } if (preg_match('/^PACKAGE_PATH=(.+)$/m', $output, $matches) === 1) { $path = trim($matches[1]); if ($path !== '') { $this->lastUpdatePackagePath = $path; $this->lastUpdatePackageName = basename($path); } } Notification::make() ->title('Pacchetto aggiornamento creato') ->success() ->send(); } public function downloadLastUpdatePackage() { $path = trim((string) ($this->lastUpdatePackagePath ?? '')); if ($path === '' || ! is_file($path)) { Notification::make() ->title('Nessun pacchetto disponibile') ->warning() ->body('Genera prima il pacchetto aggiornamento.') ->send(); return null; } return response()->download($path, $this->lastUpdatePackageName ?: basename($path)); } public function runBackupModifiedDataToGdrive(): void { $source = trim((string) Arr::get($this->data, 'impostazioni.ops.data_backup_source', '/var/www/netgescon/storage/app/amministratori')); $remote = trim((string) Arr::get($this->data, 'impostazioni.ops.data_backup_remote', 'gdrive:NetGesconBackups/prod/data-incremental')); $this->runOpsProcess( ['bash', 'scripts/ops/netgescon-data-backup-incremental-gdrive.sh', '--source', $source, '--remote', $remote], 'Backup incrementale dati su Google Drive completato', 'Backup incrementale dati su Google Drive fallito', 3600, ); } public function runGoogleDriveCheck(): void { $remote = trim((string) Arr::get($this->data, 'impostazioni.ops.data_backup_remote', 'gdrive:NetGesconBackups/prod/data-incremental')); $remoteName = Str::before($remote, ':'); $this->runOpsProcess( ['bash', 'scripts/ops/netgescon-gdrive-check.sh', '--remote', $remoteName !== '' ? $remoteName : 'gdrive'], 'Connessione Google Drive verificata', 'Connessione Google Drive non valida', 120, ); } public function runGoogleOAuthReadinessCheck(): void { $this->runOpsProcess( ['bash', 'scripts/ops/netgescon-google-check.sh'], 'Controllo Google OAuth completato', 'Controllo Google OAuth fallito', 120, ); } public function connectGoogle(): void { $google = Arr::get($this->amministratore->impostazioni ?? [], 'google', []); $clientId = trim((string) ($google['client_id'] ?? config('services.google.client_id'))); $clientSecret = trim((string) ($google['client_secret'] ?? config('services.google.client_secret'))); $redirectUri = trim((string) ($google['redirect_uri'] ?? config('services.google.redirect'))); if ($clientId === '' || $clientSecret === '' || $redirectUri === '') { Notification::make() ->title('Configura prima Google OAuth') ->warning() ->body('Compila Client ID, Client Secret e Redirect URI nella tab Posta / API, poi salva la scheda.') ->send(); return; } // In azioni Livewire/Filament usiamo redirectRoute per evitare mismatch di tipo. $this->redirectRoute('oauth.google.redirect'); } public function disconnectGoogle(): void { $impostazioni = $this->amministratore->impostazioni ?? []; $google = Arr::get($impostazioni, 'google', []); $google['oauth'] = [ 'connected' => false, 'disconnected_at' => now()->toDateTimeString(), ]; $impostazioni['google'] = $google; $this->amministratore->impostazioni = $impostazioni; $this->amministratore->save(); Notification::make() ->title('Google scollegato') ->success() ->send(); } /** * @return array> */ public function getAccessoFornitoriRowsProperty(): array { return FornitoreDipendente::query() ->with(['user.roles', 'fornitore']) ->whereHas('fornitore', function ($q): void { $q->where('amministratore_id', (int) $this->amministratore->id); }) ->orderByDesc('attivo') ->orderBy('cognome') ->orderBy('nome') ->limit(120) ->get() ->map(function (FornitoreDipendente $dipendente): array { $user = $dipendente->user; return [ 'dipendente_id' => (int) $dipendente->id, 'fornitore' => (string) ($dipendente->fornitore?->ragione_sociale ?: $dipendente->fornitore?->nome ?: 'Fornitore'), 'nome' => $dipendente->nome_completo, 'email' => (string) ($dipendente->email ?? ''), 'attivo' => (bool) $dipendente->attivo, 'user_id' => $user ? (int) $user->id : null, 'ruoli' => $user ? $user->roles->pluck('name')->values()->all() : [], ]; }) ->all(); } /** * @return array> */ public function getFornitoriDaTicketRowsProperty(): array { $fornitoreIds = Ticket::query() ->whereNotNull('assegnato_a_fornitore_id') ->whereHas('stabile', function ($q): void { $q->where('amministratore_id', (int) $this->amministratore->id); }) ->distinct() ->pluck('assegnato_a_fornitore_id') ->filter(fn($v) => is_numeric($v)) ->map(fn($v) => (int) $v) ->values() ->all(); if ($fornitoreIds === []) { return []; } return Fornitore::query() ->whereIn('id', $fornitoreIds) ->orderByRaw("COALESCE(ragione_sociale, nome, '')") ->get() ->map(function (Fornitore $fornitore): array { $linkedDipendente = FornitoreDipendente::query() ->where('fornitore_id', (int) $fornitore->id) ->whereNotNull('user_id') ->first(); return [ 'fornitore_id' => (int) $fornitore->id, 'fornitore_nome' => (string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? '')) ?: ('Fornitore #' . $fornitore->id)), 'email' => (string) ($fornitore->email ?? ''), 'telefono' => (string) ($fornitore->telefono ?: $fornitore->cellulare ?: ''), 'linked_user_id' => $linkedDipendente ? (int) $linkedDipendente->user_id : null, ]; }) ->values() ->all(); } public function abilitaAccessoFornitoreDaTicket(int $fornitoreId): void { $fornitore = Fornitore::query()->find($fornitoreId); if (! $fornitore || (int) ($fornitore->amministratore_id ?? 0) !== (int) $this->amministratore->id) { Notification::make()->title('Fornitore non valido')->danger()->send(); return; } $email = trim((string) ($fornitore->email ?? '')); if ($email === '') { Notification::make() ->title('Email fornitore mancante') ->warning() ->body('Inserisci email del fornitore per creare credenziali di accesso.') ->send(); return; } $dipendente = FornitoreDipendente::query() ->where('fornitore_id', (int) $fornitore->id) ->whereRaw('LOWER(email) = ?', [mb_strtolower($email)]) ->first(); if (! $dipendente) { $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->abilitaAccessoFornitore((int) $dipendente->id); } /** * @return array> */ public function getPendingUsersReviewRowsProperty(): array { $output = (string) ($this->opsLastOutput ?? ''); if ($output === '' || ! str_contains($output, 'PENDING_USERS=')) { return []; } $rows = []; $lines = preg_split('/\r\n|\r|\n/', $output) ?: []; foreach ($lines as $line) { $line = trim((string) $line); if (! str_starts_with($line, 'id=')) { continue; } preg_match('/id=(\d+)/', $line, $idMatch); preg_match('/email=([^|]+)/', $line, $emailMatch); preg_match('/name=([^|]+)/', $line, $nameMatch); preg_match('/roles=([^|]+)/', $line, $rolesMatch); preg_match('/flags=([^|]+)/', $line, $flagsMatch); preg_match('/created=(.+)$/', $line, $createdMatch); $rows[] = [ 'id' => isset($idMatch[1]) ? trim($idMatch[1]) : '-', 'email' => isset($emailMatch[1]) ? trim($emailMatch[1]) : '-', 'name' => isset($nameMatch[1]) ? trim($nameMatch[1]) : '-', 'roles' => isset($rolesMatch[1]) ? trim($rolesMatch[1]) : '-', 'flags' => isset($flagsMatch[1]) ? trim($flagsMatch[1]) : '-', 'created' => isset($createdMatch[1]) ? trim($createdMatch[1]) : '-', ]; } return $rows; } /** * @return array> */ public function getAccessoAmministratoreRowsProperty(): array { $query = User::query() ->with('roles') ->whereHas('roles', function ($q): void { $q->whereIn('name', ['super-admin', 'admin', 'amministratore']); }) ->where(function ($q): void { $q->whereKey((int) ($this->amministratore->user_id ?? 0)) ->orWhereHas('amministratore', function ($qa): void { $qa->whereKey((int) $this->amministratore->id); }); }) ->orderBy('name') ->limit(40); return $query ->get() ->map(fn(User $user): array=> [ 'user_id' => (int) $user->id, 'name' => (string) $user->name, 'email' => (string) $user->email, 'ruoli' => $user->roles->pluck('name')->values()->all(), ]) ->all(); } /** * @return array> */ public function getCollaboratoriCentralinoRowsProperty(): array { $mappings = (array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx.user_mappings', []); $query = User::query() ->with('roles') ->whereHas('roles', function ($q): void { $q->where('name', 'collaboratore'); }) ->whereHas('stabiliAssegnati', function ($q): void { $q->where('amministratore_id', (int) $this->amministratore->id); }) ->orderBy('name') ->limit(200); return $query ->get() ->map(function (User $user): array { $mapping = (array) ($mappings[(string) $user->id] ?? []); $extensions = array_values(array_filter((array) ($mapping['extensions'] ?? []), fn($value): bool => is_string($value) && trim($value) !== '')); $groups = array_values(array_filter((array) ($mapping['groups'] ?? []), fn($value): bool => is_string($value) && trim($value) !== '')); $lines = array_values(array_filter((array) ($mapping['lines'] ?? []), fn($value): bool => is_string($value) && trim($value) !== '')); if ($extensions === [] && filled($user->pbx_extension)) { $extensions = [(string) $user->pbx_extension]; } $this->collaboratorePbxExtension[(int) $user->id] = (string) ($user->pbx_extension ?? ''); $this->collaboratorePbxExtensions[(int) $user->id] = implode(', ', $extensions); $this->collaboratorePbxGroups[(int) $user->id] = implode(', ', $groups); $this->collaboratorePbxLines[(int) $user->id] = implode(', ', $lines); $this->collaboratorePbxPopupEnabled[(int) $user->id] = (bool) ($user->pbx_popup_enabled ?? false); $this->collaboratorePbxClickToCallEnabled[(int) $user->id] = (bool) ($user->pbx_click_to_call_enabled ?? false); return [ 'user_id' => (int) $user->id, 'name' => (string) $user->name, 'email' => (string) $user->email, 'ruoli' => $user->roles->pluck('name')->values()->all(), 'pbx_extension' => (string) ($user->pbx_extension ?? ''), 'pbx_extensions' => $this->collaboratorePbxExtensions[(int) $user->id], 'pbx_groups' => $this->collaboratorePbxGroups[(int) $user->id], 'pbx_lines' => $this->collaboratorePbxLines[(int) $user->id], 'pbx_popup_enabled' => (bool) ($user->pbx_popup_enabled ?? false), 'pbx_click_to_call_enabled' => (bool) ($user->pbx_click_to_call_enabled ?? false), ]; }) ->all(); } /** * @return array{extensions:array,groups:array,lines:array} */ public function getPbxObservedTokensProperty(): array { if (! Schema::hasTable('communication_messages')) { return [ 'extensions' => [], 'groups' => [], 'lines' => [], ]; } $rows = $this->pbxRawMessagesQuery() ->latest('id') ->limit(300) ->get(['target_extension', 'message_text', 'metadata']); $extensions = []; $groups = []; $lines = []; foreach ($rows as $message) { $metadata = (array) ($message->metadata ?? []); $this->collectPbxTokens($extensions, [ (string) ($message->target_extension ?? ''), (string) data_get($metadata, 'called_extension', ''), (string) data_get($metadata, 'source_extension', ''), ], '/\b(?:EXT\d+|\d{2,5})\b/i'); $this->collectPbxTokens($groups, [ (string) data_get($metadata, 'message_text', ''), (string) ($message->message_text ?? ''), ], '/\bGRP\d+\b/i'); $this->collectPbxTokens($lines, [ (string) data_get($metadata, 'smdr.co', ''), (string) data_get($metadata, 'line', ''), (string) data_get($metadata, 'did', ''), ], '/\b(?:\d{3,6}|EXT\d+|GRP\d+)\b/i'); } return [ 'extensions' => array_slice(array_values(array_unique($extensions)), 0, 24), 'groups' => array_slice(array_values(array_unique($groups)), 0, 24), 'lines' => array_slice(array_values(array_unique($lines)), 0, 24), ]; } /** * @return array{status:string,label:string,provider:string,channel:string,bridge_mode:string,watched_extensions:array,response_groups:array>,incoming_lines:array>,last_event_at:?string,last_event_type:?string,total_messages:int,last_24h:int,missed_calls:int,assessment:string} */ public function getPbxSummaryProperty(): array { $settings = (array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', []); $provider = (string) ($settings['provider'] ?? 'panasonic_ns1000'); $channel = (string) ($settings['channel'] ?? 'hybrid'); $bridgeMode = (string) ($settings['bridge_mode'] ?? 'group-first'); $watchedExtensions = $this->resolveWatchedPbxExtensions($settings); $responseGroups = array_values(array_filter((array) ($settings['response_groups'] ?? []), fn($row): bool => is_array($row))); $incomingLines = array_values(array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row))); if (! Schema::hasTable('communication_messages')) { return [ 'status' => ($watchedExtensions !== [] || $responseGroups !== [] || $incomingLines !== []) ? 'configured' : 'to-configure', 'label' => ($watchedExtensions !== [] || $responseGroups !== [] || $incomingLines !== []) ? 'Configurato, in attesa eventi' : 'Da configurare', 'provider' => $provider, 'channel' => $channel, 'bridge_mode' => $bridgeMode, 'watched_extensions' => $watchedExtensions, 'response_groups' => $responseGroups, 'incoming_lines' => $incomingLines, 'last_event_at' => null, 'last_event_type' => null, 'total_messages' => 0, 'last_24h' => 0, 'missed_calls' => 0, 'assessment' => 'La tabella communication_messages non e disponibile in questo ambiente, quindi il cruscotto mostra solo la configurazione PBX.', ]; } $query = $this->pbxMessagesQuery(); $rawQuery = $this->pbxRawMessagesQuery(); $latest = (clone $query)->orderByDesc('received_at')->orderByDesc('id')->first(); $total = (clone $query)->count(); $last24h = (clone $query)->where('received_at', '>=', now()->subDay())->count(); $missedCalls = (clone $rawQuery)->where(function ($q): void { $q->where('message_text', 'like', '%persa%') ->orWhere('metadata->outcome', 'like', '%miss%') ->orWhere('metadata->outcome', 'like', '%no_answer%'); })->count(); $status = 'to-configure'; $label = 'Da configurare'; if ($total > 0) { $status = 'active'; $label = 'CRM collegato'; } elseif ($watchedExtensions !== [] || $responseGroups !== [] || $incomingLines !== []) { $status = 'configured'; $label = 'Configurato, in attesa eventi'; } $assessment = $bridgeMode === 'group-first' ? 'Con modalita group-first il watcher Windows puo vedere eventi sugli interni singoli ma il bridge usa come sorgente autorevole i gruppi di risposta. Questo e coerente con i log Panasonic gia acquisiti.' : 'La configurazione e predisposta per usare gli eventi PBX direttamente nel CRM. Verifica i log del watcher Windows per confermare il flusso sul canale selezionato.'; return [ 'status' => $status, 'label' => $label, 'provider' => $provider, 'channel' => $channel, 'bridge_mode' => $bridgeMode, 'watched_extensions' => $watchedExtensions, 'response_groups' => $responseGroups, 'incoming_lines' => $incomingLines, 'last_event_at' => $latest?->received_at?->format('d/m/Y H:i:s'), 'last_event_type' => $latest ? (string) data_get($latest->metadata, 'event_type', $latest->status) : null, 'total_messages' => $total, 'last_24h' => $last24h, 'missed_calls' => $missedCalls, 'assessment' => $assessment, ]; } /** * @return array> */ public function getPbxRecentMessagesProperty(): array { if (! Schema::hasTable('communication_messages')) { return []; } return $this->pbxMessagesQuery() ->with('assignedUser:id,name') ->orderByDesc('received_at') ->orderByDesc('id') ->limit(12) ->get(['id', 'phone_number', 'target_extension', 'assigned_user_id', 'direction', 'status', 'received_at', 'metadata']) ->map(fn(CommunicationMessage $message): array=> [ 'id' => (int) $message->id, 'received_at' => $message->received_at?->format('d/m/Y H:i:s') ?? '-', 'phone_number' => (string) ($message->phone_number ?? '-'), 'target_extension' => (string) ($message->target_extension ?? data_get($message->metadata, 'called_extension', '-')), 'direction' => (string) ($message->direction ?? '-'), 'status' => (string) ($message->status ?? '-'), 'event_type' => (string) data_get($message->metadata, 'event_type', '-'), 'provider' => (string) data_get($message->metadata, 'provider', $message->channel), 'user' => (string) ($message->assignedUser?->name ?? '-'), 'studio_role' => (string) data_get($message->metadata, 'studio_role', '-'), 'studio_mode' => (string) data_get($message->metadata, 'studio_mode', '-'), ]) ->all(); } /** * @return array> */ public function getPbxRoutingRowsProperty(): array { $settings = (array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', []); $centralino = (array) Arr::get($this->amministratore->impostazioni ?? [], 'centralino', []); $groupRows = array_values(array_filter((array) ($settings['response_groups'] ?? []), fn($row): bool => is_array($row))); $incoming = array_values(array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row))); $routingRows = [ [ 'type' => 'Studio', 'label' => 'Interno centralino', 'extension' => (string) Arr::get($centralino, 'interno_centralino', '-'), 'target' => 'Smistamento studio', 'note' => (string) Arr::get($centralino, 'numero_principale', '-'), ], [ 'type' => 'Studio', 'label' => 'Operatore', 'extension' => (string) Arr::get($centralino, 'interno_operatore', '-'), 'target' => 'Utente operatore', 'note' => 'Instradamento diretto', ], [ 'type' => 'Studio', 'label' => 'Amministratore', 'extension' => (string) Arr::get($centralino, 'interno_amministratore', '-'), 'target' => 'Utente amministratore', 'note' => 'Instradamento diretto', ], [ 'type' => 'Gruppo', 'label' => 'Gruppo giorno', 'extension' => (string) Arr::get($centralino, 'interno_gruppo_giorno', '-'), 'target' => 'Modalita giorno', 'note' => 'Routing shared', ], [ 'type' => 'Gruppo', 'label' => 'Gruppo notte', 'extension' => (string) Arr::get($centralino, 'interno_gruppo_notte', '-'), 'target' => 'Modalita notte', 'note' => 'Routing shared', ], ]; foreach ($groupRows as $row) { $routingRows[] = [ 'type' => 'Gruppo', 'label' => (string) ($row['label'] ?? 'Gruppo risposta'), 'extension' => (string) ($row['extension'] ?? '-'), 'target' => (string) ($row['target_extension'] ?? '-'), 'note' => (string) ($row['mode'] ?? '-'), ]; } foreach ($incoming as $row) { $routingRows[] = [ 'type' => 'Linea', 'label' => (string) ($row['label'] ?? 'Linea entrante'), 'extension' => (string) ($row['number'] ?? '-'), 'target' => trim((string) (($row['route_group'] ?? '') !== '' ? $row['route_group'] : ($row['target_extension'] ?? '-'))), 'note' => (string) ($row['notes'] ?? '-'), ]; } return $routingRows; } private function pbxRawMessagesQuery() { $settings = (array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', []); $channels = $this->resolvePbxChannels($settings); $extensions = $this->resolveWatchedPbxExtensions((array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', [])); return CommunicationMessage::query() ->whereIn('channel', $channels) ->whereIn('direction', ['inbound', 'outbound']) ->where(function ($q) use ($extensions): void { $q->where('metadata->amministratore_id', (int) $this->amministratore->id); if ($extensions !== [] && Schema::hasColumn('communication_messages', 'target_extension')) { $q->orWhereIn('target_extension', $extensions); } }); } private function pbxMessagesQuery() { return $this->pbxRawMessagesQuery() ->where(function ($q): void { $q->whereNull('metadata->outcome') ->orWhere(function ($inner): void { $inner->where('metadata->outcome', 'not like', '%miss%') ->where('metadata->outcome', 'not like', '%no_answer%'); }); }) ->where(function ($q): void { $q->whereNull('message_text') ->orWhere('message_text', 'not like', '%persa%'); }); } /** * @param array $settings * @return array */ private function resolvePbxChannels(array $settings): array { $channel = mb_strtolower(trim((string) ($settings['channel'] ?? 'hybrid'))); return match ($channel) { 'panasonic', 'panasonic_csta', 'csta' => ['panasonic_csta'], 'smdr' => ['smdr'], default => ['panasonic_csta', 'smdr'], }; } /** * @return array */ private function resolveWatchedPbxExtensions(array $settings): array { $centralinoExtensions = [ (string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_centralino', ''), (string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_operatore', ''), (string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_amministratore', ''), (string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_gruppo_giorno', ''), (string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_gruppo_notte', ''), ]; $csvExtensions = preg_split('/\s*,\s*/', (string) ($settings['watch_extensions'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; $groupExtensions = array_map(fn($row): string => (string) ($row['extension'] ?? ''), array_filter((array) ($settings['response_groups'] ?? []), fn($row): bool => is_array($row))); $lineTargets = array_map(fn($row): string => (string) (($row['target_extension'] ?? '') !== '' ? $row['target_extension'] : ($row['route_group'] ?? '')), array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row))); return array_values(array_unique(array_filter(array_map( fn($value): string => preg_replace('/\D+/', '', (string) $value) ?: '', array_merge($centralinoExtensions, $csvExtensions, $groupExtensions, $lineTargets) )))); } public function salvaInternoCollaboratore(int $userId): void { $user = User::query()->find($userId); if (! $user) { Notification::make()->title('Utente non trovato')->danger()->send(); return; } $isCollaboratoreDelTenant = $user->hasRole('collaboratore') && $user->stabiliAssegnati()->where('amministratore_id', (int) $this->amministratore->id)->exists(); if (! $isCollaboratoreDelTenant) { Notification::make()->title('Collaboratore non valido')->danger()->send(); return; } $extensions = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxExtensions[$userId] ?? $this->collaboratorePbxExtension[$userId] ?? '')); $groups = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxGroups[$userId] ?? '')); $lines = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxLines[$userId] ?? '')); $user->pbx_extension = $extensions[0] ?? null; $user->pbx_popup_enabled = (bool) ($this->collaboratorePbxPopupEnabled[$userId] ?? false); $user->pbx_click_to_call_enabled = (bool) ($this->collaboratorePbxClickToCallEnabled[$userId] ?? false); $user->save(); $impostazioni = $this->amministratore->impostazioni ?? []; $pbx = (array) Arr::get($impostazioni, 'pbx', []); $mappings = (array) ($pbx['user_mappings'] ?? []); $mappings[(string) $userId] = [ 'extensions' => $extensions, 'groups' => $groups, 'lines' => $lines, ]; $pbx['user_mappings'] = $mappings; $impostazioni['pbx'] = $pbx; $this->amministratore->impostazioni = $impostazioni; $this->amministratore->save(); Notification::make() ->title('Mappatura PBX collaboratore aggiornata') ->success() ->send(); } /** * @param array $target * @param array $values */ private function collectPbxTokens(array &$target, array $values, string $pattern): void { foreach ($values as $value) { if ($value === '') { continue; } if (preg_match_all($pattern, strtoupper($value), $matches) > 0) { foreach ((array) ($matches[0] ?? []) as $match) { $token = trim((string) $match); if ($token !== '') { $target[] = $token; } } continue; } $token = trim(strtoupper($value)); if ($token !== '') { $target[] = $token; } } } /** * @return array */ private function normalizePbxMappingInput(string $value): array { $tokens = preg_split('/[\s,;]+/', strtoupper(trim($value)), -1, PREG_SPLIT_NO_EMPTY) ?: []; return array_values(array_unique(array_filter(array_map(function ($token): string { $clean = preg_replace('/[^A-Z0-9_-]/', '', (string) $token) ?? ''; return trim($clean); }, $tokens), fn(string $token): bool => $token !== ''))); } public function abilitaAccessoFornitore(int $dipendenteId): void { $dipendente = FornitoreDipendente::query() ->with('fornitore') ->whereKey($dipendenteId) ->first(); if (! $dipendente || (int) ($dipendente->fornitore?->amministratore_id ?? 0) !== (int) $this->amministratore->id) { Notification::make()->title('Dipendente non valido')->danger()->send(); return; } $email = trim((string) ($dipendente->email ?? '')); if ($email === '') { Notification::make() ->title('Email mancante') ->warning() ->body('Imposta prima un\'email per il dipendente fornitore.') ->send(); return; } $user = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first(); $created = false; if (! $user) { $createdPassword = $this->generateTemporaryPassword(); $user = User::query()->create([ 'name' => trim((string) ($dipendente->nome_completo ?: 'Utente Fornitore')), 'email' => $email, 'password' => Hash::make($createdPassword), 'email_verified_at' => now(), 'is_active' => true, ]); $this->lastGeneratedPassword = $createdPassword; $created = true; } $user->assignRole('fornitore'); $dipendente->user_id = (int) $user->id; $dipendente->save(); $body = $created ? 'Accesso creato. Password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)') : 'Accesso attivo/aggiornato. Se necessario, usa il reset password.'; Notification::make() ->title('Accesso fornitore abilitato') ->success() ->body($body) ->send(); } public function resetPasswordUtente(int $userId): void { $user = User::query()->find($userId); if (! $user) { Notification::make()->title('Utente non trovato')->danger()->send(); return; } $isAllowedAdminUser = (int) $user->id === (int) ($this->amministratore->user_id ?? 0); $isAllowedSupplierUser = FornitoreDipendente::query() ->where('user_id', (int) $user->id) ->whereHas('fornitore', function ($q): void { $q->where('amministratore_id', (int) $this->amministratore->id); }) ->exists(); if (! $isAllowedAdminUser && ! $isAllowedSupplierUser) { Notification::make() ->title('Operazione non consentita') ->danger() ->body('Puoi resettare solo utenti collegati al tuo gruppo amministratore/fornitore.') ->send(); return; } $newPassword = $this->generateTemporaryPassword(); $user->password = Hash::make($newPassword); $user->save(); $this->lastGeneratedPassword = $newPassword; Notification::make() ->title('Password resettata') ->success() ->body('Nuova password temporanea: ' . $newPassword) ->send(); } private function generateTemporaryPassword(): string { return Str::random(12); } private function runOpsProcess(array $command, string $successTitle, string $errorTitle, int $timeoutSeconds): void { $process = new Process($command, base_path()); $process->setTimeout($timeoutSeconds); $process->run(); $output = trim((string) $process->getOutput()); $error = trim((string) $process->getErrorOutput()); $merged = trim($output . PHP_EOL . $error); $this->opsLastOutput = $merged !== '' ? $merged : '(nessun output)'; if ($process->isSuccessful()) { Notification::make() ->title($successTitle) ->success() ->send(); return; } Notification::make() ->title($errorTitle) ->danger() ->body($error !== '' ? mb_substr($error, 0, 400) : 'Controlla output operazioni nella pagina.') ->send(); } public function submit(): void { $state = $this->getSchema('form')?->getState() ?? []; // In alcune configurazioni Filament, con statePath('data') getState() può restituire ['data' => ...]. // Normalizziamo per essere robusti. if (is_array($state['data'] ?? null)) { $state = $state['data']; } elseif (is_array($this->data ?? null) && ! empty($this->data)) { $state = $this->data; } $fillable = [ 'nome', 'cognome', 'denominazione_studio', 'partita_iva', 'codice_fiscale_studio', 'indirizzo_studio', 'cap_studio', 'citta_studio', 'provincia_studio', 'telefono_studio', 'cellulare', 'email_studio', 'pec_studio', 'fe_cassetto_enabled', 'fe_cassetto_base_url', ]; $update = Arr::only($state, $fillable); // Segreti: sovrascrivi solo se compilati. foreach (['fe_cassetto_password_script', 'fe_cassetto_username', 'fe_cassetto_password', 'fe_cassetto_pin'] as $k) { if (isset($state[$k]) && filled($state[$k])) { $update[$k] = (string) $state[$k]; } } $this->amministratore->fill($update); $this->amministratore->impostazioni = is_array($state['impostazioni'] ?? null) ? $state['impostazioni'] : []; $this->amministratore->save(); Notification::make() ->title('Scheda salvata') ->success() ->send(); } }