From 07c440fad4e08a791b2c85b005a94e1bfde8d8b7 Mon Sep 17 00:00:00 2001 From: michele Date: Sun, 15 Mar 2026 19:01:19 +0000 Subject: [PATCH] feat(release): prepare staging 0.8.1 update rollout --- VERSION | 2 +- .../Commands/GesconRubricaDedupCommand.php | 239 ++-- .../NetgesconDistributionPullCommand.php | 117 +- .../NetgesconPreupdateBackupCommand.php | 456 +++++++ .../NetgesconRestoreRecordCommand.php | 138 ++ app/Console/Commands/SmdrListenCommand.php | 495 +++++++ app/Filament/Pages/Gescon/FornitoreScheda.php | 221 +++- .../Pages/Gescon/FornitoriArchivio.php | 506 +++++++- .../Gescon/RubricaUniversaleArchivio.php | 57 +- .../Pages/Gescon/RubricaUniversaleScheda.php | 344 ++++- .../Impostazioni/SchedaAmministratore.php | 375 ++++++ .../PrecedentiAmministratoriDettaglio.php | 1139 ++++++++--------- .../PrecedentiAmministratoriElenco.php | 22 +- .../Pages/Strumenti/PostItGestione.php | 157 ++- app/Filament/Pages/Supporto/Modifiche.php | 984 +++++++++++++- app/Filament/Pages/Supporto/RichiestaHelp.php | 44 +- .../Pages/Supporto/SupportoDashboard.php | 23 + .../Pages/Supporto/TicketGestione.php | 304 ++++- app/Filament/Pages/Supporto/TicketMobile.php | 285 ++++- .../Widgets/GoogleScadenziarioOverview.php | 7 + .../Widgets/GoogleWorkspaceOverview.php | 21 + .../Admin/TicketAttachmentViewController.php | 7 +- .../Controllers/Admin/TicketController.php | 41 + .../Api/CommunicationWebhookController.php | 212 +++ .../Api/PanasonicCstaController.php | 135 +- .../Auth/AuthenticatedSessionController.php | 28 +- .../Auth/RegisteredUserController.php | 92 +- .../Fornitore/DipendenteController.php | 4 +- .../Fornitore/TicketOperativoController.php | 11 +- app/Http/Controllers/ProfileController.php | 91 +- .../SuperAdmin/ImpostazioniController.php | 152 ++- .../Controllers/SuperAdmin/UserController.php | 196 ++- app/Http/Kernel.php | 31 +- .../EnsureEmailIsVerifiedForNetgescon.php | 24 + app/Http/Requests/Auth/LoginRequest.php | 23 +- .../FortifyVerifyEmailViewResponse.php | 21 + app/Models/CommunicationMessage.php | 75 ++ app/Models/InsuranceClaim.php | 36 + app/Models/PaymentMethod.php | 22 + app/Models/PbxClickToCallRequest.php | 30 + app/Models/SubscriptionPlan.php | 24 + app/Models/SystemSetting.php | 40 + app/Models/Ticket.php | 10 + app/Models/User.php | 107 +- app/Providers/AppServiceProvider.php | 7 + .../Filament/AdminFilamentPanelProvider.php | 17 +- app/Support/Security/TotpService.php | 87 ++ bootstrap/app.php | 28 +- config/distribution.php | 4 + config/netgescon.php | 2 +- ...170000_create_subscription_plans_table.php | 48 + ...12_170100_create_payment_methods_table.php | 45 + ...12_170200_create_system_settings_table.php | 32 + ...ccess_governance_fields_to_users_table.php | 38 + ...0_add_two_factor_fields_to_users_table.php | 38 + ...2_181000_create_insurance_claims_table.php | 32 + ...00_create_communication_messages_table.php | 42 + ..._181200_seed_messaging_system_settings.php | 40 + ...ds_to_users_and_communication_messages.php | 73 ++ ...reate_pbx_click_to_call_requests_table.php | 35 + docs/CTI-NS1000-CHECKLIST.md | 55 + docs/SMDR-INTEGRAZIONE-OPERATIVA.md | 53 + docs/support/ONLINE-ERROR-SYNC.md | 41 + resources/views/admin/tickets/show.blade.php | 34 + resources/views/auth/login.blade.php | 12 + resources/views/auth/register.blade.php | 49 +- .../pages/gescon/fornitore-scheda.blade.php | 72 ++ .../rubrica-universale-scheda.blade.php | 168 ++- .../filament/pages/gescon/table.blade.php | 402 +++++- ...ministratore-operativita-accessi.blade.php | 155 +++ .../scheda-amministratore.blade.php | 46 - .../strumenti/post-it-gestione.blade.php | 88 ++ .../pages/supporto/dashboard.blade.php | 10 + .../pages/supporto/modifiche.blade.php | 309 ++++- .../pages/supporto/ticket-gestione.blade.php | 56 +- .../pages/supporto/ticket-mobile.blade.php | 56 +- .../google-workspace-overview.blade.php | 96 +- resources/views/profile/edit.blade.php | 4 + .../two-factor-authentication-form.blade.php | 58 + .../superadmin/impostazioni/index.blade.php | 141 ++ .../views/superadmin/users/create.blade.php | 9 + .../views/superadmin/users/edit.blade.php | 28 + .../views/superadmin/users/index.blade.php | 42 + routes/api.php | 17 + routes/web.php | 12 + .../netgescon-export-online-errors-to-git.sh | 28 + 86 files changed, 8724 insertions(+), 1233 deletions(-) create mode 100644 app/Console/Commands/NetgesconPreupdateBackupCommand.php create mode 100644 app/Console/Commands/NetgesconRestoreRecordCommand.php create mode 100644 app/Console/Commands/SmdrListenCommand.php create mode 100644 app/Filament/Pages/Supporto/SupportoDashboard.php create mode 100644 app/Filament/Widgets/GoogleScadenziarioOverview.php create mode 100644 app/Http/Controllers/Api/CommunicationWebhookController.php create mode 100644 app/Http/Middleware/EnsureEmailIsVerifiedForNetgescon.php create mode 100644 app/Http/Responses/FortifyVerifyEmailViewResponse.php create mode 100644 app/Models/CommunicationMessage.php create mode 100644 app/Models/InsuranceClaim.php create mode 100644 app/Models/PaymentMethod.php create mode 100644 app/Models/PbxClickToCallRequest.php create mode 100644 app/Models/SubscriptionPlan.php create mode 100644 app/Models/SystemSetting.php create mode 100644 app/Support/Security/TotpService.php create mode 100644 database/migrations/2026_03_12_170000_create_subscription_plans_table.php create mode 100644 database/migrations/2026_03_12_170100_create_payment_methods_table.php create mode 100644 database/migrations/2026_03_12_170200_create_system_settings_table.php create mode 100644 database/migrations/2026_03_12_170300_add_access_governance_fields_to_users_table.php create mode 100644 database/migrations/2026_03_12_180000_add_two_factor_fields_to_users_table.php create mode 100644 database/migrations/2026_03_12_181000_create_insurance_claims_table.php create mode 100644 database/migrations/2026_03_12_181100_create_communication_messages_table.php create mode 100644 database/migrations/2026_03_12_181200_seed_messaging_system_settings.php create mode 100644 database/migrations/2026_03_12_235500_add_pbx_fields_to_users_and_communication_messages.php create mode 100644 database/migrations/2026_03_12_235600_create_pbx_click_to_call_requests_table.php create mode 100644 docs/CTI-NS1000-CHECKLIST.md create mode 100644 docs/SMDR-INTEGRAZIONE-OPERATIVA.md create mode 100644 docs/support/ONLINE-ERROR-SYNC.md create mode 100644 resources/views/filament/pages/impostazioni/partials/scheda-amministratore-operativita-accessi.blade.php create mode 100644 resources/views/filament/pages/supporto/dashboard.blade.php create mode 100644 resources/views/profile/partials/two-factor-authentication-form.blade.php create mode 100644 resources/views/superadmin/impostazioni/index.blade.php create mode 100755 scripts/ops/netgescon-export-online-errors-to-git.sh diff --git a/VERSION b/VERSION index 6e8bf73..6f4eebd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.8.1 diff --git a/app/Console/Commands/GesconRubricaDedupCommand.php b/app/Console/Commands/GesconRubricaDedupCommand.php index 6c9e40b..a676dac 100644 --- a/app/Console/Commands/GesconRubricaDedupCommand.php +++ b/app/Console/Commands/GesconRubricaDedupCommand.php @@ -1,5 +1,4 @@ argument('amministratore_id'); - if ($adminId <= 0) { - $this->error('amministratore_id non valido'); + $allAdmin = (bool) $this->option('all-admin'); + $adminId = (int) ($this->argument('amministratore_id') ?: 0); + if (! $allAdmin && $adminId <= 0) { + $this->error('amministratore_id non valido (oppure usa --all-admin)'); return self::FAILURE; } - $dry = (bool) $this->option('dry-run'); - $deleteEmpties = (bool) $this->option('delete-empties'); + $dry = (bool) $this->option('dry-run'); + $deleteEmpties = (bool) $this->option('delete-empties'); + $includeNullAdmin = (bool) $this->option('include-null-admin'); - if (!Schema::hasTable('rubrica_universale')) { + if (! Schema::hasTable('rubrica_universale')) { $this->error('Tabella rubrica_universale non disponibile.'); return self::FAILURE; } $stats = [ - 'groups' => 0, - 'duplicates' => 0, - 'merged' => 0, - 'rubrica_soft_deleted' => 0, + 'groups' => 0, + 'duplicates' => 0, + 'merged' => 0, + 'rubrica_soft_deleted' => 0, 'rubrica_empties_soft_deleted' => 0, - 'moved_ruoli' => 0, - 'moved_canali' => 0, - 'moved_fornitori' => 0, - 'moved_stabili' => 0, + 'moved_ruoli' => 0, + 'moved_canali' => 0, + 'moved_fornitori' => 0, + 'moved_stabili' => 0, + 'moved_links_generic' => 0, ]; - $runner = function () use ($adminId, $dry, $deleteEmpties, &$stats): void { - // Carica i contatti dell'admin (escludi già soft-deleted per evitare merge strani). - $rubriche = RubricaUniversale::query() - ->where('amministratore_id', $adminId) - ->whereNull('deleted_at') + $runner = function () use ($adminId, $allAdmin, $includeNullAdmin, $dry, $deleteEmpties, &$stats): void { + // Carica i contatti target (escludi già soft-deleted per evitare merge strani). + $rubricheQuery = RubricaUniversale::query()->whereNull('deleted_at'); + if (! $allAdmin) { + $rubricheQuery->where(function ($q) use ($adminId, $includeNullAdmin): void { + $q->where('amministratore_id', $adminId); + if ($includeNullAdmin) { + $q->orWhereNull('amministratore_id'); + } + }); + } + + $rubriche = $rubricheQuery ->get([ 'id', 'codice_univoco', + 'amministratore_id', 'tipo_contatto', 'ragione_sociale', 'nome', @@ -108,72 +121,93 @@ public function handle(): int continue; } - DB::transaction(function () use ($adminId, $master, $dup, &$stats): void { - // 1) Merge campi (non sovrascrivere i valori del master, riempi solo i null/vuoti) - $payload = $this->mergeRubricaPayload($master, $dup); - if (!empty($payload)) { - $master->fill($payload); - if ($master->isDirty()) { - $master->save(); - } + // 1) Merge campi (non sovrascrivere i valori del master, riempi solo i null/vuoti) + $payload = $this->mergeRubricaPayload($master, $dup); + if (! empty($payload)) { + $master->fill($payload); + if ($master->isDirty()) { + $master->save(); } + } - // 2) Sposta relazioni verso master - if (Schema::hasTable('rubrica_ruoli')) { - $stats['moved_ruoli'] += RubricaRuolo::query() - ->where('rubrica_id', (int) $dup->id) - ->update(['rubrica_id' => (int) $master->id]); + // 2) Sposta relazioni verso master + if (Schema::hasTable('rubrica_ruoli')) { + $stats['moved_ruoli'] += RubricaRuolo::query() + ->where('rubrica_id', (int) $dup->id) + ->update(['rubrica_id' => (int) $master->id]); + } + + if (Schema::hasTable('rubrica_contatto_canali')) { + $stats['moved_canali'] += RubricaContattoCanale::query() + ->where('rubrica_id', (int) $dup->id) + ->update(['rubrica_id' => (int) $master->id]); + } + + if (Schema::hasTable('fornitori')) { + $fornitoriMove = Fornitore::query()->where('rubrica_id', (int) $dup->id); + if (! $allAdmin && $adminId > 0) { + $fornitoriMove->where(function ($q) use ($adminId, $includeNullAdmin): void { + $q->where('amministratore_id', $adminId); + if ($includeNullAdmin) { + $q->orWhereNull('amministratore_id'); + } + }); } + $stats['moved_fornitori'] += $fornitoriMove->update(['rubrica_id' => (int) $master->id]); + } - if (Schema::hasTable('rubrica_contatto_canali')) { - $stats['moved_canali'] += RubricaContattoCanale::query() - ->where('rubrica_id', (int) $dup->id) - ->update(['rubrica_id' => (int) $master->id]); + if (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id')) { + $stabiliMove = Stabile::query()->where('rubrica_id', (int) $dup->id); + if (! $allAdmin && $adminId > 0) { + $stabiliMove->where('amministratore_id', $adminId); } + $stats['moved_stabili'] += $stabiliMove->update(['rubrica_id' => (int) $master->id]); + } - if (Schema::hasTable('fornitori')) { - $stats['moved_fornitori'] += Fornitore::query() - ->where('amministratore_id', $adminId) - ->where('rubrica_id', (int) $dup->id) - ->update(['rubrica_id' => (int) $master->id]); - } + $stats['moved_links_generic'] += $this->moveGenericRubricaLinks( + (int) $dup->id, + (int) $master->id, + $adminId, + $allAdmin, + $includeNullAdmin, + ); - if (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id')) { - $stats['moved_stabili'] += Stabile::query() - ->where('amministratore_id', $adminId) - ->where('rubrica_id', (int) $dup->id) - ->update(['rubrica_id' => (int) $master->id]); - } + // 3) Soft-delete duplicato + $note = trim((string) ($dup->note ?? '')); + $suffix = 'Merged into rubrica_id=' . (int) $master->id; + if ($note === '') { + $dup->note = $suffix; + } elseif (! str_contains($note, $suffix)) { + $dup->note = mb_substr($note . "\n" . $suffix, 0, 65535); + } - // 3) Soft-delete duplicato - $note = trim((string) ($dup->note ?? '')); - $suffix = 'Merged into rubrica_id=' . (int) $master->id; - if ($note === '') { - $dup->note = $suffix; - } elseif (!str_contains($note, $suffix)) { - $dup->note = mb_substr($note . "\n" . $suffix, 0, 65535); - } - - $dup->save(); - $dup->delete(); - $stats['rubrica_soft_deleted']++; - $stats['merged']++; - }); + $dup->save(); + $dup->delete(); + $stats['rubrica_soft_deleted']++; + $stats['merged']++; } } - if (!$deleteEmpties) { + if (! $deleteEmpties) { return; } // Soft-delete contatti completamente vuoti e non collegati a nulla. - $empties = RubricaUniversale::query() - ->where('amministratore_id', $adminId) - ->whereNull('deleted_at') + $emptiesQ = RubricaUniversale::query()->whereNull('deleted_at'); + if (! $allAdmin) { + $emptiesQ->where(function ($q) use ($adminId, $includeNullAdmin): void { + $q->where('amministratore_id', $adminId); + if ($includeNullAdmin) { + $q->orWhereNull('amministratore_id'); + } + }); + } + + $empties = $emptiesQ ->get(['id', 'ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email', 'pec', 'telefono_ufficio', 'telefono_cellulare', 'telefono_casa']); foreach ($empties as $r) { - if (!$this->isRubricaEmptyIdentity($r)) { + if (! $this->isRubricaEmptyIdentity($r)) { continue; } @@ -184,10 +218,10 @@ public function handle(): int ? RubricaContattoCanale::query()->where('rubrica_id', (int) $r->id)->exists() : false; $hasFornitori = Schema::hasTable('fornitori') - ? Fornitore::query()->where('amministratore_id', $adminId)->where('rubrica_id', (int) $r->id)->exists() + ? Fornitore::query()->where('rubrica_id', (int) $r->id)->exists() : false; $hasStabili = (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id')) - ? Stabile::query()->where('amministratore_id', $adminId)->where('rubrica_id', (int) $r->id)->exists() + ? Stabile::query()->where('rubrica_id', (int) $r->id)->exists() : false; if ($hasRuoli || $hasCanali || $hasFornitori || $hasStabili) { @@ -195,7 +229,7 @@ public function handle(): int } $stats['rubrica_empties_soft_deleted']++; - if (!$dry) { + if (! $dry) { $r->note = trim(((string) ($r->note ?? '')) . "\n" . 'Auto-clean: empty identity'); $r->save(); $r->delete(); @@ -215,12 +249,57 @@ public function handle(): int return self::SUCCESS; } + private function moveGenericRubricaLinks( + int $fromRubricaId, + int $toRubricaId, + int $adminId, + bool $allAdmin, + bool $includeNullAdmin, + ): int { + if ($fromRubricaId <= 0 || $toRubricaId <= 0 || $fromRubricaId === $toRubricaId) { + return 0; + } + + $db = DB::getDatabaseName(); + $rows = DB::select( + "SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = ? + AND column_name IN ('rubrica_id', 'rubrica_universale_id') + AND table_name <> 'rubrica_universale'", + [$db] + ); + + $moved = 0; + foreach ($rows as $row) { + $table = (string) ($row->TABLE_NAME ?? ''); + $column = (string) ($row->COLUMN_NAME ?? ''); + if ($table === '' || $column === '' || ! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) { + continue; + } + + $query = DB::table($table)->where($column, $fromRubricaId); + if (! $allAdmin && $adminId > 0 && Schema::hasColumn($table, 'amministratore_id')) { + $query->where(function ($q) use ($adminId, $includeNullAdmin): void { + $q->where('amministratore_id', $adminId); + if ($includeNullAdmin) { + $q->orWhereNull('amministratore_id'); + } + }); + } + + $moved += (int) $query->update([$column => $toRubricaId]); + } + + return $moved; + } + /** @return list */ private function buildIdentityKeys(RubricaUniversale $r): array { $keys = []; - $cf = $this->normalizeCf($r->codice_fiscale); + $cf = $this->normalizeCf($r->codice_fiscale); $piva = $this->normalizeCf($r->partita_iva); $fiscal = $this->normalizeFiscalIds($cf ?: $piva); @@ -258,7 +337,7 @@ private function buildIdentityKeys(RubricaUniversale $r): array /** @param array $items */ private function pickMaster(array $items): RubricaUniversale { - $best = null; + $best = null; $bestScore = -1; foreach ($items as $r) { @@ -299,7 +378,7 @@ private function pickMaster(array $items): RubricaUniversale } if ($best === null || $score > $bestScore || ($score === $bestScore && (int) $r->id < (int) $best->id)) { - $best = $r; + $best = $r; $bestScore = $score; } } @@ -393,7 +472,15 @@ private function normalizeFiscalIds(?string $raw): array } $norm = $this->normalizeCf($raw); - if (!$norm) { + if (! $norm) { + return ['cf' => null, 'piva' => null]; + } + + // Valori placeholder/import legacy non devono guidare dedup automatico. + if (in_array($norm, ['0', '00', '000', '0000', 'ND', 'N/D', 'NULL'], true)) { + return ['cf' => null, 'piva' => null]; + } + if (preg_match('/^0+$/', $norm) === 1) { return ['cf' => null, 'piva' => null]; } diff --git a/app/Console/Commands/NetgesconDistributionPullCommand.php b/app/Console/Commands/NetgesconDistributionPullCommand.php index 3bee10d..3fb9394 100644 --- a/app/Console/Commands/NetgesconDistributionPullCommand.php +++ b/app/Console/Commands/NetgesconDistributionPullCommand.php @@ -14,15 +14,19 @@ class NetgesconDistributionPullCommand extends Command {--auth= : Token autorizzazione licensed} {--apply : Applica update se disponibile} {--dry-run : Simula senza scrivere file} - {--force : Applica anche se versione uguale o inferiore}'; + {--force : Applica anche se versione uguale o inferiore} + {--progress-file= : File JSON dove scrivere lo stato avanzamento}'; protected $description = 'Controlla e applica aggiornamenti NetGescon dal server distribuzione'; public function handle(): int { + $this->writeProgress(2, 'Avvio controllo aggiornamenti', 'running'); + $baseUrl = rtrim((string) config('distribution.update_url', ''), '/'); if ($baseUrl === '') { $this->error('NETGESCON_UPDATE_URL non configurato.'); + $this->writeProgress(100, 'NETGESCON_UPDATE_URL non configurato', 'failed', 1); return self::FAILURE; } @@ -37,16 +41,23 @@ public function handle(): int if ($channel === 'licensed') { if ($code === '' || $auth === '') { $this->error('Canale licensed richiede --code e --auth (o variabili NETGESCON_LICENSED_*).'); + $this->writeProgress(100, 'Credenziali licensed mancanti', 'failed', 1); return self::FAILURE; } $query['code'] = $code; $query['auth'] = $auth; } - $timeout = (int) config('distribution.http_timeout_seconds', 60); + $timeout = (int) config('distribution.http_timeout_seconds', 60); + $resolveEntries = $this->resolveEntries(); $this->info("Controllo aggiornamenti su {$manifestUrl} (channel={$channel})..."); + if ($resolveEntries !== []) { + $this->line('Override resolve attivo: ' . implode(', ', $resolveEntries)); + } + $this->writeProgress(12, 'Contatto server distribution', 'running'); $response = Http::timeout($timeout) + ->withOptions($this->httpOptions(false, $resolveEntries)) ->acceptJson() ->withHeaders(array_filter([ 'X-Netgescon-Node' => $nodeCode !== '' ? $nodeCode : null, @@ -54,7 +65,23 @@ public function handle(): int ->get($manifestUrl, $query); if (! $response->successful()) { + if ($response->status() === 404) { + $healthUrl = $baseUrl . '/api/v1/distribution/health'; + $health = Http::timeout($timeout) + ->withOptions($this->httpOptions(false, $resolveEntries)) + ->acceptJson() + ->get($healthUrl); + + if ($health->successful()) { + $this->error('Manifest endpoint non trovato (HTTP 404), ma health distribution risponde OK.'); + $this->error('Verifica che il server update esponga /api/v1/distribution/updates/manifest.'); + $this->writeProgress(100, 'Manifest endpoint non disponibile sul server update', 'failed', 1); + return self::FAILURE; + } + } + $this->error('Manifest non raggiungibile: HTTP ' . $response->status()); + $this->writeProgress(100, 'Manifest non raggiungibile: HTTP ' . $response->status(), 'failed', 1); return self::FAILURE; } @@ -67,6 +94,7 @@ public function handle(): int if ($remoteVersion === '' || $packageUrl === '' || $hash === '') { $this->error('Manifest incompleto: richiesti version/package_url/sha256.'); + $this->writeProgress(100, 'Manifest incompleto', 'failed', 1); return self::FAILURE; } @@ -78,16 +106,19 @@ public function handle(): int if (! $hasNewVersion && ! $this->option('force')) { $this->info('Nessun aggiornamento necessario.'); + $this->writeProgress(100, 'Nessun aggiornamento necessario', 'completed', 0); return self::SUCCESS; } if (! $this->option('apply')) { $this->info('Update disponibile. Esegui con --apply per installarlo.'); + $this->writeProgress(100, 'Update disponibile (apply non richiesto)', 'completed', 0); return self::SUCCESS; } if ($this->option('dry-run')) { $this->warn('Dry-run: update rilevato, nessuna modifica applicata.'); + $this->writeProgress(100, 'Dry-run completato', 'completed', 0); return self::SUCCESS; } @@ -95,6 +126,7 @@ public function handle(): int $backupRoot = storage_path('app/update-backups'); File::ensureDirectoryExists($tmpRoot); File::ensureDirectoryExists($backupRoot); + $this->writeProgress(25, 'Preparazione cartelle temporanee', 'running'); $stamp = date('Ymd-His'); $zipPath = $tmpRoot . '/netgescon-update-' . $stamp . '.zip'; @@ -102,9 +134,13 @@ public function handle(): int $envBackup = $backupRoot . '/.env-' . $stamp; $this->info('Download pacchetto...'); - $download = Http::timeout($timeout)->withOptions(['stream' => true])->get($packageUrl); + $this->writeProgress(35, 'Download pacchetto update', 'running'); + $download = Http::timeout($timeout) + ->withOptions($this->httpOptions(true, $resolveEntries)) + ->get($packageUrl); if (! $download->successful()) { $this->error('Download fallito: HTTP ' . $download->status()); + $this->writeProgress(100, 'Download fallito: HTTP ' . $download->status(), 'failed', 1); return self::FAILURE; } @@ -113,25 +149,32 @@ public function handle(): int $calcHash = strtolower(hash_file('sha256', $zipPath) ?: ''); if ($calcHash !== $hash) { $this->error('Hash SHA256 non valido. Atteso=' . $hash . ' calcolato=' . $calcHash); + $this->writeProgress(100, 'Verifica hash fallita', 'failed', 1); return self::FAILURE; } + $this->writeProgress(50, 'Pacchetto verificato', 'running'); + $this->info('Backup .env...'); + $this->writeProgress(58, 'Backup ambiente corrente', 'running'); if (File::exists(base_path('.env'))) { File::copy(base_path('.env'), $envBackup); } $this->info('Estrazione pacchetto...'); + $this->writeProgress(66, 'Estrazione pacchetto', 'running'); File::ensureDirectoryExists($extractDir); $zip = new ZipArchive(); if ($zip->open($zipPath) !== true) { $this->error('Impossibile aprire ZIP update.'); + $this->writeProgress(100, 'Impossibile aprire ZIP update', 'failed', 1); return self::FAILURE; } $zip->extractTo($extractDir); $zip->close(); $this->info('Applicazione file (esclusi .env e storage)...'); + $this->writeProgress(78, 'Applicazione file', 'running'); $this->syncDir($extractDir, base_path()); if (File::exists($envBackup)) { @@ -139,16 +182,42 @@ public function handle(): int } $this->call('optimize:clear'); + $this->writeProgress(90, 'Pulizia cache applicazione', 'running'); if ($migrateRequired) { $this->warn('Manifest richiede migrate: esecuzione php artisan migrate --force'); + $this->writeProgress(95, 'Esecuzione migration', 'running'); $this->call('migrate', ['--force' => true]); } $this->info('Aggiornamento applicato con successo.'); + $this->writeProgress(100, 'Aggiornamento completato', 'completed', 0); return self::SUCCESS; } + private function writeProgress(int $percent, string $message, string $status, ?int $exitCode = null): void + { + $path = trim((string) $this->option('progress-file')); + if ($path === '') { + return; + } + + $dir = dirname($path); + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + + $payload = [ + 'timestamp' => now()->toIso8601String(), + 'percent' => max(0, min(100, $percent)), + 'message' => $message, + 'status' => $status, + 'exit_code' => $exitCode, + ]; + + @file_put_contents($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + private function syncDir(string $from, string $to): void { $items = File::allFiles($from); @@ -166,4 +235,46 @@ private function syncDir(string $from, string $to): void File::copy($path, $target); } } + + /** + * @return array + */ + private function resolveEntries(): array + { + $raw = trim((string) config('distribution.http_resolve', '')); + if ($raw === '') { + return []; + } + + $entries = array_values(array_filter(array_map('trim', explode(',', $raw)), static fn(string $v): bool => $v !== '')); + $valid = []; + + foreach ($entries as $entry) { + if (preg_match('/^[^:\s]+:\d{1,5}:\d{1,3}(?:\.\d{1,3}){3}$/', $entry) === 1) { + $valid[] = $entry; + continue; + } + + $this->warn('Entry NETGESCON_UPDATE_RESOLVE ignorata (formato non valido): ' . $entry); + } + + return $valid; + } + + /** + * @param array $resolveEntries + * @return array + */ + private function httpOptions(bool $stream, array $resolveEntries): array + { + $options = ['stream' => $stream]; + + if ($resolveEntries !== []) { + $options['curl'] = [ + CURLOPT_RESOLVE => $resolveEntries, + ]; + } + + return $options; + } } diff --git a/app/Console/Commands/NetgesconPreupdateBackupCommand.php b/app/Console/Commands/NetgesconPreupdateBackupCommand.php new file mode 100644 index 0000000..493bb4b --- /dev/null +++ b/app/Console/Commands/NetgesconPreupdateBackupCommand.php @@ -0,0 +1,456 @@ + */ + private array $latestManifest = []; + + public function handle(): int + { + $stamp = now()->format('Ymd-His'); + $tag = trim((string) $this->option('tag')) ?: 'auto'; + $snapshotId = $stamp . '-' . Str::slug($tag, '-'); + + $root = storage_path('app/update-backups'); + $snapshotDir = $root . '/snapshots/' . $snapshotId; + $filesDir = $snapshotDir . '/files'; + $dbDir = $snapshotDir . '/db'; + $metaDir = $snapshotDir . '/meta'; + + File::ensureDirectoryExists($filesDir); + File::ensureDirectoryExists($dbDir); + File::ensureDirectoryExists($metaDir); + + $this->line('Backup snapshot: ' . $snapshotId); + + $latestPath = $root . '/latest-manifest.json'; + if (is_file($latestPath)) { + $decoded = json_decode((string) file_get_contents($latestPath), true); + if (is_array($decoded)) { + $this->latestManifest = $decoded; + } + } + + $manifest = $this->buildFileManifest(); + $differential = (bool) $this->option('differential'); + $copiedCount = $this->copySnapshotFiles($manifest, $filesDir, $differential); + + $tables = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('tables'))), fn(string $v): bool => $v !== '')); + $dbSummary = $this->exportTables($tables, $dbDir, $metaDir); + + $meta = [ + 'snapshot_id' => $snapshotId, + 'created_at' => now()->toIso8601String(), + 'tag' => $tag, + 'differential' => $differential, + 'copied_files' => $copiedCount, + 'tables' => $dbSummary, + 'node_code' => (string) config('distribution.node_code', ''), + 'app_version' => (string) config('netgescon.version', '0.0.0'), + ]; + + file_put_contents($metaDir . '/snapshot-meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + file_put_contents($metaDir . '/files-manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + if (is_file(base_path('.env'))) { + File::copy(base_path('.env'), $metaDir . '/.env.backup'); + } + if (is_file(base_path('VERSION'))) { + File::copy(base_path('VERSION'), $metaDir . '/VERSION.backup'); + } + + $zipPath = $root . '/archives/' . $snapshotId . '.zip'; + File::ensureDirectoryExists(dirname($zipPath)); + $this->zipDirectory($snapshotDir, $zipPath); + + $latestPayload = [ + 'snapshot_id' => $snapshotId, + 'snapshot_dir' => $snapshotDir, + 'zip_path' => $zipPath, + 'created_at' => now()->toIso8601String(), + 'files_manifest' => $manifest, + ]; + file_put_contents($latestPath, json_encode($latestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + $this->info('Backup locale creato: ' . $zipPath); + + $driveInfo = null; + if ((bool) $this->option('drive')) { + $adminId = (int) $this->option('admin-id'); + if ($adminId <= 0) { + $this->warn('Drive upload saltato: --admin-id mancante.'); + } else { + $driveInfo = $this->uploadBackupToDrive($adminId, $zipPath, $snapshotId); + if (is_array($driveInfo)) { + $this->info('Backup caricato su Drive: ' . (string) ($driveInfo['webViewLink'] ?? $driveInfo['id'] ?? 'ok')); + } else { + $this->warn('Upload Drive non riuscito.'); + } + } + } + + $result = [ + 'snapshot_id' => $snapshotId, + 'zip_path' => $zipPath, + 'drive' => $driveInfo, + 'copied_files' => $copiedCount, + 'tables' => $dbSummary, + ]; + + $this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + /** @return array */ + private function buildFileManifest(): array + { + $roots = ['app', 'bootstrap', 'config', 'database', 'resources', 'routes', 'public']; + $manifest = []; + + foreach ($roots as $root) { + $abs = base_path($root); + if (! is_dir($abs)) { + continue; + } + + $files = File::allFiles($abs); + foreach ($files as $file) { + $path = $file->getPathname(); + $rel = ltrim(str_replace(base_path(), '', $path), '/'); + $hash = hash_file('sha256', $path) ?: ''; + if ($hash === '') { + continue; + } + $manifest[$rel] = $hash; + } + } + + ksort($manifest); + return $manifest; + } + + /** + * @param array $manifest + */ + private function copySnapshotFiles(array $manifest, string $targetRoot, bool $differential): int + { + $old = []; + if ($differential && is_array($this->latestManifest['files_manifest'] ?? null)) { + $old = (array) $this->latestManifest['files_manifest']; + } + + $count = 0; + foreach ($manifest as $rel => $hash) { + $unchanged = $differential && isset($old[$rel]) && (string) $old[$rel] === (string) $hash; + if ($unchanged) { + continue; + } + + $src = base_path($rel); + if (! is_file($src)) { + continue; + } + + $dst = $targetRoot . '/' . $rel; + File::ensureDirectoryExists(dirname($dst)); + File::copy($src, $dst); + $count++; + } + + return $count; + } + + /** + * @param array $tables + * @return array + */ + private function exportTables(array $tables, string $dbDir, string $metaDir): array + { + $summary = []; + + foreach ($tables as $table) { + if (! DB::getSchemaBuilder()->hasTable($table)) { + continue; + } + + $columns = DB::getSchemaBuilder()->getColumnListing($table); + $pk = in_array('id', $columns, true) ? 'id' : ($columns[0] ?? 'id'); + + $file = $dbDir . '/' . $table . '.ndjson'; + $indexFile = $metaDir . '/index-' . $table . '.json'; + if (is_file($file)) { + @unlink($file); + } + + $rowsCount = 0; + $index = []; + + DB::table($table) + ->orderBy($pk) + ->chunk(500, function ($rows) use (&$rowsCount, &$index, $file, $pk): void { + foreach ($rows as $rowObj) { + $row = (array) $rowObj; + $payload = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (! is_string($payload)) { + continue; + } + + $rowHash = sha1($payload); + $pkValue = (string) ($row[$pk] ?? ''); + $record = [ + 'pk' => $pkValue, + 'hash' => $rowHash, + 'data' => $row, + ]; + + file_put_contents($file, json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND); + if ($pkValue !== '') { + $index[$pkValue] = $rowHash; + } + $rowsCount++; + } + }); + + file_put_contents($indexFile, json_encode($index, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + $summary[$table] = [ + 'rows' => $rowsCount, + 'file' => $file, + 'index' => $indexFile, + ]; + } + + return $summary; + } + + private function zipDirectory(string $sourceDir, string $zipPath): void + { + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Impossibile creare archivio zip backup.'); + } + + $files = File::allFiles($sourceDir); + foreach ($files as $file) { + $absolute = $file->getPathname(); + $relative = ltrim(str_replace($sourceDir, '', $absolute), '/'); + $zip->addFile($absolute, $relative); + } + + $zip->close(); + } + + /** @return array|null */ + private function uploadBackupToDrive(int $adminId, string $zipPath, string $snapshotId): ?array + { + $admin = Amministratore::query()->find($adminId); + if (! $admin instanceof Amministratore) { + $this->warn('Amministratore non trovato per upload Drive.'); + return null; + } + + $google = Arr::get($admin->impostazioni ?? [], 'google', []); + $oauth = Arr::get($google, 'oauth', []); + $token = $this->resolveGoogleAccessToken($admin, $google, $oauth); + if ($token === null) { + $this->warn('Token Google non disponibile per upload backup.'); + return null; + } + + $rootFolderId = trim((string) Arr::get($google, 'drive_backup_root_id', '')); + if ($rootFolderId === '') { + $rootFolderId = 'root'; + } + + $node = (string) config('distribution.node_code', 'node'); + $year = now()->format('Y'); + $month = now()->format('m'); + + $backupsFolder = $this->ensureDriveFolder($token, 'NetGescon-Backups', $rootFolderId); + if ($backupsFolder === null) { + return null; + } + $nodeFolder = $this->ensureDriveFolder($token, $node !== '' ? $node : 'node', $backupsFolder); + $yearFolder = $nodeFolder ? $this->ensureDriveFolder($token, $year, $nodeFolder) : null; + $monthFolder = $yearFolder ? $this->ensureDriveFolder($token, $month, $yearFolder) : null; + if ($monthFolder === null) { + return null; + } + + $this->ensureDriveTemplateStructure($token, $rootFolderId); + + $fileName = basename($zipPath); + return $this->uploadDriveFile($token, $monthFolder, $fileName, $zipPath, 'application/zip'); + } + + private function ensureDriveTemplateStructure(string $token, string $rootFolderId): void + { + $templateRoot = $this->ensureDriveFolder($token, 'NetGescon-Template-Cartelle-Condominio', $rootFolderId); + if ($templateRoot === null) { + return; + } + + $folders = config('netgescon.google.drive_template_folders', []); + if (! is_array($folders)) { + return; + } + + foreach ($folders as $folder) { + $name = trim((string) $folder); + if ($name === '') { + continue; + } + $this->ensureDriveFolder($token, $name, $templateRoot); + } + } + + private function ensureDriveFolder(string $token, string $name, string $parentId): ?string + { + $query = sprintf("mimeType='application/vnd.google-apps.folder' and trashed=false and name='%s' and '%s' in parents", str_replace("'", "\\'", $name), $parentId); + + $response = Http::withToken($token) + ->acceptJson() + ->get('https://www.googleapis.com/drive/v3/files', [ + 'q' => $query, + 'fields' => 'files(id,name)', + 'pageSize' => 1, + 'supportsAllDrives' => 'true', + 'includeItemsFromAllDrives' => 'true', + ]); + + if ($response->successful()) { + $files = $response->json('files'); + if (is_array($files) && isset($files[0]['id']) && is_string($files[0]['id'])) { + return $files[0]['id']; + } + } + + $create = Http::withToken($token) + ->acceptJson() + ->post('https://www.googleapis.com/drive/v3/files?supportsAllDrives=true', [ + 'name' => $name, + 'mimeType' => 'application/vnd.google-apps.folder', + 'parents' => [$parentId], + ]); + + if (! $create->successful()) { + $this->warn('Impossibile creare cartella Drive: ' . $name); + return null; + } + + $id = $create->json('id'); + return is_string($id) ? $id : null; + } + + /** @return array|null */ + private function uploadDriveFile(string $token, string $parentId, string $name, string $path, string $mime): ?array + { + $meta = [ + 'name' => $name, + 'parents' => [$parentId], + ]; + + $boundary = 'netgescon_' . Str::random(20); + $content = "--{$boundary}\r\n"; + $content .= "Content-Type: application/json; charset=UTF-8\r\n\r\n"; + $content .= json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\r\n"; + $content .= "--{$boundary}\r\n"; + $content .= "Content-Type: {$mime}\r\n\r\n"; + $content .= (string) file_get_contents($path) . "\r\n"; + $content .= "--{$boundary}--"; + + $response = Http::withToken($token) + ->withHeaders([ + 'Content-Type' => 'multipart/related; boundary=' . $boundary, + ]) + ->post('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,name,webViewLink', $content); + + if (! $response->successful()) { + $this->warn('Upload Drive fallito: HTTP ' . $response->status()); + return null; + } + + $json = $response->json(); + return is_array($json) ? $json : null; + } + + private function resolveGoogleAccessToken(Amministratore $admin, array $google, array $oauth): ?string + { + $accessToken = trim((string) Arr::get($oauth, 'access_token', '')); + if ($accessToken !== '') { + return $accessToken; + } + + $refreshToken = trim((string) Arr::get($oauth, 'refresh_token', '')); + if ($refreshToken === '') { + return null; + } + + $settingsClientId = trim((string) Arr::get($google, 'client_id', '')); + $settingsClientSecret = trim((string) Arr::get($google, 'client_secret', '')); + $configClientId = trim((string) config('services.google.client_id')); + $configClientSecret = trim((string) config('services.google.client_secret')); + + $pairs = []; + if ($settingsClientId !== '' && $settingsClientSecret !== '') { + $pairs[] = [$settingsClientId, $settingsClientSecret]; + } + if ($configClientId !== '' && $configClientSecret !== '') { + $pairs[] = [$configClientId, $configClientSecret]; + } + + foreach ($pairs as [$clientId, $clientSecret]) { + $res = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ]); + + if (! $res->successful()) { + continue; + } + + $token = trim((string) $res->json('access_token', '')); + if ($token === '') { + continue; + } + + $settings = $admin->impostazioni ?? []; + $settingsGoogle = Arr::get($settings, 'google', []); + $settingsOauth = Arr::get($settingsGoogle, 'oauth', []); + $settingsOauth['access_token'] = $token; + $settingsOauth['expires_in'] = (int) $res->json('expires_in', 3600); + $settingsOauth['refreshed_at'] = now()->toDateTimeString(); + $settingsGoogle['oauth'] = $settingsOauth; + $settings['google'] = $settingsGoogle; + $admin->impostazioni = $settings; + $admin->save(); + + return $token; + } + + return null; + } +} diff --git a/app/Console/Commands/NetgesconRestoreRecordCommand.php b/app/Console/Commands/NetgesconRestoreRecordCommand.php new file mode 100644 index 0000000..4d1762d --- /dev/null +++ b/app/Console/Commands/NetgesconRestoreRecordCommand.php @@ -0,0 +1,138 @@ +argument('table')); + $pk = trim((string) $this->argument('pk')); + $pkColumn = trim((string) $this->option('pk-column')) ?: 'id'; + + if ($table === '' || $pk === '') { + $this->error('Parametri table/pk obbligatori.'); + return self::FAILURE; + } + + $snapshotDir = $this->resolveSnapshotDir(); + if ($snapshotDir === null) { + $this->error('Snapshot non trovato.'); + return self::FAILURE; + } + + $file = $snapshotDir . '/db/' . $table . '.ndjson'; + if (! is_file($file)) { + $this->error('Export tabella non trovato nello snapshot: ' . $file); + return self::FAILURE; + } + + $record = $this->findRecordByPk($file, $pk); + if (! is_array($record)) { + $this->error('Record non trovato nello snapshot per PK=' . $pk); + return self::FAILURE; + } + + $this->line('Snapshot: ' . $snapshotDir); + $this->line('Tabella: ' . $table . ' · PK: ' . $pkColumn . '=' . $pk); + $this->line('Hash: ' . (string) ($record['hash'] ?? '-')); + + $data = is_array($record['data'] ?? null) ? (array) $record['data'] : []; + if (! array_key_exists($pkColumn, $data)) { + $data[$pkColumn] = $pk; + } + + if (! (bool) $this->option('apply')) { + $this->warn('Preview mode: nessuna scrittura eseguita. Usa --apply per ripristinare.'); + $this->line(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return self::SUCCESS; + } + + if (! DB::getSchemaBuilder()->hasTable($table)) { + $this->error('Tabella non esistente su DB corrente: ' . $table); + return self::FAILURE; + } + + DB::table($table)->updateOrInsert( + [$pkColumn => $data[$pkColumn]], + $data + ); + + $this->info('Record ripristinato con successo.'); + return self::SUCCESS; + } + + private function resolveSnapshotDir(): ?string + { + $snapshotId = trim((string) $this->option('snapshot-id')); + $root = storage_path('app/update-backups/snapshots'); + + if ($snapshotId !== '') { + $dir = $root . '/' . $snapshotId; + return is_dir($dir) ? $dir : null; + } + + $latest = storage_path('app/update-backups/latest-manifest.json'); + if (is_file($latest)) { + $decoded = json_decode((string) @file_get_contents($latest), true); + if (is_array($decoded) && isset($decoded['snapshot_dir']) && is_string($decoded['snapshot_dir']) && is_dir($decoded['snapshot_dir'])) { + return $decoded['snapshot_dir']; + } + } + + if (! is_dir($root)) { + return null; + } + + $dirs = array_values(array_filter(scandir($root) ?: [], function (string $name) use ($root): bool { + return $name !== '.' && $name !== '..' && is_dir($root . '/' . $name); + })); + + rsort($dirs, SORT_NATURAL); + if ($dirs === []) { + return null; + } + + return $root . '/' . $dirs[0]; + } + + /** @return array|null */ + private function findRecordByPk(string $file, string $pk): ?array + { + $handle = fopen($file, 'rb'); + if (! is_resource($handle)) { + return null; + } + + try { + while (($line = fgets($handle)) !== false) { + $decoded = json_decode((string) $line, true); + if (! is_array($decoded)) { + continue; + } + + $candidate = (string) ($decoded['pk'] ?? ''); + if ($candidate === $pk) { + return $decoded; + } + } + } finally { + fclose($handle); + } + + return null; + } +} diff --git a/app/Console/Commands/SmdrListenCommand.php b/app/Console/Commands/SmdrListenCommand.php new file mode 100644 index 0000000..95271b3 --- /dev/null +++ b/app/Console/Commands/SmdrListenCommand.php @@ -0,0 +1,495 @@ + */ + private const CO_PUBLIC_LINES = [ + '1' => '0639731100', + '01' => '0639731100', + '0001' => '0639731100', + '3' => '0688812703', + '03' => '0688812703', + '0003' => '0688812703', + ]; + + /** @var array */ + private const EXTENSION_CONTEXT = [ + '601' => 'giorno', + '603' => 'notte', + ]; + + protected $signature = 'smdr:listen + {--host=192.168.0.101 : SMDR host/IP} + {--port=2300 : SMDR TCP port} + {--user=SMDR : Login username} + {--pass=PCCSMDR : Login password} + {--timeout=0 : Read timeout in seconds (0 = no timeout)} + {--reconnect=1 : Auto-reconnect when socket closes} + {--reconnect-delay=3 : Seconds before reconnect attempt} + {--write-postit=1 : Create Post-it records from incoming SMDR lines} + {--write-communications=1 : Store raw lines in communication_messages} + {--no-auth : Skip sending user/password} + {--max-lines=0 : Stop after N lines (0 = infinite)}'; + + protected $description = 'Listen to Panasonic NS1000 SMDR stream over TCP and store incoming call records.'; + + public function handle(): int + { + $host = (string) $this->option('host'); + $port = (int) $this->option('port'); + $user = (string) $this->option('user'); + $pass = (string) $this->option('pass'); + $timeout = (int) $this->option('timeout'); + $reconnect = (bool) ((int) $this->option('reconnect')); + $reconnectDelay = max(1, (int) $this->option('reconnect-delay')); + $maxLines = (int) $this->option('max-lines'); + $writePostIt = (bool) ((int) $this->option('write-postit')); + $writeCommunications = (bool) ((int) $this->option('write-communications')); + $auth = ! $this->option('no-auth'); + + $lineCount = 0; + $attempt = 0; + $seenFingerprints = []; + $seenOrder = []; + $seenLimit = 5000; + + do { + $attempt++; + $this->info("[Attempt {$attempt}] Connecting to SMDR {$host}:{$port}..."); + + $socket = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 10); + if (! is_resource($socket)) { + $this->error("Connection failed ({$errno}): {$errstr}"); + if (! $reconnect) { + return self::FAILURE; + } + + sleep($reconnectDelay); + continue; + } + + if ($timeout > 0) { + stream_set_timeout($socket, $timeout); + } + + stream_set_blocking($socket, true); + + if ($auth) { + $okUser = @fwrite($socket, $user . "\r\n"); + usleep(200000); + $okPass = @fwrite($socket, $pass . "\r\n"); + + if ($okUser === false || $okPass === false) { + $this->warn('Socket closed during auth write (broken pipe).'); + fclose($socket); + + if (! $reconnect) { + $this->info('SMDR listener stopped.'); + return self::SUCCESS; + } + + $this->line("Reconnect in {$reconnectDelay}s..."); + sleep($reconnectDelay); + continue; + } + + $this->line('Credentials sent (CR+LF).'); + } else { + $this->line('Authentication skipped (--no-auth).'); + } + + $this->info('Listening for SMDR lines...'); + + while (! feof($socket)) { + $line = fgets($socket); + + if ($line === false) { + $meta = stream_get_meta_data($socket); + if (($meta['timed_out'] ?? false) === true) { + $this->warn('Read timeout reached, waiting for next data...'); + continue; + } + + usleep(200000); + continue; + } + + $line = trim($line); + if ($line === '') { + continue; + } + + $parsed = $this->parseSmdrLine($line); + if ($parsed === null) { + continue; + } + + $fingerprint = $parsed['fingerprint']; + if (isset($seenFingerprints[$fingerprint])) { + continue; + } + + $seenFingerprints[$fingerprint] = true; + $seenOrder[] = $fingerprint; + + if (count($seenOrder) > $seenLimit) { + $old = array_shift($seenOrder); + if ($old !== null) { + unset($seenFingerprints[$old]); + } + } + + $lineCount++; + $this->line("[{$lineCount}] {$line}"); + + $phone = $parsed['phone_number']; + $direction = $parsed['direction']; + $receivedAt = $parsed['occurred_at'] ?? now(); + $durationSeconds = $parsed['duration_seconds']; + $targetExtension = (string) ($parsed['extension'] ?? ''); + [$assignedUserId, $stabileId] = $this->resolveRouting($targetExtension); + + Log::info('SMDR line received', [ + 'line' => $line, + 'phone' => $phone, + 'direction' => $direction, + 'source' => 'smdr', + ]); + + if ($writeCommunications && Schema::hasTable('communication_messages')) { + $communicationPayload = [ + 'channel' => 'smdr', + 'direction' => $direction, + 'external_chat_id' => null, + 'external_message_id' => null, + 'phone_number' => $phone, + 'sender_name' => null, + 'message_text' => $line, + 'attachments' => null, + 'status' => 'received', + 'received_at' => $receivedAt, + 'metadata' => [ + 'raw_line' => $line, + 'source_host' => $host, + 'source_port' => $port, + 'assigned_user_id' => $assignedUserId, + 'stabile_id' => $stabileId, + 'smdr' => [ + 'date' => $parsed['date'], + 'time' => $parsed['time'], + 'extension' => $parsed['extension'], + 'call_phase' => $parsed['call_phase'], + 'co' => $parsed['co'], + 'co_line_number' => $parsed['co_line_number'], + 'dial_number_raw' => $parsed['dial_number_raw'], + 'dial_number' => $parsed['dial_number'], + 'ring_duration_raw' => $parsed['ring_duration_raw'], + 'duration_raw' => $parsed['duration_raw'], + 'duration_seconds' => $durationSeconds, + 'cost_raw' => $parsed['cost_raw'], + 'cost_amount' => $parsed['cost_amount'], + 'cost_currency' => $parsed['cost_currency'], + 'account_code' => $parsed['account_code'], + 'direction' => $direction, + 'fingerprint' => $fingerprint, + ], + ], + ]; + + if (Schema::hasColumn('communication_messages', 'target_extension')) { + $communicationPayload['target_extension'] = $targetExtension !== '' ? $targetExtension : null; + } + + if (Schema::hasColumn('communication_messages', 'assigned_user_id')) { + $communicationPayload['assigned_user_id'] = $assignedUserId; + } + + if (Schema::hasColumn('communication_messages', 'stabile_id')) { + $communicationPayload['stabile_id'] = $stabileId; + } + + CommunicationMessage::query()->create($communicationPayload); + } + + if ($writePostIt && Schema::hasTable('chiamate_post_it')) { + try { + ChiamataPostIt::query()->create([ + 'telefono' => $phone, + 'stabile_id' => $stabileId, + 'creato_da_user_id' => $assignedUserId, + 'nome_chiamante' => 'SMDR ' . strtoupper($direction), + 'oggetto' => 'Chiamata ' . strtoupper($direction) . ' da SMDR', + 'nota' => $line, + 'direzione' => $this->mapPostItDirection($direction, $durationSeconds), + 'origine' => 'smdr', + 'origine_id' => $fingerprint, + 'durata_secondi' => $durationSeconds, + 'priorita' => 'Media', + 'stato' => 'post_it', + 'chiamata_il' => $receivedAt, + ]); + } catch (\Throwable $e) { + Log::warning('SMDR post-it write skipped', [ + 'line' => $line, + 'error' => $e->getMessage(), + ]); + } + } + + if ($maxLines > 0 && $lineCount >= $maxLines) { + $this->warn("Max lines ({$maxLines}) reached, stopping."); + fclose($socket); + $this->info('SMDR listener stopped.'); + return self::SUCCESS; + } + } + + fclose($socket); + $this->warn('Socket closed by remote host.'); + + if (! $reconnect) { + $this->info('SMDR listener stopped.'); + return self::SUCCESS; + } + + $this->line("Reconnect in {$reconnectDelay}s..."); + sleep($reconnectDelay); + } while (true); + } + + private function parseSmdrLine(string $line): ?array + { + $normalized = trim((string) preg_replace('/^\[\d+\]\s*/', '', $line)); + if ($normalized === '') { + return null; + } + + $upper = strtoupper($normalized); + if ( + str_starts_with($upper, 'DATE ') || + str_starts_with($upper, 'DATE\t') || + str_starts_with($upper, 'CD') || + str_starts_with($upper, 'TR') || + preg_match('/^-{5,}$/', $normalized) === 1 + ) { + return null; + } + + $parts = preg_split('/\s+/', $normalized); + if (! is_array($parts) || count($parts) < 4) { + return null; + } + + $date = $parts[0] ?? ''; + $time = $parts[1] ?? ''; + $extension = $parts[2] ?? ''; + + if ( + preg_match('/^\d{2}\/\d{2}\/\d{2}$/', $date) !== 1 || + preg_match('/^\d{2}:\d{2}$/', $time) !== 1 || + preg_match('/^\d{2,4}$/', $extension) !== 1 + ) { + return null; + } + + $idx = 3; + $co = null; + $dialNumberRaw = null; + if (isset($parts[$idx]) && preg_match('/^\d{1,4}$/', $parts[$idx]) === 1) { + $co = $parts[$idx]; + $idx++; + } + + if (! isset($parts[$idx])) { + return null; + } + + $dialNumberRaw = $parts[$idx]; + $idx++; + + $ringDurationRaw = null; + $durationRaw = null; + $costRaw = null; + $accountCode = null; + + if (isset($parts[$idx]) && preg_match('/^\d{1,2}\'\d{2}$/', $parts[$idx]) === 1) { + $ringDurationRaw = $parts[$idx]; + $idx++; + } + + if (isset($parts[$idx]) && preg_match('/^\d{2}:\d{2}\'\d{2}$/', $parts[$idx]) === 1) { + $durationRaw = $parts[$idx]; + $idx++; + } elseif ($ringDurationRaw !== null) { + $durationRaw = $ringDurationRaw; + } + + if (isset($parts[$idx]) && preg_match('/^[A-Z]{2,3}[0-9]+(?:\.[0-9]+)?$/', $parts[$idx]) === 1) { + $costRaw = $parts[$idx]; + $idx++; + } + + if (isset($parts[$idx])) { + $accountCode = implode(' ', array_slice($parts, $idx)); + } + + $dialUpper = strtoupper($dialNumberRaw); + $direction = 'outbound'; + $isInbound = str_contains($dialUpper, ''); + $isInternal = str_starts_with($dialUpper, 'EXT'); + + if ($isInternal) { + $direction = 'internal'; + } elseif ($isInbound) { + $direction = 'inbound'; + } + + $dialNumber = str_ireplace('', '', $dialNumberRaw); + $phoneNumber = $this->sanitizeNumber($dialNumber); + if ($direction === 'internal') { + $internalTarget = $this->sanitizeNumber(substr($dialNumberRaw, 3)); + $phoneNumber = $internalTarget ?: $phoneNumber; + } + + $coNormalized = $co !== null ? ltrim((string) $co, '0') : ''; + $coLookup = $coNormalized !== '' ? $coNormalized : (string) $co; + $publicLine = self::CO_PUBLIC_LINES[(string) $co] ?? self::CO_PUBLIC_LINES[$coLookup] ?? null; + $callPhase = self::EXTENSION_CONTEXT[$extension] ?? null; + + if ($direction !== 'internal' && $phoneNumber === null && $publicLine !== null) { + // Fallback: for records without external number we keep the known public line. + $phoneNumber = $publicLine; + } + + try { + $occurredAt = Carbon::createFromFormat('d/m/y H:i', "{$date} {$time}"); + } catch (\Throwable) { + $occurredAt = now(); + } + + $durationSeconds = $this->parseDurationToSeconds($durationRaw); + [$costCurrency, $costAmount] = $this->parseCost($costRaw); + + $fingerprintBase = implode('|', [ + $date, + $time, + $extension, + (string) $co, + $dialNumberRaw, + (string) $durationRaw, + (string) $costRaw, + (string) $accountCode, + ]); + + return [ + 'date' => $date, + 'time' => $time, + 'extension' => $extension, + 'co' => $co, + 'co_line_number' => $publicLine, + 'dial_number_raw' => $dialNumberRaw, + 'dial_number' => $dialNumber, + 'call_phase' => $callPhase, + 'duration_raw' => $durationRaw, + 'ring_duration_raw' => $ringDurationRaw, + 'duration_seconds' => $durationSeconds, + 'cost_raw' => $costRaw, + 'cost_currency' => $costCurrency, + 'cost_amount' => $costAmount, + 'account_code' => $accountCode, + 'direction' => $direction, + 'phone_number' => $phoneNumber, + 'occurred_at' => $occurredAt, + 'fingerprint' => sha1($fingerprintBase), + ]; + } + + private function sanitizeNumber(?string $value): ?string + { + if ($value === null) { + return null; + } + + $digits = preg_replace('/\D+/', '', $value); + if (! is_string($digits) || $digits === '') { + return null; + } + + return $digits; + } + + private function parseDurationToSeconds(?string $duration): ?int + { + if ($duration === null) { + return null; + } + + if (preg_match('/^(\d{1,2})\'(\d{2})$/', $duration, $m) === 1) { + return ((int) $m[1] * 60) + (int) $m[2]; + } + + if (preg_match('/^(\d{2}):(\d{2})\'(\d{2})$/', $duration, $m) !== 1) { + return null; + } + + return ((int) $m[1] * 3600) + ((int) $m[2] * 60) + (int) $m[3]; + } + + private function parseCost(?string $costRaw): array + { + if ($costRaw === null) { + return [null, null]; + } + + if (preg_match('/^([A-Z]{2,3})([0-9]+(?:\.[0-9]+)?)$/', $costRaw, $m) !== 1) { + return [null, null]; + } + + return [$m[1], (float) $m[2]]; + } + + private function mapPostItDirection(string $direction, ?int $durationSeconds): string + { + if ($direction === 'inbound') { + if ($durationSeconds === 0) { + return 'persa'; + } + + return 'in_arrivo'; + } + + return 'in_uscita'; + } + + private function resolveRouting(string $extension): array + { + $ext = trim($extension); + if ($ext === '' || ! Schema::hasColumn('users', 'pbx_extension')) { + return [null, null]; + } + + $user = User::query()->where('pbx_extension', $ext)->first(); + if (! $user) { + return [null, null]; + } + + $stabileId = null; + if (method_exists($user, 'stabiliAssegnati')) { + $stabileId = $user->stabiliAssegnati()->value('stabili.id'); + } + + return [ + (int) $user->id, + $stabileId ? (int) $stabileId : null, + ]; + } +} diff --git a/app/Filament/Pages/Gescon/FornitoreScheda.php b/app/Filament/Pages/Gescon/FornitoreScheda.php index 8268c92..76570ed 100644 --- a/app/Filament/Pages/Gescon/FornitoreScheda.php +++ b/app/Filament/Pages/Gescon/FornitoreScheda.php @@ -4,6 +4,7 @@ use App\Models\Documento; use App\Models\FatturaElettronica; use App\Models\Fornitore; +use App\Models\FornitoreDipendente; use App\Models\FornitoreStabileImpostazione; use App\Models\RegistroRitenuteAcconto; use App\Models\RubricaUniversale; @@ -30,7 +31,9 @@ use Filament\Pages\Page; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; class FornitoreScheda extends Page { @@ -67,6 +70,19 @@ class FornitoreScheda extends Page /** @var array */ public array $tagSuggestions = []; + /** @var array> */ + public array $dipendentiRows = []; + + public string $nuovoDipendenteNome = ''; + + public string $nuovoDipendenteCognome = ''; + + public string $nuovoDipendenteEmail = ''; + + public string $nuovoDipendenteTelefono = ''; + + public ?string $lastGeneratedPassword = null; + public function cleanDisplayValue(?string $value, string $fallback = '-'): string { $v = trim((string) $value); @@ -128,17 +144,170 @@ public function mount(int | string $record): void // ignore } - $allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0))); - $fornitoreAdminId = (int) ($this->fornitore->amministratore_id ?? 0); + $allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0))); + $fornitoreAdminIds = array_values(array_unique(array_filter([ + (int) ($this->fornitore->amministratore_id ?? 0), + (int) ($this->fornitore->rubrica?->amministratore_id ?? 0), + ], fn($v) => (int) $v > 0))); - if ($fornitoreAdminId <= 0 || ! in_array($fornitoreAdminId, $allowedAdminIds, true)) { - abort(404); + if ($fornitoreAdminIds === []) { + if (! $user->hasAnyRole(['admin', 'amministratore'])) { + abort(404); + } + } else { + $hasIntersection = count(array_intersect($fornitoreAdminIds, $allowedAdminIds)) > 0; + if (! $hasIntersection) { + abort(404); + } } } $this->hydrateBoxData($user); $this->tagsInput = (string) ($this->fornitore->tags ?? ''); $this->loadTagSuggestions(); + $this->refreshDipendentiRows(); + } + + public function creaDipendenteFornitore(): void + { + $nome = trim($this->nuovoDipendenteNome); + $email = mb_strtolower(trim($this->nuovoDipendenteEmail)); + + if ($nome === '' || $email === '') { + Notification::make()->title('Nome ed email sono obbligatori')->warning()->send(); + return; + } + + $exists = FornitoreDipendente::query() + ->where('fornitore_id', (int) $this->fornitore->id) + ->whereRaw('LOWER(email) = ?', [$email]) + ->exists(); + + if ($exists) { + Notification::make()->title('Dipendente gia presente per questo fornitore')->warning()->send(); + return; + } + + FornitoreDipendente::query()->create([ + 'fornitore_id' => (int) $this->fornitore->id, + 'nome' => $nome, + 'cognome' => trim($this->nuovoDipendenteCognome) ?: null, + 'email' => $email, + 'telefono' => trim($this->nuovoDipendenteTelefono) ?: null, + 'attivo' => true, + 'created_by_user_id' => Auth::id(), + 'updated_by_user_id' => Auth::id(), + ]); + + $this->nuovoDipendenteNome = ''; + $this->nuovoDipendenteCognome = ''; + $this->nuovoDipendenteEmail = ''; + $this->nuovoDipendenteTelefono = ''; + + $this->refreshDipendentiRows(); + Notification::make()->title('Dipendente aggiunto')->success()->send(); + } + + public function abilitaAccessoDipendente(int $dipendenteId): void + { + $dipendente = FornitoreDipendente::query() + ->where('fornitore_id', (int) $this->fornitore->id) + ->find($dipendenteId); + + if (! $dipendente instanceof FornitoreDipendente) { + Notification::make()->title('Dipendente non trovato')->danger()->send(); + return; + } + + $email = trim((string) ($dipendente->email ?? '')); + if ($email === '') { + Notification::make()->title('Email dipendente mancante')->warning()->send(); + return; + } + + $targetUser = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first(); + $created = false; + + if (! $targetUser instanceof User) { + $generatedPassword = Str::random(12); + $targetUser = User::query()->create([ + 'name' => trim((string) ($dipendente->nome_completo ?: 'Utente fornitore')), + 'email' => $email, + 'password' => Hash::make($generatedPassword), + 'email_verified_at' => now(), + 'is_active' => true, + ]); + + $this->lastGeneratedPassword = $generatedPassword; + $created = true; + } + + $targetUser->assignRole('fornitore'); + + $dipendente->user_id = (int) $targetUser->id; + $dipendente->attivo = true; + $dipendente->updated_by_user_id = Auth::id(); + $dipendente->save(); + + $this->refreshDipendentiRows(); + + Notification::make() + ->title('Accesso dipendente abilitato') + ->body($created + ? 'Accesso creato. Password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)') + : 'Accesso collegato ad utente esistente.') + ->success() + ->send(); + } + + public function resetPasswordDipendenteUser(int $userId): void + { + $dipendente = FornitoreDipendente::query() + ->where('fornitore_id', (int) $this->fornitore->id) + ->where('user_id', $userId) + ->first(); + + if (! $dipendente instanceof FornitoreDipendente) { + Notification::make()->title('Utente non collegato a questo fornitore')->danger()->send(); + return; + } + + $target = User::query()->find($userId); + if (! $target instanceof User) { + Notification::make()->title('Utente non trovato')->danger()->send(); + return; + } + + $newPassword = Str::random(12); + $target->password = Hash::make($newPassword); + $target->save(); + + $this->lastGeneratedPassword = $newPassword; + + Notification::make() + ->title('Password dipendente resettata') + ->body('Nuova password temporanea: ' . $newPassword) + ->success() + ->send(); + } + + public function toggleDipendenteAttivo(int $dipendenteId): void + { + $dipendente = FornitoreDipendente::query() + ->where('fornitore_id', (int) $this->fornitore->id) + ->find($dipendenteId); + + if (! $dipendente instanceof FornitoreDipendente) { + Notification::make()->title('Dipendente non trovato')->danger()->send(); + return; + } + + $dipendente->attivo = ! (bool) $dipendente->attivo; + $dipendente->updated_by_user_id = Auth::id(); + $dipendente->save(); + + $this->refreshDipendentiRows(); + Notification::make()->title('Stato dipendente aggiornato')->success()->send(); } public function saveTags(): void @@ -148,7 +317,7 @@ public function saveTags(): void return; } - $normalized = $this->normalizeTags($this->tagsInput); + $normalized = $this->normalizeTags($this->tagsInput); $this->fornitore->tags = implode(', ', $normalized); $this->fornitore->save(); @@ -255,6 +424,30 @@ private function loadTagSuggestions(): void $this->tagSuggestions = array_slice($pool, 0, 300); } + private function refreshDipendentiRows(): void + { + $this->dipendentiRows = FornitoreDipendente::query() + ->with('user:id,name,email') + ->where('fornitore_id', (int) $this->fornitore->id) + ->orderByDesc('attivo') + ->orderBy('nome') + ->orderBy('cognome') + ->get() + ->map(function (FornitoreDipendente $d): array { + $userId = (int) ($d->user_id ?? 0); + return [ + 'id' => (int) $d->id, + 'nome' => (string) ($d->nome_completo ?: 'Dipendente #' . $d->id), + 'email' => (string) ($d->email ?? ''), + 'telefono' => (string) ($d->telefono ?? ''), + 'attivo' => (bool) $d->attivo, + 'user_id' => $userId, + 'user_label' => $userId > 0 ? ((string) ($d->user?->name ?: ('Utente #' . $userId))): '-', + ]; + }) + ->all(); + } + /** * @return array */ @@ -285,7 +478,7 @@ private function splitTags(string $value): array $clean = trim($part); $clean = preg_replace('/\s+/', ' ', $clean) ?? ''; return $clean; - }, $parts), fn (string $p): bool => $p !== '')); + }, $parts), fn(string $p): bool => $p !== '')); } private function canonicalizeTag(string $raw): ?string @@ -296,15 +489,15 @@ private function canonicalizeTag(string $raw): ?string } $map = [ - 'idr' => 'idraulico', - 'idraul' => 'idraulico', - 'elett' => 'elettricista', + 'idr' => 'idraulico', + 'idraul' => 'idraulico', + 'elett' => 'elettricista', 'elettric' => 'elettricista', - 'ascens' => 'ascensorista', - 'puliz' => 'pulizie', - 'giardin' => 'giardiniere', - 'assicur' => 'assicurazione', - 'manut' => 'manutenzione', + 'ascens' => 'ascensorista', + 'puliz' => 'pulizie', + 'giardin' => 'giardiniere', + 'assicur' => 'assicurazione', + 'manut' => 'manutenzione', ]; foreach ($map as $prefix => $canonical) { diff --git a/app/Filament/Pages/Gescon/FornitoriArchivio.php b/app/Filament/Pages/Gescon/FornitoriArchivio.php index 368060e..2483c4c 100644 --- a/app/Filament/Pages/Gescon/FornitoriArchivio.php +++ b/app/Filament/Pages/Gescon/FornitoriArchivio.php @@ -2,6 +2,8 @@ namespace App\Filament\Pages\Gescon; use App\Models\Fornitore; +use App\Models\FornitoreDipendente; +use App\Models\RubricaUniversale; use App\Models\User; use App\Support\StabileContext; use BackedEnum; @@ -10,12 +12,15 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use UnitEnum; class FornitoriArchivio extends Page { public int $fornitoriCount = 0; - public string $search = ''; + public string $fornitoriSearch = ''; public string $activeTab = 'elenco'; public ?int $selectedFornitoreId = null; @@ -28,6 +33,36 @@ class FornitoriArchivio extends Page public ?string $contoIban = null; public ?string $contoIntestazioneEsatta = null; + public string $dipendentiRubricaSearch = ''; + + public string $searchInput = ''; + + public string $editRagioneSociale = ''; + public string $editTitolo = ''; + public string $editNome = ''; + public string $editCognome = ''; + public string $editEmail = ''; + public string $editPec = ''; + public string $editTelefono = ''; + public string $editCellulare = ''; + public string $editPartitaIva = ''; + public string $editCodiceFiscale = ''; + public string $editSitoWeb = ''; + public string $editTags = ''; + public string $editNote = ''; + + public string $newFornitoreRagioneSociale = ''; + public string $newFornitoreNome = ''; + public string $newFornitoreCognome = ''; + public string $newFornitoreEmail = ''; + public string $newFornitoreTelefono = ''; + public string $newFornitorePartitaIva = ''; + public string $newFornitoreCodiceFiscale = ''; + + public string $newDipendenteNome = ''; + public string $newDipendenteCognome = ''; + public string $newDipendenteEmail = ''; + public string $newDipendenteTelefono = ''; protected static ?string $navigationLabel = 'Fornitori'; @@ -57,6 +92,8 @@ public function mount(): void } catch (\Throwable) { $this->fornitoriCount = 0; } + + $this->searchInput = $this->fornitoriSearch; } protected function getTableQuery(): Builder @@ -94,24 +131,32 @@ public function getFornitoriRowsProperty(): Collection ->orderBy('cognome') ->orderBy('nome'); - $term = trim($this->search); + $term = trim($this->fornitoriSearch); if ($term !== '') { - $query->where(function (Builder $sub) use ($term): void { - $like = '%' . $term . '%'; + $hasTags = Schema::hasColumn('fornitori', 'tags'); + $like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%'; + + $query->where(function (Builder $sub) use ($like, $hasTags): void { $sub->where('ragione_sociale', 'like', $like) ->orWhere('nome', 'like', $like) ->orWhere('cognome', 'like', $like) ->orWhere('partita_iva', 'like', $like) ->orWhere('codice_fiscale', 'like', $like) ->orWhere('email', 'like', $like) - ->orWhere('telefono', 'like', $like) - ->orWhere('tags', 'like', $like); + ->orWhere('telefono', 'like', $like); + + if ($hasTags) { + $sub->orWhere('tags', 'like', $like); + } }); } return $query->limit(400)->get(); } + public function updatedFornitoriSearch(): void + {} + public function getFornitoreLabel(Fornitore $fornitore): string { $label = trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? '')))); @@ -136,15 +181,157 @@ public function getSelectedFornitoreProperty(): ?Fornitore } return $this->getTableQuery() + ->with(['rubrica', 'dipendenti.user']) ->withCount('dipendenti') ->find((int) $this->selectedFornitoreId); } + /** + * @return array + */ + public function getActiveSearchFiltersProperty(): array + { + return $this->extractSearchTokens((string) $this->fornitoriSearch); + } + + /** + * @return Collection + */ + public function getDipendentiRowsProperty(): Collection + { + $fornitore = $this->selectedFornitore; + if (! $fornitore instanceof Fornitore) { + return new Collection(); + } + + return FornitoreDipendente::query() + ->with('user') + ->where('fornitore_id', (int) $fornitore->id) + ->orderByDesc('attivo') + ->orderBy('cognome') + ->orderBy('nome') + ->get(); + } + + /** + * @return Collection + */ + public function getRubricaDipendentiCandidatesProperty(): Collection + { + $fornitore = $this->selectedFornitore; + if (! $fornitore instanceof Fornitore) { + return new Collection(); + } + + $query = RubricaUniversale::query() + ->whereNull('deleted_at') + ->where(function (Builder $q): void { + $q->where('tipo_contatto', 'persona_fisica') + ->orWhereNull('tipo_contatto'); + }) + ->where(function (Builder $q): void { + $q->whereNotNull('nome') + ->where('nome', '!=', '') + ->orWhereNotNull('cognome') + ->where('cognome', '!=', '') + ->orWhereNotNull('email') + ->where('email', '!=', ''); + }); + + if (Schema::hasColumn('rubrica_universale', 'amministratore_id')) { + $adminId = (int) ($fornitore->amministratore_id ?? 0); + if ($adminId > 0) { + $query->where(function (Builder $q) use ($adminId): void { + $q->where('amministratore_id', $adminId) + ->orWhereNull('amministratore_id'); + }); + } + } + + $term = trim($this->dipendentiRubricaSearch); + if ($term !== '') { + $tokens = $this->extractSearchTokens($term); + foreach ($tokens as $token) { + $like = '%' . $token . '%'; + $query->where(function (Builder $sub) use ($like): void { + $sub->where('nome', 'like', $like) + ->orWhere('cognome', 'like', $like) + ->orWhere('ragione_sociale', 'like', $like) + ->orWhere('email', 'like', $like) + ->orWhere('telefono_cellulare', 'like', $like) + ->orWhere('telefono_ufficio', 'like', $like) + ->orWhere('codice_fiscale', 'like', $like); + }); + } + } + + return $query + ->orderBy('cognome') + ->orderBy('nome') + ->limit(80) + ->get(['id', 'nome', 'cognome', 'ragione_sociale', 'email', 'telefono_cellulare', 'telefono_ufficio', 'codice_fiscale']); + } + public function apriElenco(): void { $this->activeTab = 'elenco'; } + public function apriTabScheda(): void + { + if ((int) ($this->selectedFornitoreId ?? 0) <= 0) { + Notification::make() + ->title('Seleziona prima un fornitore dalla Tab 1') + ->warning() + ->send(); + $this->activeTab = 'elenco'; + return; + } + + $this->hydrateSchedaFromSelected(); + $this->activeTab = 'scheda'; + } + + public function apriTabDipendenti(): void + { + if ((int) ($this->selectedFornitoreId ?? 0) <= 0) { + Notification::make() + ->title('Seleziona prima un fornitore dalla Tab 1') + ->warning() + ->send(); + $this->activeTab = 'elenco'; + return; + } + + $this->hydrateSchedaFromSelected(); + $this->activeTab = 'dipendenti'; + } + + public function applicaRicerca(): void + { + $this->fornitoriSearch = trim($this->searchInput); + $this->activeTab = 'elenco'; + } + + public function pulisciRicerca(): void + { + $this->fornitoriSearch = ''; + $this->searchInput = ''; + $this->activeTab = 'elenco'; + } + + public function removeSearchFilter(string $token): void + { + $tokens = array_values(array_filter( + $this->extractSearchTokens((string) $this->fornitoriSearch), + static fn(string $value): bool => mb_strtolower($value) !== mb_strtolower($token) + )); + + $this->fornitoriSearch = implode(' e ', $tokens); + $this->searchInput = $this->fornitoriSearch; + $this->activeTab = 'elenco'; + } + public function apriScheda(int $fornitoreId): void { $fornitore = $this->getTableQuery()->find($fornitoreId); @@ -158,6 +345,246 @@ public function apriScheda(int $fornitoreId): void $this->loadSchedaState($fornitore); } + public function updatedSelectedFornitoreId(): void + { + $this->hydrateSchedaFromSelected(); + } + + public function createFornitoreRapido(): void + { + $adminId = $this->resolveAmministratoreId(); + if ($adminId <= 0) { + Notification::make()->title('Amministratore non risolto')->danger()->send(); + return; + } + + $data = Validator::make([ + 'ragione_sociale' => trim($this->newFornitoreRagioneSociale), + 'nome' => trim($this->newFornitoreNome), + 'cognome' => trim($this->newFornitoreCognome), + 'email' => trim($this->newFornitoreEmail), + 'telefono' => trim($this->newFornitoreTelefono), + 'partita_iva' => trim($this->newFornitorePartitaIva), + 'codice_fiscale' => trim($this->newFornitoreCodiceFiscale), + ], [ + 'ragione_sociale' => ['nullable', 'string', 'max:255'], + 'nome' => ['nullable', 'string', 'max:255'], + 'cognome' => ['nullable', 'string', 'max:255'], + 'email' => ['nullable', 'email', 'max:255'], + 'telefono' => ['nullable', 'string', 'max:64'], + 'partita_iva' => ['nullable', 'string', 'max:32'], + 'codice_fiscale' => ['nullable', 'string', 'max:32'], + ])->validate(); + + if (($data['ragione_sociale'] ?? '') === '' && (($data['nome'] ?? '') === '' && ($data['cognome'] ?? '') === '')) { + Notification::make()->title('Inserisci almeno ragione sociale o nominativo')->warning()->send(); + return; + } + + $fornitore = Fornitore::query()->create([ + 'amministratore_id' => $adminId, + 'ragione_sociale' => $this->cleanNullable($data['ragione_sociale'] ?? null), + 'nome' => $this->cleanNullable($data['nome'] ?? null), + 'cognome' => $this->cleanNullable($data['cognome'] ?? null), + 'email' => $this->cleanNullable($data['email'] ?? null), + 'telefono' => $this->cleanNullable($data['telefono'] ?? null), + 'partita_iva' => $this->cleanNullable($data['partita_iva'] ?? null), + 'codice_fiscale' => $this->cleanNullable($data['codice_fiscale'] ?? null), + ]); + + $this->newFornitoreRagioneSociale = ''; + $this->newFornitoreNome = ''; + $this->newFornitoreCognome = ''; + $this->newFornitoreEmail = ''; + $this->newFornitoreTelefono = ''; + $this->newFornitorePartitaIva = ''; + $this->newFornitoreCodiceFiscale = ''; + + $this->fornitoriCount = (int) $this->getTableQuery()->count(); + $this->apriScheda((int) $fornitore->id); + + Notification::make()->title('Fornitore creato')->success()->send(); + } + + public function saveSchedaAnagrafica(): void + { + $fornitore = $this->selectedFornitore; + if (! $fornitore instanceof Fornitore) { + Notification::make()->title('Seleziona un fornitore')->danger()->send(); + return; + } + + $data = Validator::make([ + 'ragione_sociale' => trim($this->editRagioneSociale), + 'titolo' => trim($this->editTitolo), + 'nome' => trim($this->editNome), + 'cognome' => trim($this->editCognome), + 'email' => trim($this->editEmail), + 'pec' => trim($this->editPec), + 'telefono' => trim($this->editTelefono), + 'cellulare' => trim($this->editCellulare), + 'partita_iva' => trim($this->editPartitaIva), + 'codice_fiscale' => trim($this->editCodiceFiscale), + 'sito_web' => trim($this->editSitoWeb), + 'tags' => trim($this->editTags), + 'note' => trim($this->editNote), + ], [ + 'ragione_sociale' => ['nullable', 'string', 'max:255'], + 'titolo' => ['nullable', 'string', 'max:50'], + 'nome' => ['nullable', 'string', 'max:255'], + 'cognome' => ['nullable', 'string', 'max:255'], + 'email' => ['nullable', 'email', 'max:255'], + 'pec' => ['nullable', 'email', 'max:255'], + 'telefono' => ['nullable', 'string', 'max:64'], + 'cellulare' => ['nullable', 'string', 'max:64'], + 'partita_iva' => ['nullable', 'string', 'max:32'], + 'codice_fiscale' => ['nullable', 'string', 'max:32'], + 'sito_web' => ['nullable', 'string', 'max:255'], + 'tags' => ['nullable', 'string', 'max:1000'], + 'note' => ['nullable', 'string', 'max:5000'], + ])->validate(); + + $fornitore->ragione_sociale = $this->cleanNullable($data['ragione_sociale'] ?? null); + if (Schema::hasColumn('fornitori', 'titolo')) { + $fornitore->titolo = $this->cleanNullable($data['titolo'] ?? null); + } + $fornitore->nome = $this->cleanNullable($data['nome'] ?? null); + $fornitore->cognome = $this->cleanNullable($data['cognome'] ?? null); + $fornitore->email = $this->cleanNullable($data['email'] ?? null); + $fornitore->pec = $this->cleanNullable($data['pec'] ?? null); + $fornitore->telefono = $this->cleanNullable($data['telefono'] ?? null); + $fornitore->cellulare = $this->cleanNullable($data['cellulare'] ?? null); + $fornitore->partita_iva = $this->cleanNullable($data['partita_iva'] ?? null); + $fornitore->codice_fiscale = $this->cleanNullable($data['codice_fiscale'] ?? null); + $fornitore->sito_web = $this->cleanNullable($data['sito_web'] ?? null); + $fornitore->tags = $this->cleanNullable($data['tags'] ?? null); + $fornitore->note = $this->cleanNullable($data['note'] ?? null); + $fornitore->save(); + + $this->loadSchedaState($fornitore->fresh()); + Notification::make()->title('Anagrafica fornitore aggiornata')->success()->send(); + } + + public function addDipendenteManuale(): void + { + $fornitore = $this->selectedFornitore; + if (! $fornitore instanceof Fornitore) { + Notification::make()->title('Seleziona un fornitore')->warning()->send(); + return; + } + + $nome = trim($this->newDipendenteNome); + $email = trim($this->newDipendenteEmail); + if ($nome === '') { + Notification::make()->title('Nome dipendente obbligatorio')->warning()->send(); + return; + } + + if ($email !== '') { + $exists = FornitoreDipendente::query() + ->where('fornitore_id', (int) $fornitore->id) + ->whereRaw('LOWER(email) = ?', [mb_strtolower($email)]) + ->exists(); + if ($exists) { + Notification::make()->title('Dipendente con stessa email gia presente')->warning()->send(); + return; + } + } + + FornitoreDipendente::query()->create([ + 'fornitore_id' => (int) $fornitore->id, + 'nome' => $nome, + 'cognome' => $this->cleanNullable($this->newDipendenteCognome), + 'email' => $this->cleanNullable($email), + 'telefono' => $this->cleanNullable($this->newDipendenteTelefono), + 'attivo' => true, + 'created_by_user_id' => Auth::id(), + 'updated_by_user_id' => Auth::id(), + ]); + + $this->newDipendenteNome = ''; + $this->newDipendenteCognome = ''; + $this->newDipendenteEmail = ''; + $this->newDipendenteTelefono = ''; + + Notification::make()->title('Dipendente aggiunto')->success()->send(); + } + + public function addDipendenteFromRubrica(int $rubricaId): void + { + $fornitore = $this->selectedFornitore; + if (! $fornitore instanceof Fornitore) { + Notification::make()->title('Seleziona prima un fornitore')->warning()->send(); + $this->activeTab = 'elenco'; + return; + } + + $rubrica = RubricaUniversale::query()->find((int) $rubricaId); + if (! $rubrica instanceof RubricaUniversale) { + Notification::make()->title('Contatto rubrica non trovato')->danger()->send(); + return; + } + + $email = trim((string) ($rubrica->email ?? '')); + $nome = trim((string) ($rubrica->nome ?? '')); + $cognome = trim((string) ($rubrica->cognome ?? '')); + $telefono = trim((string) ($rubrica->telefono_cellulare ?: $rubrica->telefono_ufficio ?: '')); + + if ($nome === '' && $cognome === '') { + $fromRs = trim((string) ($rubrica->ragione_sociale ?? '')); + if ($fromRs !== '') { + $nome = $fromRs; + } else { + Notification::make()->title('Rubrica senza nominativo utile')->warning()->send(); + return; + } + } + + $exists = FornitoreDipendente::query() + ->where('fornitore_id', (int) $fornitore->id) + ->where(function (Builder $q) use ($email, $nome, $cognome, $telefono): void { + if ($email !== '') { + $q->orWhereRaw('LOWER(email) = ?', [mb_strtolower($email)]); + } + + $full = mb_strtolower(trim($nome . ' ' . $cognome)); + if ($full !== '') { + $q->orWhereRaw("LOWER(TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, '')))) = ?", [$full]); + } + + if ($telefono !== '') { + $normalized = preg_replace('/\D+/', '', $telefono) ?? ''; + if ($normalized !== '') { + $q->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono, ''), ' ', ''), '-', ''), '.', ''), '(', ''), ')', '') = ?", [$normalized]); + } + } + }) + ->exists(); + + if ($exists) { + Notification::make()->title('Dipendente gia collegato a questo fornitore')->warning()->send(); + return; + } + + $dipendente = new FornitoreDipendente(); + $dipendente->fornitore_id = (int) $fornitore->id; + $dipendente->nome = $nome; + $dipendente->cognome = $cognome !== '' ? $cognome : null; + $dipendente->email = $email !== '' ? $email : null; + $dipendente->telefono = $telefono !== '' ? $telefono : null; + $dipendente->attivo = true; + $dipendente->created_by_user_id = Auth::id(); + $dipendente->updated_by_user_id = Auth::id(); + + $existingNote = trim((string) ($rubrica->note ?? '')); + $linkNote = 'Agganciato da rubrica ID #' . (int) $rubrica->id; + $dipendente->note = Str::limit(trim($linkNote . ($existingNote !== '' ? (' | ' . $existingNote) : '')), 2000, ''); + $dipendente->save(); + + Notification::make()->title('Dipendente collegato al fornitore')->success()->send(); + $this->activeTab = 'dipendenti'; + } + public function saveSchedaAutomazioni(): void { $fornitore = $this->selectedFornitore; @@ -209,6 +636,52 @@ private function loadSchedaState(Fornitore $fornitore): void $this->contoIntestazioneEsatta = is_string($fornitore->intestazione_cc_esatta ?? null) ? $fornitore->intestazione_cc_esatta : null; + + $this->editRagioneSociale = (string) ($fornitore->ragione_sociale ?? ''); + $this->editTitolo = Schema::hasColumn('fornitori', 'titolo') + ? (string) ($fornitore->titolo ?? '') + : ''; + $this->editNome = (string) ($fornitore->nome ?? ''); + $this->editCognome = (string) ($fornitore->cognome ?? ''); + $this->editEmail = (string) ($fornitore->email ?? ''); + $this->editPec = (string) ($fornitore->pec ?? ''); + $this->editTelefono = (string) ($fornitore->telefono ?? ''); + $this->editCellulare = (string) ($fornitore->cellulare ?? ''); + $this->editPartitaIva = (string) ($fornitore->partita_iva ?? ''); + $this->editCodiceFiscale = (string) ($fornitore->codice_fiscale ?? ''); + $this->editSitoWeb = (string) ($fornitore->sito_web ?? ''); + $this->editTags = (string) ($fornitore->tags ?? ''); + $this->editNote = (string) ($fornitore->note ?? ''); + } + + private function resolveAmministratoreId(): int + { + $user = Auth::user(); + if (! $user instanceof User) { + return 0; + } + + $adminId = (int) ($user->amministratore?->id ?? 0); + if ($adminId > 0) { + return $adminId; + } + + $stabile = StabileContext::getActiveStabile($user); + return (int) ($stabile?->amministratore_id ?? 0); + } + + private function hydrateSchedaFromSelected(): void + { + if ((int) ($this->selectedFornitoreId ?? 0) <= 0) { + return; + } + + $fornitore = $this->getTableQuery()->find((int) $this->selectedFornitoreId); + if (! $fornitore instanceof Fornitore) { + return; + } + + $this->loadSchedaState($fornitore); } private function cleanNullable(?string $value): ?string @@ -216,4 +689,25 @@ private function cleanNullable(?string $value): ?string $value = trim((string) $value); return $value !== '' ? $value : null; } + + /** + * @return array + */ + private function extractSearchTokens(string $raw): array + { + $term = trim(preg_replace('/\s+/', ' ', $raw) ?? ''); + if ($term === '') { + return []; + } + + $hasLogicalSeparator = preg_match('/[,;|+]|\s(?:e|ed|and)\s/ui', $term) === 1; + if (! $hasLogicalSeparator) { + return [$term]; + } + + $parts = preg_split('/(?:[,;|+]|\s(?:e|ed|and)\s)+/ui', $term) ?: []; + $parts = array_values(array_filter(array_map(static fn(string $value): string => trim($value), $parts), static fn(string $value): bool => $value !== '')); + + return $parts !== [] ? array_values(array_unique($parts)) : [$term]; + } } diff --git a/app/Filament/Pages/Gescon/RubricaUniversaleArchivio.php b/app/Filament/Pages/Gescon/RubricaUniversaleArchivio.php index 177084d..68dadd8 100644 --- a/app/Filament/Pages/Gescon/RubricaUniversaleArchivio.php +++ b/app/Filament/Pages/Gescon/RubricaUniversaleArchivio.php @@ -1,5 +1,4 @@ hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); } public function mount(): void @@ -57,20 +57,57 @@ protected function getTableQuery(): Builder } $activeStabile = StabileContext::getActiveStabile($user); - $adminId = (int) ($activeStabile?->amministratore_id ?: 0); + $adminId = (int) ($activeStabile?->amministratore_id ?: 0); if ($adminId <= 0) { return RubricaUniversale::query()->whereRaw('1 = 0'); } $query = RubricaUniversale::query(); - // Tenant-aware: mostra TUTTI i contatti dell'amministratore (più eventuali globali se presenti). - if (Schema::hasColumn('rubrica_universale', 'amministratore_id')) { - $query->where(function (Builder $q) use ($adminId) { - $q->where('amministratore_id', $adminId) - ->orWhereNull('amministratore_id'); - }); - } + // Allineamento con Anagrafica Unica: include solo contatti realmente collegati al tenant. + $query->where(function (Builder $q) use ($adminId): void { + $q->whereRaw('1 = 0'); + + if (Schema::hasColumn('rubrica_universale', 'amministratore_id')) { + $q->orWhere('rubrica_universale.amministratore_id', $adminId); + } + + if (Schema::hasTable('fornitori')) { + $q->orWhereExists(function ($sub) use ($adminId) { + $sub->select(DB::raw(1)) + ->from('fornitori as f') + ->whereColumn('f.rubrica_id', 'rubrica_universale.id') + ->where('f.amministratore_id', $adminId); + }); + } + + if (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id')) { + $q->orWhereExists(function ($sub) use ($adminId) { + $sub->select(DB::raw(1)) + ->from('stabili as s') + ->whereColumn('s.rubrica_id', 'rubrica_universale.id') + ->where('s.amministratore_id', $adminId); + }); + } + + if (Schema::hasTable('rubrica_ruoli') && Schema::hasTable('stabili')) { + $q->orWhereExists(function ($sub) use ($adminId) { + $sub->select(DB::raw(1)) + ->from('rubrica_ruoli as rr') + ->join('stabili as s2', 's2.id', '=', 'rr.stabile_id') + ->whereColumn('rr.rubrica_id', 'rubrica_universale.id') + ->where('s2.amministratore_id', $adminId); + }); + } + }); + + $query->where(function (Builder $q): void { + $q->whereNotNull('ragione_sociale')->where('ragione_sociale', '!=', '') + ->orWhereNotNull('cognome')->where('cognome', '!=', '') + ->orWhereNotNull('nome')->where('nome', '!=', '') + ->orWhereNotNull('codice_fiscale')->where('codice_fiscale', '!=', '') + ->orWhereNotNull('partita_iva')->where('partita_iva', '!=', ''); + }); return $query ->orderByRaw("COALESCE(ragione_sociale, '')") diff --git a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php index 1f66551..82f5581 100644 --- a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php +++ b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php @@ -2,6 +2,7 @@ namespace App\Filament\Pages\Gescon; use App\Models\Fornitore; +use App\Models\FornitoreDipendente; use App\Models\RubricaContattoCanale; use App\Models\RubricaRuolo; use App\Models\RubricaUniversale; @@ -22,7 +23,9 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; class RubricaUniversaleScheda extends Page { @@ -68,6 +71,23 @@ class RubricaUniversaleScheda extends Page public bool $isInlineEditing = false; + public string $sideTab = 'collegamenti'; + + /** @var array> */ + public array $fornitoriCollegati = []; + + /** @var array> */ + public array $dipendentiFornitoreRows = []; + + /** @var array */ + public array $dipendenteRubricaSelect = []; + + /** @var array> */ + public array $dipendenteManualDraft = []; + + /** @var array */ + public array $dipendentePbxExtension = []; + /** @var array */ public array $inlineForm = []; @@ -240,6 +260,7 @@ public function mount(int | string $record): void ->all(); $this->fillInlineForm(); + $this->hydrateFornitoriWorkspace(); } public function startInlineEdit(): void @@ -257,47 +278,47 @@ public function cancelInlineEdit(): void public function saveInlineEdit(): void { $data = validator($this->inlineForm, [ - 'nome' => ['nullable', 'string', 'max:255'], - 'cognome' => ['nullable', 'string', 'max:255'], - 'ragione_sociale' => ['nullable', 'string', 'max:255'], - 'tipo_contatto' => ['nullable', 'string', 'max:120'], - 'categoria' => ['nullable', 'string', 'max:120'], - 'codice_fiscale' => ['nullable', 'string', 'max:32'], - 'partita_iva' => ['nullable', 'string', 'max:32'], - 'email' => ['nullable', 'string', 'max:255'], - 'pec' => ['nullable', 'string', 'max:255'], - 'telefono_ufficio' => ['nullable', 'string', 'max:64'], - 'telefono_cellulare' => ['nullable', 'string', 'max:64'], - 'telefono_casa' => ['nullable', 'string', 'max:64'], - 'indirizzo_completo' => ['nullable', 'string', 'max:255'], - 'tipo_utenza_call' => ['nullable', 'string', 'max:120'], + 'nome' => ['nullable', 'string', 'max:255'], + 'cognome' => ['nullable', 'string', 'max:255'], + 'ragione_sociale' => ['nullable', 'string', 'max:255'], + 'tipo_contatto' => ['nullable', 'string', 'max:120'], + 'categoria' => ['nullable', 'string', 'max:120'], + 'codice_fiscale' => ['nullable', 'string', 'max:32'], + 'partita_iva' => ['nullable', 'string', 'max:32'], + 'email' => ['nullable', 'string', 'max:255'], + 'pec' => ['nullable', 'string', 'max:255'], + 'telefono_ufficio' => ['nullable', 'string', 'max:64'], + 'telefono_cellulare' => ['nullable', 'string', 'max:64'], + 'telefono_casa' => ['nullable', 'string', 'max:64'], + 'indirizzo_completo' => ['nullable', 'string', 'max:255'], + 'tipo_utenza_call' => ['nullable', 'string', 'max:120'], 'riferimento_stabile' => ['nullable', 'string', 'max:255'], - 'riferimento_unita' => ['nullable', 'string', 'max:255'], - 'note' => ['nullable', 'string', 'max:5000'], - 'note_segreteria' => ['nullable', 'string', 'max:5000'], + 'riferimento_unita' => ['nullable', 'string', 'max:255'], + 'note' => ['nullable', 'string', 'max:5000'], + 'note_segreteria' => ['nullable', 'string', 'max:5000'], ])->validate(); $this->rubrica->fill([ - 'nome' => $this->cleanNullable($data['nome'] ?? null), - 'cognome' => $this->cleanNullable($data['cognome'] ?? null), - 'ragione_sociale' => $this->cleanNullable($data['ragione_sociale'] ?? null), - 'tipo_contatto' => $this->cleanNullable($data['tipo_contatto'] ?? null), - 'categoria' => $this->cleanNullable($data['categoria'] ?? null), - 'codice_fiscale' => $this->cleanNullable($data['codice_fiscale'] ?? null), - 'partita_iva' => $this->cleanNullable($data['partita_iva'] ?? null), - 'email' => $this->cleanNullable($data['email'] ?? null), - 'pec' => $this->cleanNullable($data['pec'] ?? null), - 'telefono_ufficio' => $this->cleanNullable($data['telefono_ufficio'] ?? null), - 'telefono_cellulare' => $this->cleanNullable($data['telefono_cellulare'] ?? null), - 'telefono_casa' => $this->cleanNullable($data['telefono_casa'] ?? null), - 'indirizzo_completo' => $this->cleanNullable($data['indirizzo_completo'] ?? null), - 'tipo_utenza_call' => $this->cleanNullable($data['tipo_utenza_call'] ?? null), - 'riferimento_stabile' => $this->cleanNullable($data['riferimento_stabile'] ?? null), - 'riferimento_unita' => $this->cleanNullable($data['riferimento_unita'] ?? null), - 'note' => $this->cleanNullable($data['note'] ?? null), - 'note_segreteria' => $this->cleanNullable($data['note_segreteria'] ?? null), + 'nome' => $this->cleanNullable($data['nome'] ?? null), + 'cognome' => $this->cleanNullable($data['cognome'] ?? null), + 'ragione_sociale' => $this->cleanNullable($data['ragione_sociale'] ?? null), + 'tipo_contatto' => $this->cleanNullable($data['tipo_contatto'] ?? null), + 'categoria' => $this->cleanNullable($data['categoria'] ?? null), + 'codice_fiscale' => $this->cleanNullable($data['codice_fiscale'] ?? null), + 'partita_iva' => $this->cleanNullable($data['partita_iva'] ?? null), + 'email' => $this->cleanNullable($data['email'] ?? null), + 'pec' => $this->cleanNullable($data['pec'] ?? null), + 'telefono_ufficio' => $this->cleanNullable($data['telefono_ufficio'] ?? null), + 'telefono_cellulare' => $this->cleanNullable($data['telefono_cellulare'] ?? null), + 'telefono_casa' => $this->cleanNullable($data['telefono_casa'] ?? null), + 'indirizzo_completo' => $this->cleanNullable($data['indirizzo_completo'] ?? null), + 'tipo_utenza_call' => $this->cleanNullable($data['tipo_utenza_call'] ?? null), + 'riferimento_stabile' => $this->cleanNullable($data['riferimento_stabile'] ?? null), + 'riferimento_unita' => $this->cleanNullable($data['riferimento_unita'] ?? null), + 'note' => $this->cleanNullable($data['note'] ?? null), + 'note_segreteria' => $this->cleanNullable($data['note_segreteria'] ?? null), 'data_ultima_modifica' => now()->toDateString(), - 'modificato_da' => Auth::id(), + 'modificato_da' => Auth::id(), ]); $this->rubrica->save(); @@ -886,24 +907,24 @@ protected function getHeaderActions(): array private function fillInlineForm(): void { $this->inlineForm = [ - 'nome' => (string) ($this->rubrica->nome ?? ''), - 'cognome' => (string) ($this->rubrica->cognome ?? ''), - 'ragione_sociale' => (string) ($this->rubrica->ragione_sociale ?? ''), - 'tipo_contatto' => (string) ($this->rubrica->tipo_contatto ?? ''), - 'categoria' => (string) ($this->rubrica->categoria ?? ''), - 'codice_fiscale' => (string) ($this->rubrica->codice_fiscale ?? ''), - 'partita_iva' => (string) ($this->rubrica->partita_iva ?? ''), - 'email' => (string) ($this->rubrica->email ?? ''), - 'pec' => (string) ($this->rubrica->pec ?? ''), - 'telefono_ufficio' => (string) ($this->rubrica->telefono_ufficio ?? ''), - 'telefono_cellulare' => (string) ($this->rubrica->telefono_cellulare ?? ''), - 'telefono_casa' => (string) ($this->rubrica->telefono_casa ?? ''), - 'indirizzo_completo' => (string) ($this->rubrica->indirizzo_completo ?? ''), - 'tipo_utenza_call' => (string) ($this->rubrica->tipo_utenza_call ?? ''), + 'nome' => (string) ($this->rubrica->nome ?? ''), + 'cognome' => (string) ($this->rubrica->cognome ?? ''), + 'ragione_sociale' => (string) ($this->rubrica->ragione_sociale ?? ''), + 'tipo_contatto' => (string) ($this->rubrica->tipo_contatto ?? ''), + 'categoria' => (string) ($this->rubrica->categoria ?? ''), + 'codice_fiscale' => (string) ($this->rubrica->codice_fiscale ?? ''), + 'partita_iva' => (string) ($this->rubrica->partita_iva ?? ''), + 'email' => (string) ($this->rubrica->email ?? ''), + 'pec' => (string) ($this->rubrica->pec ?? ''), + 'telefono_ufficio' => (string) ($this->rubrica->telefono_ufficio ?? ''), + 'telefono_cellulare' => (string) ($this->rubrica->telefono_cellulare ?? ''), + 'telefono_casa' => (string) ($this->rubrica->telefono_casa ?? ''), + 'indirizzo_completo' => (string) ($this->rubrica->indirizzo_completo ?? ''), + 'tipo_utenza_call' => (string) ($this->rubrica->tipo_utenza_call ?? ''), 'riferimento_stabile' => (string) ($this->rubrica->riferimento_stabile ?? ''), - 'riferimento_unita' => (string) ($this->rubrica->riferimento_unita ?? ''), - 'note' => (string) ($this->rubrica->note ?? ''), - 'note_segreteria' => (string) ($this->rubrica->note_segreteria ?? ''), + 'riferimento_unita' => (string) ($this->rubrica->riferimento_unita ?? ''), + 'note' => (string) ($this->rubrica->note ?? ''), + 'note_segreteria' => (string) ($this->rubrica->note_segreteria ?? ''), ]; } @@ -1020,4 +1041,221 @@ protected function getUnitaOptions($stabileId): array }) ->all(); } + + private function hydrateFornitoriWorkspace(): void + { + $rubricaId = (int) $this->rubrica->id; + $cf = trim((string) ($this->rubrica->codice_fiscale ?? '')); + $piva = trim((string) ($this->rubrica->partita_iva ?? '')); + $email = mb_strtolower(trim((string) ($this->rubrica->email ?? ''))); + + $query = Fornitore::query(); + if ($this->adminId > 0) { + $query->where('amministratore_id', $this->adminId); + } + + $query->where(function (Builder $q) use ($rubricaId, $cf, $piva, $email): void { + $q->where('rubrica_id', $rubricaId); + if ($cf !== '') { + $q->orWhere('codice_fiscale', $cf); + } + if ($piva !== '') { + $q->orWhere('partita_iva', $piva); + } + if ($email !== '') { + $q->orWhereRaw('LOWER(email) = ?', [$email]); + } + }); + + $fornitori = $query + ->orderByRaw("COALESCE(ragione_sociale, '')") + ->orderBy('id') + ->limit(80) + ->get(['id', 'rubrica_id', 'ragione_sociale', 'nome', 'cognome', 'email', 'telefono', 'cellulare', 'codice_fiscale', 'partita_iva']); + + $this->fornitoriCollegati = $fornitori->map(fn(Fornitore $f) => [ + 'id' => (int) $f->id, + 'nome' => (string) ($f->ragione_sociale ?: trim((string) ($f->nome ?? '') . ' ' . (string) ($f->cognome ?? '')) ?: ('Fornitore #' . $f->id)), + 'rubrica_id' => (int) ($f->rubrica_id ?? 0), + 'email' => (string) ($f->email ?? ''), + 'telefono' => (string) ($f->telefono ?: $f->cellulare ?: ''), + 'cf' => (string) ($f->codice_fiscale ?? ''), + 'piva' => (string) ($f->partita_iva ?? ''), + ])->all(); + + $fornitoreIds = $fornitori->pluck('id')->map(fn($v) => (int) $v)->all(); + if ($fornitoreIds === []) { + $this->dipendentiFornitoreRows = []; + return; + } + + $dipendenti = FornitoreDipendente::query() + ->with(['user.roles', 'fornitore']) + ->whereIn('fornitore_id', $fornitoreIds) + ->orderByDesc('attivo') + ->orderBy('cognome') + ->orderBy('nome') + ->limit(300) + ->get(); + + $this->dipendentiFornitoreRows = $dipendenti->map(function (FornitoreDipendente $d): array { + $user = $d->user; + $this->dipendentePbxExtension[(int) $d->id] = (string) ($user?->pbx_extension ?? ''); + + return [ + 'id' => (int) $d->id, + 'fornitore_id' => (int) $d->fornitore_id, + 'fornitore_nome' => (string) ($d->fornitore?->ragione_sociale ?: ('Fornitore #' . $d->fornitore_id)), + 'nome' => (string) $d->nome_completo, + 'email' => (string) ($d->email ?? ''), + 'telefono' => (string) ($d->telefono ?? ''), + 'attivo' => (bool) $d->attivo, + 'user_id' => $user ? (int) $user->id : null, + 'ruoli' => $user ? $user->roles->pluck('name')->values()->all() : [], + 'pbx_extension' => (string) ($user?->pbx_extension ?? ''), + ]; + })->all(); + } + + public function collegaRubricaDipendente(int $fornitoreId): void + { + $rubricaId = (int) ($this->dipendenteRubricaSelect[$fornitoreId] ?? 0); + if ($rubricaId <= 0) { + Notification::make()->title('Inserisci ID rubrica da collegare')->warning()->send(); + return; + } + + $fornitore = Fornitore::query()->find($fornitoreId); + if (! $fornitore || ($this->adminId > 0 && (int) ($fornitore->amministratore_id ?? 0) !== $this->adminId)) { + Notification::make()->title('Fornitore non valido')->danger()->send(); + return; + } + + $rubricaDip = RubricaUniversale::query()->find($rubricaId); + if (! $rubricaDip) { + Notification::make()->title('Rubrica dipendente non trovata')->danger()->send(); + return; + } + + $email = mb_strtolower(trim((string) ($rubricaDip->email ?? ''))); + $query = FornitoreDipendente::query()->where('fornitore_id', (int) $fornitore->id); + if ($email !== '') { + $query->whereRaw('LOWER(email) = ?', [$email]); + } else { + $query->where('nome', (string) ($rubricaDip->nome ?? '')) + ->where('cognome', (string) ($rubricaDip->cognome ?? '')); + } + + $dip = $query->first(); + if (! $dip) { + $dip = new FornitoreDipendente(); + $dip->fornitore_id = (int) $fornitore->id; + $dip->created_by_user_id = Auth::id(); + } + + $dip->nome = (string) ($rubricaDip->nome ?: $rubricaDip->ragione_sociale ?: 'Dipendente'); + $dip->cognome = (string) ($rubricaDip->cognome ?? ''); + $dip->email = $email !== '' ? $email : null; + $dip->telefono = (string) ($rubricaDip->telefono_cellulare ?: $rubricaDip->telefono_ufficio ?: ''); + $dip->attivo = true; + $dip->updated_by_user_id = Auth::id(); + $dip->save(); + + unset($this->dipendenteRubricaSelect[$fornitoreId]); + $this->hydrateFornitoriWorkspace(); + + Notification::make()->title('Dipendente collegato da rubrica')->success()->send(); + } + + public function creaDipendenteManuale(int $fornitoreId): void + { + $draft = (array) ($this->dipendenteManualDraft[$fornitoreId] ?? []); + $nome = trim((string) ($draft['nome'] ?? '')); + $cognome = trim((string) ($draft['cognome'] ?? '')); + $email = mb_strtolower(trim((string) ($draft['email'] ?? ''))); + $telefono = trim((string) ($draft['telefono'] ?? '')); + + if ($nome === '') { + Notification::make()->title('Nome dipendente obbligatorio')->warning()->send(); + return; + } + + $fornitore = Fornitore::query()->find($fornitoreId); + if (! $fornitore || ($this->adminId > 0 && (int) ($fornitore->amministratore_id ?? 0) !== $this->adminId)) { + Notification::make()->title('Fornitore non valido')->danger()->send(); + return; + } + + $dip = new FornitoreDipendente(); + $dip->fornitore_id = (int) $fornitore->id; + $dip->nome = $nome; + $dip->cognome = $cognome !== '' ? $cognome : null; + $dip->email = $email !== '' ? $email : null; + $dip->telefono = $telefono !== '' ? $telefono : null; + $dip->attivo = true; + $dip->created_by_user_id = Auth::id(); + $dip->updated_by_user_id = Auth::id(); + $dip->save(); + + unset($this->dipendenteManualDraft[$fornitoreId]); + $this->hydrateFornitoriWorkspace(); + + Notification::make()->title('Nuovo dipendente creato')->success()->send(); + } + + public function abilitaAccessoDipendente(int $dipendenteId): void + { + $dip = FornitoreDipendente::query()->with('fornitore')->find($dipendenteId); + if (! $dip || ($this->adminId > 0 && (int) ($dip->fornitore?->amministratore_id ?? 0) !== $this->adminId)) { + Notification::make()->title('Dipendente non valido')->danger()->send(); + return; + } + + $email = mb_strtolower(trim((string) ($dip->email ?? ''))); + if ($email === '') { + Notification::make()->title('Email dipendente mancante')->warning()->send(); + return; + } + + $user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first(); + if (! $user) { + $tmpPwd = Str::random(12); + $user = User::query()->create([ + 'name' => trim((string) ($dip->nome_completo ?: 'Utente fornitore')), + 'email' => $email, + 'password' => Hash::make($tmpPwd), + 'email_verified_at' => now(), + 'is_active' => true, + ]); + } + + $user->assignRole('fornitore'); + $dip->user_id = (int) $user->id; + $dip->updated_by_user_id = Auth::id(); + $dip->save(); + + $this->hydrateFornitoriWorkspace(); + Notification::make()->title('Accesso dipendente abilitato')->success()->send(); + } + + public function salvaInternoDipendente(int $dipendenteId): void + { + $dip = FornitoreDipendente::query()->with('user', 'fornitore')->find($dipendenteId); + if (! $dip || ! $dip->user || ($this->adminId > 0 && (int) ($dip->fornitore?->amministratore_id ?? 0) !== $this->adminId)) { + Notification::make()->title('Dipendente/utente non valido')->danger()->send(); + return; + } + + if (! Schema::hasColumn('users', 'pbx_extension')) { + Notification::make()->title('Campo interno PBX non disponibile')->warning()->send(); + return; + } + + $ext = trim((string) ($this->dipendentePbxExtension[$dipendenteId] ?? '')); + $dip->user->pbx_extension = $ext !== '' ? $ext : null; + $dip->user->save(); + + $this->hydrateFornitoriWorkspace(); + Notification::make()->title('Interno PBX aggiornato')->success()->send(); + } } diff --git a/app/Filament/Pages/Impostazioni/SchedaAmministratore.php b/app/Filament/Pages/Impostazioni/SchedaAmministratore.php index 499b615..76f609f 100644 --- a/app/Filament/Pages/Impostazioni/SchedaAmministratore.php +++ b/app/Filament/Pages/Impostazioni/SchedaAmministratore.php @@ -2,11 +2,15 @@ namespace App\Filament\Pages\Impostazioni; use App\Models\Amministratore; +use App\Models\Fornitore; +use App\Models\FornitoreDipendente; use App\Models\Stabile; +use App\Models\Ticket; use App\Models\User; use App\Support\StabileContext; use BackedEnum; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -21,6 +25,7 @@ use Filament\Schemas\Schema; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Symfony\Component\Process\Process; use UnitEnum; @@ -51,6 +56,11 @@ class SchedaAmministratore extends Page implements HasForms public ?string $lastUpdatePackageName = null; + public ?string $lastGeneratedPassword = null; + + /** @var array */ + public array $collaboratorePbxExtension = []; + public Amministratore $amministratore; public static function canAccess(): bool @@ -182,6 +192,23 @@ public function form(Schema $schema): Schema ->email() ->maxLength(255), ]), + + Section::make('Centralino studio') + ->columns(2) + ->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.note_instradamento') + ->label('Note instradamento') + ->maxLength(255), + ]), ]), Tab::make('Documento') @@ -557,6 +584,16 @@ public function form(Schema $schema): Schema ->maxLength(255), ]), ]), + + 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')), + ]), + ]), ]), ]); } @@ -794,6 +831,344 @@ public function disconnectGoogle(): void ->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 + { + $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 { + $this->collaboratorePbxExtension[(int) $user->id] = (string) ($user->pbx_extension ?? ''); + + 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_popup_enabled' => (bool) ($user->pbx_popup_enabled ?? false), + 'pbx_click_to_call_enabled' => (bool) ($user->pbx_click_to_call_enabled ?? false), + ]; + }) + ->all(); + } + + 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; + } + + $extension = trim((string) ($this->collaboratorePbxExtension[$userId] ?? '')); + $user->pbx_extension = $extension !== '' ? $extension : null; + $user->save(); + + Notification::make() + ->title('Interno collaboratore aggiornato') + ->success() + ->send(); + } + + 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()); diff --git a/app/Filament/Pages/RevisioneContabile/PrecedentiAmministratoriDettaglio.php b/app/Filament/Pages/RevisioneContabile/PrecedentiAmministratoriDettaglio.php index 16cfe98..89a926d 100644 --- a/app/Filament/Pages/RevisioneContabile/PrecedentiAmministratoriDettaglio.php +++ b/app/Filament/Pages/RevisioneContabile/PrecedentiAmministratoriDettaglio.php @@ -1,25 +1,24 @@ true, + public ?string $testoRiepilogoDateFrom = null; + public ?string $testoRiepilogoDateTo = null; + public string $testoRiepilogoSearch = ''; + public string $ordinaria2023SummarySort = 'nominativo'; + public array $ordinaria2023SummaryColumns = [ + 'interno' => true, 'nominativo' => true, - 'estratto' => true, - 'riepilogo' => true, - 'delta' => true, + 'estratto' => true, + 'riepilogo' => true, + 'delta' => true, ]; public string $versamentiSummaryGestione = 'ordinaria2023'; - public array $bilancioFiles = []; - public ?string $bilancioFile = null; + public array $bilancioFiles = []; + public ?string $bilancioFile = null; public string $bilancioContent = ''; - public string $bilancioTab = 'pdf'; + public string $bilancioTab = 'pdf'; #[Url] public string $tab = 'dettaglio'; @@ -123,21 +122,21 @@ private function loadPdfDone(): void { $path = $this->getPdfStatePath(); if (! File::exists($path)) { - $this->pdfDone = []; + $this->pdfDone = []; $this->pdfLinks = []; return; } - $raw = File::get($path); + $raw = File::get($path); $data = is_string($raw) ? json_decode($raw, true) : null; if (is_array($data) && array_is_list($data)) { - $this->pdfDone = $data; + $this->pdfDone = $data; $this->pdfLinks = []; return; } - $this->pdfDone = is_array($data['done'] ?? null) ? $data['done'] : []; - $links = is_array($data['links'] ?? null) ? $data['links'] : []; + $this->pdfDone = is_array($data['done'] ?? null) ? $data['done'] : []; + $links = is_array($data['links'] ?? null) ? $data['links'] : []; $this->pdfLinks = []; foreach ($links as $name => $unitId) { $this->pdfLinks[md5((string) $name)] = $unitId; @@ -160,8 +159,8 @@ private function savePdfDone(): void File::ensureDirectoryExists(dirname($path)); $existingLinks = []; if (File::exists($path)) { - $raw = File::get($path); - $data = is_string($raw) ? json_decode($raw, true) : null; + $raw = File::get($path); + $data = is_string($raw) ? json_decode($raw, true) : null; $existingLinks = is_array($data['links'] ?? null) ? $data['links'] : []; } @@ -182,49 +181,49 @@ private function savePdfDone(): void } } File::put($path, json_encode([ - 'done' => $this->pdfDone, - 'links' => $links, + 'done' => $this->pdfDone, + 'links' => $links, 'last_estratto_file' => $this->estrattoFile, 'last_bilancio_file' => $this->bilancioFile, - 'last_bilancio_tab' => $this->bilancioTab, + 'last_bilancio_tab' => $this->bilancioTab, ], JSON_PRETTY_PRINT)); } private function loadPdfFiles(): void { - $dir = $this->getEstrattiPdfDir(); - $this->pdfFiles = []; + $dir = $this->getEstrattiPdfDir(); + $this->pdfFiles = []; $this->pdfLinkKeyToName = []; if (! File::isDirectory($dir)) { return; } $files = File::files($dir); - $list = []; + $list = []; foreach ($files as $file) { $path = $file->getPathname(); if (! str_ends_with(strtolower($path), '.pdf')) { continue; } - $name = $file->getFilename(); - $txtName = pathinfo($name, PATHINFO_FILENAME) . '.txt'; - $txtRel = 'reports/' . $txtName; - $txtAbs = storage_path('app/' . $txtRel); - $key = md5($name); + $name = $file->getFilename(); + $txtName = pathinfo($name, PATHINFO_FILENAME) . '.txt'; + $txtRel = 'reports/' . $txtName; + $txtAbs = storage_path('app/' . $txtRel); + $key = md5($name); $this->pdfLinkKeyToName[$key] = $name; - $list[] = [ - 'name' => $name, - 'path' => $path, - 'txt_rel' => $txtRel, - 'txt_abs' => $txtAbs, - 'has_txt' => File::exists($txtAbs) && File::size($txtAbs) > 0, - 'done' => in_array($name, $this->pdfDone, true), - 'link_key' => $key, + $list[] = [ + 'name' => $name, + 'path' => $path, + 'txt_rel' => $txtRel, + 'txt_abs' => $txtAbs, + 'has_txt' => File::exists($txtAbs) && File::size($txtAbs) > 0, + 'done' => in_array($name, $this->pdfDone, true), + 'link_key' => $key, 'linked_unit_id' => $this->pdfLinks[$key] ?? null, ]; } - usort($list, fn ($a, $b) => strcmp($a['name'], $b['name'])); + usort($list, fn($a, $b) => strcmp($a['name'], $b['name'])); $this->pdfFiles = $list; } @@ -236,7 +235,7 @@ public function refreshPdfFiles(): void private function loadBilancioFiles(): void { - $dir = $this->getBilancioPdfDir(); + $dir = $this->getBilancioPdfDir(); $list = []; if (File::exists($dir)) { @@ -248,12 +247,12 @@ private function loadBilancioFiles(): void } $txtName = pathinfo($name, PATHINFO_FILENAME) . '.txt'; - $txtRel = 'reports/' . $txtName; - $txtAbs = storage_path('app/' . $txtRel); + $txtRel = 'reports/' . $txtName; + $txtAbs = storage_path('app/' . $txtRel); $list[] = [ - 'name' => $name, - 'path' => $file->getPathname(), + 'name' => $name, + 'path' => $file->getPathname(), 'txt_rel' => $txtRel, 'txt_abs' => $txtAbs, 'has_txt' => File::exists($txtAbs) && File::size($txtAbs) > 0, @@ -261,7 +260,7 @@ private function loadBilancioFiles(): void } } - usort($list, fn ($a, $b) => strcmp($a['name'], $b['name'])); + usort($list, fn($a, $b) => strcmp($a['name'], $b['name'])); $this->bilancioFiles = $list; if ($this->bilancioFile === null) { @@ -283,13 +282,13 @@ private function loadBilancioContent(): void return; } - $path = storage_path('app/' . $this->bilancioFile); + $path = storage_path('app/' . $this->bilancioFile); $this->bilancioContent = File::exists($path) ? (string) File::get($path) : ''; } public function extractBilancioTxt(string $name): void { - $dir = $this->getBilancioPdfDir(); + $dir = $this->getBilancioPdfDir(); $pdfPath = $dir . '/' . $name; if (! File::exists($pdfPath)) { Notification::make()->title('PDF non trovato')->danger()->send(); @@ -297,8 +296,8 @@ public function extractBilancioTxt(string $name): void } $txtName = pathinfo($name, PATHINFO_FILENAME) . '.txt'; - $txtRel = 'reports/' . $txtName; - $txtAbs = storage_path('app/' . $txtRel); + $txtRel = 'reports/' . $txtName; + $txtAbs = storage_path('app/' . $txtRel); File::ensureDirectoryExists(dirname($txtAbs)); $process = new Process(['pdftotext', '-layout', $pdfPath, $txtAbs]); @@ -328,7 +327,7 @@ public function updatedBilancioFile(): void public function togglePdfDone(string $name): void { if (in_array($name, $this->pdfDone, true)) { - $this->pdfDone = array_values(array_filter($this->pdfDone, fn ($v) => $v !== $name)); + $this->pdfDone = array_values(array_filter($this->pdfDone, fn($v) => $v !== $name)); } else { $this->pdfDone[] = $name; } @@ -344,7 +343,7 @@ public function updatedPdfLinks(): void public function extractPdfTxt(string $name): void { - $dir = $this->getEstrattiPdfDir(); + $dir = $this->getEstrattiPdfDir(); $pdfPath = $dir . '/' . $name; if (! File::exists($pdfPath)) { Notification::make()->title('PDF non trovato')->danger()->send(); @@ -352,8 +351,8 @@ public function extractPdfTxt(string $name): void } $txtName = pathinfo($name, PATHINFO_FILENAME) . '.txt'; - $txtRel = 'reports/' . $txtName; - $txtAbs = storage_path('app/' . $txtRel); + $txtRel = 'reports/' . $txtName; + $txtAbs = storage_path('app/' . $txtRel); File::ensureDirectoryExists(dirname($txtAbs)); $process = new Process(['pdftotext', '-layout', $pdfPath, $txtAbs]); @@ -396,10 +395,10 @@ public function getEstrattiRiepilogoAllProperty(): array $parsed = $this->parseRiepilogoFromContent($content); foreach ($parsed as $row) { $rows[] = [ - 'file' => basename((string) $file), - 'data' => $row['data'] ?? null, - 'descrizione' => $row['descrizione'] ?? '', - 'importo' => (float) ($row['importo'] ?? 0), + 'file' => basename((string) $file), + 'data' => $row['data'] ?? null, + 'descrizione' => $row['descrizione'] ?? '', + 'importo' => (float) ($row['importo'] ?? 0), 'importo_altre' => $row['importo_altre'] ?? null, ]; } @@ -441,11 +440,11 @@ public function getEstrattiGestioneStraordinariaAllProperty(): array continue; } $rows[] = [ - 'file' => basename((string) $file), - 'data' => $row['data'] ?? null, + 'file' => basename((string) $file), + 'data' => $row['data'] ?? null, 'descrizione' => $row['descrizione'] ?? '', - 'importo' => (float) ($row['importo'] ?? 0), - 'tipo' => $row['tipo'] ?? '', + 'importo' => (float) ($row['importo'] ?? 0), + 'tipo' => $row['tipo'] ?? '', ]; } } @@ -459,18 +458,18 @@ public function getEstrattiGestioneStraordinariaAllProperty(): array public function getOrdinaria2023CondominoRiepilogoComparisonProperty(): array { $gestioneRows = $this->estrattoGestioneOrdinaria; - $versRows = array_values(array_filter($gestioneRows, fn ($r) => ($r['colonna'] ?? '') === 'versamenti')); + $versRows = array_values(array_filter($gestioneRows, fn($r) => ($r['colonna'] ?? '') === 'versamenti')); - $linked = $this->estrattoLinkedUnita; + $linked = $this->estrattoLinkedUnita; $nominativo = trim((string) ($linked['denominazione'] ?? '')); if ($nominativo === '' && ! empty($versRows)) { $nominativo = $this->extractNominativoFromDescrizione((string) ($versRows[0]['descrizione'] ?? '')); } - $dates = array_values(array_filter(array_map(fn ($r) => $r['data'] ?? null, $versRows))); - $from = null; - $to = null; + $dates = array_values(array_filter(array_map(fn($r) => $r['data'] ?? null, $versRows))); + $from = null; + $to = null; foreach ($dates as $d) { $dt = $this->parseDateInput($d); if (! $dt) { @@ -484,15 +483,15 @@ public function getOrdinaria2023CondominoRiepilogoComparisonProperty(): array } } - $estrattoTot = (float) collect($versRows)->sum(fn ($r) => (float) ($r['importo'] ?? 0)); + $estrattoTot = (float) collect($versRows)->sum(fn($r) => (float) ($r['importo'] ?? 0)); $riepilogoRows = []; - $riepilogoTot = 0.0; + $riepilogoTot = 0.0; if ($nominativo !== '' && ($from || $to)) { $nameKey = $this->normalizeMatchText($nominativo); foreach ($this->estrattiRiepilogoAll as $row) { $data = $row['data'] ?? null; - $dt = $this->parseDateInput($data); + $dt = $this->parseDateInput($data); if (! $dt) { continue; } @@ -515,26 +514,26 @@ public function getOrdinaria2023CondominoRiepilogoComparisonProperty(): array continue; } - $importo = (float) ($row['importo'] ?? 0); - $riepilogoTot += $importo; - $riepilogoRows[] = [ - 'data' => $data, + $importo = (float) ($row['importo'] ?? 0); + $riepilogoTot += $importo; + $riepilogoRows[] = [ + 'data' => $data, 'descrizione' => $descr, - 'importo' => $importo, + 'importo' => $importo, ]; } } return [ - 'nominativo' => $nominativo, - 'periodo' => [ + 'nominativo' => $nominativo, + 'periodo' => [ 'from' => $from ? $from->format('d/m/Y') : null, - 'to' => $to ? $to->format('d/m/Y') : null, + 'to' => $to ? $to->format('d/m/Y') : null, ], - 'estratto_totale' => $estrattoTot, + 'estratto_totale' => $estrattoTot, 'riepilogo_totale' => $riepilogoTot, - 'delta' => $estrattoTot - $riepilogoTot, - 'riepilogo_rows' => $riepilogoRows, + 'delta' => $estrattoTot - $riepilogoTot, + 'riepilogo_rows' => $riepilogoRows, ]; } @@ -552,7 +551,7 @@ public function getVersamentiSummaryProperty(): array public function getVersamentiSummaryRowForCurrentNominativoProperty(): ?array { $linked = $this->estrattoLinkedUnita; - $name = trim((string) ($linked['denominazione'] ?? '')); + $name = trim((string) ($linked['denominazione'] ?? '')); if ($name === '') { return null; } @@ -571,8 +570,8 @@ public function getVersamentiSummaryRowForCurrentNominativoProperty(): ?array public function getRiepilogoVersamentiForCurrentNominativoProperty(): array { $linked = $this->estrattoLinkedUnita; - $name = trim((string) ($linked['denominazione'] ?? '')); - $rows = []; + $name = trim((string) ($linked['denominazione'] ?? '')); + $rows = []; $totale = 0.0; $fileBase = ''; @@ -581,12 +580,12 @@ public function getRiepilogoVersamentiForCurrentNominativoProperty(): array } $fileConstraint = $fileBase !== '' ? $fileBase . '.txt' : ''; - $gestione = $this->versamentiSummaryGestione; + $gestione = $this->versamentiSummaryGestione; $gestioneLabel = match ($gestione) { 'ordinaria2024' => 'Ordinaria 2024', 'straordinaria' => 'Straordinaria', - 'tutte' => 'Tutte', - default => 'Ordinaria 2023', + 'tutte' => 'Tutte', + default => 'Ordinaria 2023', }; $estrattoRows = []; @@ -604,16 +603,16 @@ public function getRiepilogoVersamentiForCurrentNominativoProperty(): array $estrattoRows = $this->estrattiGestioneOrdinaria2023All; } - $estrattoRows = array_values(array_filter($estrattoRows, fn ($r) => ($r['tipo'] ?? '') === 'versamento')); - $filesSet = []; - $fileRanges = []; + $estrattoRows = array_values(array_filter($estrattoRows, fn($r) => ($r['tipo'] ?? '') === 'versamento')); + $filesSet = []; + $fileRanges = []; foreach ($estrattoRows as $row) { $file = (string) ($row['file'] ?? ''); if ($file === '') { continue; } $filesSet[$file] = true; - $dt = $this->parseDateInput($row['data'] ?? null); + $dt = $this->parseDateInput($row['data'] ?? null); if (! $dt) { continue; } @@ -660,20 +659,20 @@ public function getRiepilogoVersamentiForCurrentNominativoProperty(): array continue; } $importo = (float) ($row['importo'] ?? 0); - $totale += $importo; - $rows[] = [ - 'data' => $row['data'] ?? null, + $totale += $importo; + $rows[] = [ + 'data' => $row['data'] ?? null, 'descrizione' => $descr, - 'importo' => $importo, - 'gestione' => $gestioneLabel, + 'importo' => $importo, + 'gestione' => $gestioneLabel, ]; } return [ 'nominativo' => $fileBase !== '' ? $fileBase : '', - 'totale' => $totale, - 'rows' => $rows, - 'gestione' => $gestioneLabel, + 'totale' => $totale, + 'rows' => $rows, + 'gestione' => $gestioneLabel, ]; } @@ -710,20 +709,20 @@ public function getRiepilogoVersamentiForCurrentNominativoProperty(): array } } $importo = (float) ($row['importo'] ?? 0); - $totale += $importo; - $rows[] = [ - 'data' => $row['data'] ?? null, + $totale += $importo; + $rows[] = [ + 'data' => $row['data'] ?? null, 'descrizione' => $descr, - 'importo' => $importo, - 'gestione' => $gestioneLabel, + 'importo' => $importo, + 'gestione' => $gestioneLabel, ]; } return [ 'nominativo' => $name, - 'totale' => $totale, - 'rows' => $rows, - 'gestione' => $gestioneLabel, + 'totale' => $totale, + 'rows' => $rows, + 'gestione' => $gestioneLabel, ]; } @@ -739,8 +738,8 @@ private function buildVersamentiSummary(string $gestione): array continue; } $unitMap[$this->normalizeMatchText($name)] = [ - 'nominativo' => $name, - 'interno' => $u['interno'] ?? null, + 'nominativo' => $name, + 'interno' => $u['interno'] ?? null, 'codice_unita' => $u['codice_unita'] ?? null, ]; } @@ -760,16 +759,16 @@ private function buildVersamentiSummary(string $gestione): array $estrattoRows = $this->estrattiGestioneOrdinaria2023All; } - $estrattoRows = array_values(array_filter($estrattoRows, fn ($r) => ($r['tipo'] ?? '') === 'versamento')); - $filesSet = []; - $fileRanges = []; + $estrattoRows = array_values(array_filter($estrattoRows, fn($r) => ($r['tipo'] ?? '') === 'versamento')); + $filesSet = []; + $fileRanges = []; foreach ($estrattoRows as $row) { $file = (string) ($row['file'] ?? ''); if ($file === '') { continue; } $filesSet[$file] = true; - $dt = $this->parseDateInput($row['data'] ?? null); + $dt = $this->parseDateInput($row['data'] ?? null); if (! $dt) { continue; } @@ -787,12 +786,12 @@ private function buildVersamentiSummary(string $gestione): array $estrattiTotals = []; foreach ($estrattoRows as $row) { - $descr = (string) ($row['descrizione'] ?? ''); - $file = (string) ($row['file'] ?? ''); - $linkedUnit = $file !== '' ? $this->resolveLinkedUnitaForTxt('reports/' . pathinfo($file, PATHINFO_FILENAME) . '.txt') : null; - $linkedName = $linkedUnit['denominazione'] ?? null; + $descr = (string) ($row['descrizione'] ?? ''); + $file = (string) ($row['file'] ?? ''); + $linkedUnit = $file !== '' ? $this->resolveLinkedUnitaForTxt('reports/' . pathinfo($file, PATHINFO_FILENAME) . '.txt') : null; + $linkedName = $linkedUnit['denominazione'] ?? null; $linkedInterno = $linkedUnit['interno'] ?? null; - $linkedCodice = $linkedUnit['codice_unita'] ?? null; + $linkedCodice = $linkedUnit['codice_unita'] ?? null; $name = $linkedName ?: $this->extractNominativoFromDescrizione($descr); $name = trim((string) $name); @@ -802,17 +801,17 @@ private function buildVersamentiSummary(string $gestione): array $key = $this->normalizeMatchText($name); if (! isset($estrattiTotals[$key])) { $interno = $linkedInterno ?? ($unitMap[$key]['interno'] ?? null); - $codice = $linkedCodice ?? ($unitMap[$key]['codice_unita'] ?? null); + $codice = $linkedCodice ?? ($unitMap[$key]['codice_unita'] ?? null); if (($interno === null || $interno === '' || (ctype_digit((string) $interno) && is_string($codice) && preg_match('/[A-Za-z]/', $codice))) && $codice) { $interno = $codice; } $estrattiTotals[$key] = [ - 'nominativo' => $unitMap[$key]['nominativo'] ?? $name, - 'interno' => $interno, - 'file_base' => $file !== '' ? pathinfo($file, PATHINFO_FILENAME) : null, - 'estratto_totale' => 0.0, + 'nominativo' => $unitMap[$key]['nominativo'] ?? $name, + 'interno' => $interno, + 'file_base' => $file !== '' ? pathinfo($file, PATHINFO_FILENAME) : null, + 'estratto_totale' => 0.0, 'riepilogo_totale' => 0.0, - 'delta' => 0.0, + 'delta' => 0.0, ]; } if (! empty($file) && empty($estrattiTotals[$key]['file_base'])) { @@ -821,7 +820,7 @@ private function buildVersamentiSummary(string $gestione): array $estrattiTotals[$key]['estratto_totale'] += (float) ($row['importo'] ?? 0); } - $riepilogoTotals = []; + $riepilogoTotals = []; $riepilogoTotalsByFile = []; foreach ($this->estrattiRiepilogoAll as $row) { $file = (string) ($row['file'] ?? ''); @@ -852,11 +851,11 @@ private function buildVersamentiSummary(string $gestione): array $key = $this->normalizeMatchText($name); if (! isset($riepilogoTotals[$key])) { $riepilogoTotals[$key] = [ - 'nominativo' => $unitMap[$key]['nominativo'] ?? $name, - 'interno' => $unitMap[$key]['interno'] ?? null, - 'estratto_totale' => 0.0, + 'nominativo' => $unitMap[$key]['nominativo'] ?? $name, + 'interno' => $unitMap[$key]['interno'] ?? null, + 'estratto_totale' => 0.0, 'riepilogo_totale' => 0.0, - 'delta' => 0.0, + 'delta' => 0.0, ]; } $riepilogoTotals[$key]['riepilogo_totale'] += (float) ($row['importo'] ?? 0); @@ -872,15 +871,15 @@ private function buildVersamentiSummary(string $gestione): array $keys = array_unique(array_merge(array_keys($estrattiTotals), array_keys($riepilogoTotals))); $rows = []; foreach ($keys as $key) { - $e = $estrattiTotals[$key] ?? null; - $r = $riepilogoTotals[$key] ?? null; - $nominativo = $e['nominativo'] ?? ($r['nominativo'] ?? ''); - $interno = $e['interno'] ?? ($r['interno'] ?? null); - $estrattoTot = (float) ($e['estratto_totale'] ?? 0); + $e = $estrattiTotals[$key] ?? null; + $r = $riepilogoTotals[$key] ?? null; + $nominativo = $e['nominativo'] ?? ($r['nominativo'] ?? ''); + $interno = $e['interno'] ?? ($r['interno'] ?? null); + $estrattoTot = (float) ($e['estratto_totale'] ?? 0); $riepilogoTot = (float) ($r['riepilogo_totale'] ?? 0); if (! empty($e['file_base'] ?? null)) { $fileName = (string) ($e['file_base'] ?? ''); - $fileKey = $fileName !== '' ? $fileName . '.txt' : ''; + $fileKey = $fileName !== '' ? $fileName . '.txt' : ''; if ($fileKey !== '' && isset($riepilogoTotalsByFile[$key][$fileKey])) { $riepilogoTot = (float) $riepilogoTotalsByFile[$key][$fileKey]; } elseif ($fileKey !== '' && isset($riepilogoTotalsByFile[$key][$fileName])) { @@ -888,20 +887,20 @@ private function buildVersamentiSummary(string $gestione): array } } $rows[] = [ - 'nominativo' => $nominativo, - 'interno' => $interno, - 'file_base' => $e['file_base'] ?? null, - 'estratto_totale' => $estrattoTot, + 'nominativo' => $nominativo, + 'interno' => $interno, + 'file_base' => $e['file_base'] ?? null, + 'estratto_totale' => $estrattoTot, 'riepilogo_totale' => $riepilogoTot, - 'delta' => $estrattoTot - $riepilogoTot, + 'delta' => $estrattoTot - $riepilogoTot, ]; } $sort = $this->ordinaria2023SummarySort; usort($rows, function ($a, $b) use ($sort): int { if ($sort === 'interno') { - $ai = $a['interno'] ?? ''; - $bi = $b['interno'] ?? ''; + $ai = $a['interno'] ?? ''; + $bi = $b['interno'] ?? ''; $aiVal = $ai === '' ? PHP_INT_MAX : (int) preg_replace('/\D+/', '', (string) $ai); $biVal = $bi === '' ? PHP_INT_MAX : (int) preg_replace('/\D+/', '', (string) $bi); if ($aiVal === $biVal) { @@ -925,8 +924,8 @@ private function parseRiepilogoFromContent(string $content): array return []; } - $lines = preg_split('/\R/u', $content) ?: []; - $rows = []; + $lines = preg_split('/\R/u', $content) ?: []; + $rows = []; $inSection = false; foreach ($lines as $line) { @@ -952,7 +951,7 @@ private function parseRiepilogoFromContent(string $content): array continue; } - $data = $m[1] ?? null; + $data = $m[1] ?? null; $descrizione = trim((string) ($m[2] ?? '')); $amounts = $this->parseSignedAmounts($line); @@ -960,15 +959,15 @@ private function parseRiepilogoFromContent(string $content): array continue; } - $importo = $amounts[0] ?? 0.0; + $importo = $amounts[0] ?? 0.0; $importoAlt = $amounts[1] ?? null; $rows[] = [ - 'data' => $data, - 'descrizione' => $descrizione, - 'importo' => $importo, + 'data' => $data, + 'descrizione' => $descrizione, + 'importo' => $importo, 'importo_altre' => $importoAlt, - 'raw_line' => $line, + 'raw_line' => $line, ]; } @@ -992,11 +991,11 @@ private function getEstrattiGestioneOrdinariaAllByYear(int $year): array continue; } $rows[] = [ - 'file' => basename((string) $file), - 'data' => $row['data'] ?? null, + 'file' => basename((string) $file), + 'data' => $row['data'] ?? null, 'descrizione' => $row['descrizione'] ?? '', - 'importo' => (float) ($row['importo'] ?? 0), - 'tipo' => $row['tipo'] ?? '', + 'importo' => (float) ($row['importo'] ?? 0), + 'tipo' => $row['tipo'] ?? '', ]; } } @@ -1013,8 +1012,8 @@ private function parseStraordinariaFromContent(string $content): array return []; } - $lines = preg_split('/\R/u', $content) ?: []; - $rows = []; + $lines = preg_split('/\R/u', $content) ?: []; + $rows = []; $inSection = false; foreach ($lines as $line) { @@ -1047,13 +1046,13 @@ private function parseStraordinariaFromContent(string $content): array continue; } - $tipo = null; + $tipo = null; $colonna = null; if (preg_match('/^\(?\s*versamento/i', $rest)) { - $tipo = 'versamento'; + $tipo = 'versamento'; $colonna = 'versamenti'; } elseif (preg_match('/rata\s+straordinaria/i', $rest)) { - $tipo = 'rata'; + $tipo = 'rata'; $colonna = 'rate'; } @@ -1068,12 +1067,12 @@ private function parseStraordinariaFromContent(string $content): array $importo = $amounts[0]; $rows[] = [ - 'data' => $data, + 'data' => $data, 'descrizione' => $rest, - 'importo' => $importo, - 'tipo' => $tipo, - 'colonna' => $colonna ?? 'rate', - 'raw_line' => $line, + 'importo' => $importo, + 'tipo' => $tipo, + 'colonna' => $colonna ?? 'rate', + 'raw_line' => $line, ]; } @@ -1089,7 +1088,7 @@ public function getEstrattoLinkedUnitaProperty(): ?array return null; } - $txtRel = $this->estrattoFile; + $txtRel = $this->estrattoFile; $txtBase = pathinfo($txtRel, PATHINFO_FILENAME); $pdfRow = null; @@ -1132,13 +1131,13 @@ public function importEstrattoRiepilogo(): void return; } - $imported = 0; + $imported = 0; $duplicates = 0; $sourceFile = $this->estrattoFile ? basename((string) $this->estrattoFile) : null; DB::transaction(function () use ($rows, $sourceFile, &$imported, &$duplicates) { foreach ($rows as $row) { - $data = $row['data'] ?? null; + $data = $row['data'] ?? null; $dataIso = null; if ($data) { try { @@ -1149,7 +1148,7 @@ public function importEstrattoRiepilogo(): void } $mainImporto = (float) ($row['importo'] ?? 0); - $altImporto = $row['importo_altre'] ?? null; + $altImporto = $row['importo_altre'] ?? null; $candidates = [ ['importo' => $mainImporto, 'colonna' => 'principale', 'tipo_riga' => 'versamento'], @@ -1180,20 +1179,20 @@ public function importEstrattoRiepilogo(): void RevisionePrecedenteMovimentoBanca::query()->create([ 'revisione_precedente_amministratore_id' => $this->record->id, - 'stabile_id' => $this->record->stabile_id, - 'data' => $dataIso, - 'numero' => null, - 'tipo_riga' => $cand['tipo_riga'] ?? null, - 'descrizione' => $row['descrizione'] ?? null, - 'descrizione_estesa' => null, - 'importo' => $cand['importo'] ?? null, - 'saldo' => null, - 'source_file' => $sourceFile, - 'raw_line' => $row['raw_line'] ?? null, - 'row_hash' => $rowHash, - 'payload' => [ + 'stabile_id' => $this->record->stabile_id, + 'data' => $dataIso, + 'numero' => null, + 'tipo_riga' => $cand['tipo_riga'] ?? null, + 'descrizione' => $row['descrizione'] ?? null, + 'descrizione_estesa' => null, + 'importo' => $cand['importo'] ?? null, + 'saldo' => null, + 'source_file' => $sourceFile, + 'raw_line' => $row['raw_line'] ?? null, + 'row_hash' => $rowHash, + 'payload' => [ 'estratto_tipo' => 'riepilogo_versamenti', - 'colonna' => $cand['colonna'] ?? null, + 'colonna' => $cand['colonna'] ?? null, ], ]); $imported++; @@ -1234,7 +1233,7 @@ public function getEstrattoConguagliProperty(): array return []; } - return array_values(array_filter($rows, fn (array $row): bool => ($row['tipo'] ?? '') === 'conguaglio')); + return array_values(array_filter($rows, fn(array $row): bool => ($row['tipo'] ?? '') === 'conguaglio')); } /** @@ -1247,8 +1246,8 @@ public function getBilancioRiepilogoProperty(): array return []; } - $lines = preg_split('/\R/u', $content) ?: []; - $rows = []; + $lines = preg_split('/\R/u', $content) ?: []; + $rows = []; $inSection = false; foreach ($lines as $line) { @@ -1275,14 +1274,14 @@ public function getBilancioRiepilogoProperty(): array continue; } - $importo = $amounts[0]; + $importo = $amounts[0]; $descrizione = preg_replace('/\s*-?€\s*[0-9\.]+,[0-9]{2}.*/u', '', $line); $descrizione = trim((string) $descrizione); $rows[] = [ 'descrizione' => $descrizione !== '' ? $descrizione : $line, - 'importo' => (float) $importo, - 'raw_line' => $line, + 'importo' => (float) $importo, + 'raw_line' => $line, ]; } @@ -1299,10 +1298,10 @@ public function getBilancioOrdinaria2023Property(): array return []; } - $lines = preg_split('/\R/u', $content) ?: []; - $rows = []; + $lines = preg_split('/\R/u', $content) ?: []; + $rows = []; $inSection = false; - $inTable = false; + $inTable = false; foreach ($lines as $line) { $line = trim((string) $line); @@ -1334,20 +1333,20 @@ public function getBilancioOrdinaria2023Property(): array $numbers = $m[0] ?? []; } if (count($numbers) >= 3) { - $nums = array_slice($numbers, -3); + $nums = array_slice($numbers, -3); $preventivo = (float) str_replace(',', '.', str_replace('.', '', $nums[0])); - $spese = (float) str_replace(',', '.', str_replace('.', '', $nums[1])); - $sbilancio = (float) str_replace(',', '.', str_replace('.', '', $nums[2])); - $rows[] = [ - 'mastro' => null, - 'conto' => null, + $spese = (float) str_replace(',', '.', str_replace('.', '', $nums[1])); + $sbilancio = (float) str_replace(',', '.', str_replace('.', '', $nums[2])); + $rows[] = [ + 'mastro' => null, + 'conto' => null, 'descrizione' => 'Totale', - 'preventivo' => $preventivo, - 'spese' => $spese, - 'sbilancio' => $sbilancio, - 'raw_line' => $line, - 'is_totale' => true, - 'is_mastro' => false, + 'preventivo' => $preventivo, + 'spese' => $spese, + 'sbilancio' => $sbilancio, + 'raw_line' => $line, + 'is_totale' => true, + 'is_mastro' => false, ]; } break; @@ -1363,44 +1362,44 @@ public function getBilancioOrdinaria2023Property(): array } if (count($numbers) >= 3) { - $nums = array_slice($numbers, -3); + $nums = array_slice($numbers, -3); $preventivo = (float) str_replace(',', '.', str_replace('.', '', $nums[0])); - $spese = (float) str_replace(',', '.', str_replace('.', '', $nums[1])); - $sbilancio = (float) str_replace(',', '.', str_replace('.', '', $nums[2])); + $spese = (float) str_replace(',', '.', str_replace('.', '', $nums[1])); + $sbilancio = (float) str_replace(',', '.', str_replace('.', '', $nums[2])); } else { - $nums = array_slice($numbers, -2); + $nums = array_slice($numbers, -2); $preventivo = (float) str_replace(',', '.', str_replace('.', '', $nums[0])); - $spese = (float) str_replace(',', '.', str_replace('.', '', $nums[1])); - $sbilancio = $spese - $preventivo; + $spese = (float) str_replace(',', '.', str_replace('.', '', $nums[1])); + $sbilancio = $spese - $preventivo; } - $descrRaw = trim((string) preg_replace('/-?\d{1,3}(?:\.\d{3})*,\d{2}.*/u', '', $line)); - $mastro = null; - $conto = null; + $descrRaw = trim((string) preg_replace('/-?\d{1,3}(?:\.\d{3})*,\d{2}.*/u', '', $line)); + $mastro = null; + $conto = null; $descrizione = $descrRaw; if (preg_match('/^(\d{2})\/(\d{3})\s+(.*)$/u', $descrRaw, $mm)) { - $mastro = $mm[1] ?? null; - $conto = $mm[2] ?? null; + $mastro = $mm[1] ?? null; + $conto = $mm[2] ?? null; $descrizione = trim((string) ($mm[3] ?? $descrRaw)); } elseif (preg_match('/^(\d{2})\s+(.*)$/u', $descrRaw, $mm)) { - $mastro = $mm[1] ?? null; - $conto = null; + $mastro = $mm[1] ?? null; + $conto = null; $descrizione = trim((string) ($mm[2] ?? $descrRaw)); } $isMastro = $mastro !== null && $conto === null; $rows[] = [ - 'mastro' => $mastro, - 'conto' => $conto, + 'mastro' => $mastro, + 'conto' => $conto, 'descrizione' => $descrizione !== '' ? $descrizione : $descrRaw, - 'preventivo' => $preventivo, - 'spese' => $spese, - 'sbilancio' => $sbilancio, - 'raw_line' => $line, - 'is_totale' => false, - 'is_mastro' => $isMastro, + 'preventivo' => $preventivo, + 'spese' => $spese, + 'sbilancio' => $sbilancio, + 'raw_line' => $line, + 'is_totale' => false, + 'is_mastro' => $isMastro, ]; } @@ -1417,8 +1416,8 @@ public function getBilancioContoEconomicoProperty(): array return []; } - $lines = preg_split('/\R/u', $content) ?: []; - $rows = []; + $lines = preg_split('/\R/u', $content) ?: []; + $rows = []; $inSection = false; foreach ($lines as $line) { @@ -1438,14 +1437,14 @@ public function getBilancioContoEconomicoProperty(): array if (preg_match('/^Totale\s+ricavi\s*\/\s*costi/i', $line)) { $amounts = $this->parseSignedAmounts($line); - $costi = $amounts[0] ?? null; - $ricavi = $amounts[1] ?? null; - $rows[] = [ - 'descrizione' => 'Totale ricavi / costi', - 'costi' => $costi !== null ? (float) $costi : null, - 'ricavi' => $ricavi !== null ? (float) $ricavi : null, - 'raw_line' => $line, - 'is_totale' => true, + $costi = $amounts[0] ?? null; + $ricavi = $amounts[1] ?? null; + $rows[] = [ + 'descrizione' => 'Totale ricavi / costi', + 'costi' => $costi !== null ? (float) $costi : null, + 'ricavi' => $ricavi !== null ? (float) $ricavi : null, + 'raw_line' => $line, + 'is_totale' => true, 'is_versamenti' => false, ]; break; @@ -1463,10 +1462,10 @@ public function getBilancioContoEconomicoProperty(): array $descrizione = preg_replace('/€?\s*-?[0-9\.]+,[0-9]{2}.*/u', '', $line); $descrizione = trim((string) $descrizione); - $isTotale = preg_match('/^Totale\b/i', $line) === 1; + $isTotale = preg_match('/^Totale\b/i', $line) === 1; $isVersamenti = stripos($line, 'VERSAMENTI ESEGUITI DA CONDOMINI') !== false; - $forceRicavi = stripos($line, 'Debiti/crediti v/condomini gestione Ordinaria 2023') !== false - || stripos($line, 'Arrotondamento') !== false; + $forceRicavi = stripos($line, 'Debiti/crediti v/condomini gestione Ordinaria 2023') !== false + || stripos($line, 'Arrotondamento') !== false; $mastro = null; if (preg_match('/^(\d{2})\s+.*$/u', $descrizione, $mm)) { @@ -1476,10 +1475,10 @@ public function getBilancioContoEconomicoProperty(): array } } - $costi = null; + $costi = null; $ricavi = null; if (count($amounts) >= 2) { - $costi = (float) $amounts[0]; + $costi = (float) $amounts[0]; $ricavi = (float) $amounts[1]; } else { if ($forceRicavi || $isVersamenti || stripos($line, 'ricavi') !== false) { @@ -1490,13 +1489,13 @@ public function getBilancioContoEconomicoProperty(): array } $rows[] = [ - 'descrizione' => $descrizione !== '' ? $descrizione : $line, - 'costi' => $costi, - 'ricavi' => $ricavi, - 'raw_line' => $line, - 'is_totale' => $isTotale, + 'descrizione' => $descrizione !== '' ? $descrizione : $line, + 'costi' => $costi, + 'ricavi' => $ricavi, + 'raw_line' => $line, + 'is_totale' => $isTotale, 'is_versamenti' => $isVersamenti, - 'mastro' => $mastro, + 'mastro' => $mastro, ]; } @@ -1511,7 +1510,7 @@ public function getEstrattiVersamentiTotalProperty(): float } return (float) collect($rows) ->where('colonna', 'versamenti') - ->sum(fn ($r) => (float) ($r['importo'] ?? 0)); + ->sum(fn($r) => (float) ($r['importo'] ?? 0)); } /** @@ -1519,7 +1518,7 @@ public function getEstrattiVersamentiTotalProperty(): float */ public function getBilancioMastroSpeseTotalsProperty(): array { - $rows = $this->bilancioOrdinaria2023; + $rows = $this->bilancioOrdinaria2023; $totals = []; foreach ($rows as $row) { if (! ($row['is_mastro'] ?? false)) { @@ -1544,8 +1543,8 @@ public function getBilancioStatoPatrimonialeProperty(): array return []; } - $lines = preg_split('/\R/u', $content) ?: []; - $rows = []; + $lines = preg_split('/\R/u', $content) ?: []; + $rows = []; $inSection = false; foreach ($lines as $line) { @@ -1578,12 +1577,12 @@ public function getBilancioStatoPatrimonialeProperty(): array $descrizione = preg_replace('/€?\s*-?[0-9\.]+,[0-9]{2}.*/u', '', $line); $descrizione = trim((string) $descrizione); - $isTotale = preg_match('/^Totale\b/i', $line) === 1; + $isTotale = preg_match('/^Totale\b/i', $line) === 1; - $attivo = null; + $attivo = null; $passivo = null; if (count($amounts) >= 2) { - $attivo = (float) $amounts[0]; + $attivo = (float) $amounts[0]; $passivo = (float) $amounts[1]; } else { $importo = (float) $amounts[0]; @@ -1596,10 +1595,10 @@ public function getBilancioStatoPatrimonialeProperty(): array $rows[] = [ 'descrizione' => $descrizione !== '' ? $descrizione : $line, - 'attivo' => $attivo, - 'passivo' => $passivo, - 'raw_line' => $line, - 'is_totale' => $isTotale, + 'attivo' => $attivo, + 'passivo' => $passivo, + 'raw_line' => $line, + 'is_totale' => $isTotale, ]; } @@ -1617,10 +1616,10 @@ public function getBilancioStatoPatrimonialeProperty(): array }; $firstLabel = $normalize('Debiti v/fornitori gestione Ordinaria 2020 -'); - $lastLabel = $normalize('Debiti v/fornitori gestione Straordinaria Computo Metrico lavori cortile LevelOn -'); + $lastLabel = $normalize('Debiti v/fornitori gestione Straordinaria Computo Metrico lavori cortile LevelOn -'); $firstIndex = null; - $lastIndex = null; + $lastIndex = null; foreach ($rows as $idx => $row) { $label = $normalize($row['descrizione'] ?? ''); if ($firstIndex === null && $label === $firstLabel) { @@ -1635,22 +1634,22 @@ public function getBilancioStatoPatrimonialeProperty(): array return $rows; } - $totalsLabels = array_map(fn ($t) => $normalize($t['label'] ?? ''), $fornitoriTotals); - $totalsFirst = array_search($firstLabel, $totalsLabels, true); - $totalsLast = array_search($lastLabel, $totalsLabels, true); + $totalsLabels = array_map(fn($t) => $normalize($t['label'] ?? ''), $fornitoriTotals); + $totalsFirst = array_search($firstLabel, $totalsLabels, true); + $totalsLast = array_search($lastLabel, $totalsLabels, true); if ($totalsFirst === false || $totalsLast === false || $totalsFirst > $totalsLast) { return $rows; } - $slice = array_slice($fornitoriTotals, $totalsFirst, $totalsLast - $totalsFirst + 1); + $slice = array_slice($fornitoriTotals, $totalsFirst, $totalsLast - $totalsFirst + 1); $linkedRows = []; foreach ($slice as $total) { $linkedRows[] = [ 'descrizione' => (string) ($total['label'] ?? ''), - 'attivo' => (float) ($total['crediti'] ?? 0), - 'passivo' => (float) ($total['debiti'] ?? 0), - 'raw_line' => 'FORNITORI_TOTALE', - 'is_totale' => false, + 'attivo' => (float) ($total['crediti'] ?? 0), + 'passivo' => (float) ($total['debiti'] ?? 0), + 'raw_line' => 'FORNITORI_TOTALE', + 'is_totale' => false, ]; } @@ -1672,24 +1671,24 @@ protected function getBilancioFornitoriSectionTotals(): array } $grouped = []; - $order = []; + $order = []; foreach ($rows as $row) { - $parts = explode(' · ', (string) ($row['descrizione'] ?? ''), 2); + $parts = explode(' · ', (string) ($row['descrizione'] ?? ''), 2); $section = count($parts) > 1 ? $parts[0] : 'Sezione fornitori'; if (! array_key_exists($section, $grouped)) { $grouped[$section] = ['crediti' => 0.0, 'debiti' => 0.0]; - $order[] = $section; + $order[] = $section; } $grouped[$section]['crediti'] += (float) ($row['crediti'] ?? 0); - $grouped[$section]['debiti'] += (float) ($row['debiti'] ?? 0); + $grouped[$section]['debiti'] += (float) ($row['debiti'] ?? 0); } $totals = []; foreach ($order as $section) { $totals[] = [ - 'label' => $section, + 'label' => $section, 'crediti' => (float) ($grouped[$section]['crediti'] ?? 0), - 'debiti' => (float) ($grouped[$section]['debiti'] ?? 0), + 'debiti' => (float) ($grouped[$section]['debiti'] ?? 0), ]; } @@ -1706,11 +1705,11 @@ public function getBilancioCreditiDebitiFornitoriProperty(): array return []; } - $lines = preg_split('/\R/u', $content) ?: []; - $rows = []; - $inSection = false; + $lines = preg_split('/\R/u', $content) ?: []; + $rows = []; + $inSection = false; $pendingIndex = null; - $pendingDesc = null; + $pendingDesc = null; $sectionLabel = null; foreach ($lines as $line) { @@ -1722,9 +1721,9 @@ public function getBilancioCreditiDebitiFornitoriProperty(): array if (preg_match('/CREDITI\s*\/\s*DEBITI\s+VERSO\s+FORNITORI/i', $line) || preg_match('/DEBITI\s+V\/FORNITORI/i', $line) ) { - $inSection = true; + $inSection = true; $pendingIndex = null; - $pendingDesc = null; + $pendingDesc = null; $sectionLabel = trim($line); continue; } @@ -1745,11 +1744,11 @@ public function getBilancioCreditiDebitiFornitoriProperty(): array if (preg_match('/^\d+\s*$/u', $line)) { $pendingIndex = trim($line); - $pendingDesc = null; + $pendingDesc = null; continue; } - $amounts = $this->parseSignedAmounts($line); + $amounts = $this->parseSignedAmounts($line); $descrizione = preg_replace('/€?\s*-?[0-9\.]+,[0-9]{2}.*/u', '', $line); $descrizione = trim((string) $descrizione); @@ -1761,33 +1760,33 @@ public function getBilancioCreditiDebitiFornitoriProperty(): array } if ($pendingIndex) { - $prefix = $pendingIndex . ' '; - $descrizione = $prefix . ($pendingDesc ?: ($descrizione !== '' ? $descrizione : $line)); + $prefix = $pendingIndex . ' '; + $descrizione = $prefix . ($pendingDesc ?: ($descrizione !== '' ? $descrizione : $line)); $pendingIndex = null; - $pendingDesc = null; + $pendingDesc = null; } $crediti = null; - $debiti = null; + $debiti = null; if (count($amounts) >= 2) { $crediti = (float) $amounts[0]; - $debiti = (float) $amounts[1]; + $debiti = (float) $amounts[1]; } else { - $importo = (float) ($amounts[0] ?? 0); + $importo = (float) ($amounts[0] ?? 0); $forceCredito = stripos($line, 'ACEA Energia SpA Mercato Libero - fatt. 10122003394060') !== false - || stripos($line, 'Sire Service srl - fatt. 3408/2023') !== false + || stripos($line, 'Sire Service srl - fatt. 3408/2023') !== false || (stripos($line, 'SPIEZIO DOMENICO') !== false && stripos($line, 'fatt. 67') === false); $forceDebito = stripos($line, 'UNOENERGY S.P.A. - fatt. 11837582') !== false - || stripos($line, 'UNOENERGY S.P.A. - fatt. 11837581') !== false - || stripos($line, 'UNOENERGY S.P.A. - fatt. 10027448') !== false - || stripos($line, 'AD SERVICE 2009 SRL - fatt. FV 23003890') !== false; + || stripos($line, 'UNOENERGY S.P.A. - fatt. 11837581') !== false + || stripos($line, 'UNOENERGY S.P.A. - fatt. 10027448') !== false + || stripos($line, 'AD SERVICE 2009 SRL - fatt. FV 23003890') !== false; if ($forceCredito) { $crediti = $importo; } elseif ($forceDebito) { $debiti = $importo; } else { - $pos = strpos($line, (string) $amounts[0]); + $pos = strpos($line, (string) $amounts[0]); $isCredito = $pos !== false && $pos < 70; if ($isCredito) { $crediti = $importo; @@ -1804,10 +1803,10 @@ public function getBilancioCreditiDebitiFornitoriProperty(): array $rows[] = [ 'descrizione' => $label, - 'crediti' => $crediti, - 'debiti' => $debiti, - 'raw_line' => $line, - 'is_totale' => false, + 'crediti' => $crediti, + 'debiti' => $debiti, + 'raw_line' => $line, + 'is_totale' => false, ]; } @@ -1819,12 +1818,12 @@ public function getBilancioCreditiDebitiFornitoriProperty(): array */ public function getConguagliComparisonProperty(): array { - $linked = $this->estrattoLinkedUnita; + $linked = $this->estrattoLinkedUnita; $conguagli = $this->estrattoConguagli; $pdfTotals = [ - '2023' => 0.0, - '2024' => 0.0, + '2023' => 0.0, + '2024' => 0.0, 'total' => 0.0, ]; @@ -1848,14 +1847,14 @@ public function getConguagliComparisonProperty(): array } $legacyTotals = [ - '2023' => null, - '2024' => null, + '2023' => null, + '2024' => null, 'total' => null, ]; $legacyMatch = null; if ($linked && Schema::connection('gescon_import')->hasTable('condomin') && Schema::connection('gescon_import')->hasTable('dett_tab')) { - $name = trim((string) ($linked['denominazione'] ?? '')); + $name = trim((string) ($linked['denominazione'] ?? '')); $stabileLegacy = $this->record->stabile->old_id ?? $this->record->stabile->codice_stabile ?? null; $condominQ = DB::connection('gescon_import')->table('condomin'); @@ -1864,8 +1863,8 @@ public function getConguagliComparisonProperty(): array } if ($name !== '') { - $upperName = mb_strtoupper($name); - $hasNome = Schema::connection('gescon_import')->hasColumn('condomin', 'nome'); + $upperName = mb_strtoupper($name); + $hasNome = Schema::connection('gescon_import')->hasColumn('condomin', 'nome'); $hasCognome = Schema::connection('gescon_import')->hasColumn('condomin', 'cognome'); $hasNomCond = Schema::connection('gescon_import')->hasColumn('condomin', 'nom_cond'); if ($hasNome && $hasCognome) { @@ -1878,7 +1877,7 @@ public function getConguagliComparisonProperty(): array $condomin = $condominQ->first(); if ($condomin) { $legacyMatch = [ - 'id_cond' => $condomin->cod_cond ?? $condomin->id_cond ?? null, + 'id_cond' => $condomin->cod_cond ?? $condomin->id_cond ?? null, 'nominativo' => trim((string) (($condomin->cognome ?? '') . ' ' . ($condomin->nome ?? ''))) ?: (string) ($condomin->nom_cond ?? ''), ]; @@ -1896,8 +1895,8 @@ public function getConguagliComparisonProperty(): array } if (Schema::connection('gescon_import')->hasColumn('dett_tab', 'legacy_year')) { - $legacyTotals['2023'] = (float) (clone $detQ)->where('legacy_year', '2023')->sum('cons_euro'); - $legacyTotals['2024'] = (float) (clone $detQ)->where('legacy_year', '2024')->sum('cons_euro'); + $legacyTotals['2023'] = (float) (clone $detQ)->where('legacy_year', '2023')->sum('cons_euro'); + $legacyTotals['2024'] = (float) (clone $detQ)->where('legacy_year', '2024')->sum('cons_euro'); $legacyTotals['total'] = (float) $detQ->sum('cons_euro'); } else { $legacyTotals['total'] = (float) $detQ->sum('cons_euro'); @@ -1906,17 +1905,17 @@ public function getConguagliComparisonProperty(): array } $deltaTotals = [ - '2023' => $legacyTotals['2023'] !== null ? ($pdfTotals['2023'] - (float) $legacyTotals['2023']) : null, - '2024' => $legacyTotals['2024'] !== null ? ($pdfTotals['2024'] - (float) $legacyTotals['2024']) : null, + '2023' => $legacyTotals['2023'] !== null ? ($pdfTotals['2023'] - (float) $legacyTotals['2023']) : null, + '2024' => $legacyTotals['2024'] !== null ? ($pdfTotals['2024'] - (float) $legacyTotals['2024']) : null, 'total' => $legacyTotals['total'] !== null ? ($pdfTotals['total'] - (float) $legacyTotals['total']) : null, ]; return [ - 'linked_unit' => $linked, - 'pdf_totals' => $pdfTotals, + 'linked_unit' => $linked, + 'pdf_totals' => $pdfTotals, 'legacy_totals' => $legacyTotals, - 'delta_totals' => $deltaTotals, - 'legacy_match' => $legacyMatch, + 'delta_totals' => $deltaTotals, + 'legacy_match' => $legacyMatch, ]; } @@ -1943,7 +1942,7 @@ public function getEstrattoGestioneOrdinaria2024Property(): array public function getEstrattiVersamentiOrdinaria2023TotalsProperty(): array { $totals = []; - $files = $this->estrattiFiles; + $files = $this->estrattiFiles; if (empty($files)) { return []; } @@ -1953,17 +1952,17 @@ public function getEstrattiVersamentiOrdinaria2023TotalsProperty(): array if (trim($content) === '') { continue; } - $rows = $this->parseGestioneOrdinariaFromContent($content, 2023); + $rows = $this->parseGestioneOrdinariaFromContent($content, 2023); $versamenti = (float) collect($rows) ->where('colonna', 'versamenti') - ->sum(fn ($r) => (float) ($r['importo'] ?? 0)); + ->sum(fn($r) => (float) ($r['importo'] ?? 0)); $rateOrd = (float) collect($rows) ->where('colonna', 'rate') ->where('tipo', 'rata') - ->sum(fn ($r) => (float) ($r['importo'] ?? 0)); + ->sum(fn($r) => (float) ($r['importo'] ?? 0)); $totaleOrd = $versamenti + $rateOrd; - $pdfRow = null; + $pdfRow = null; $txtBase = pathinfo((string) $file, PATHINFO_FILENAME); foreach ($this->pdfFiles as $row) { if (($row['txt_rel'] ?? null) === $file) { @@ -1981,7 +1980,7 @@ public function getEstrattiVersamentiOrdinaria2023TotalsProperty(): array } } - $unit = null; + $unit = null; $unitId = $pdfRow['linked_unit_id'] ?? null; if ($unitId) { foreach ($this->unitaImmobiliariList as $u) { @@ -1993,12 +1992,12 @@ public function getEstrattiVersamentiOrdinaria2023TotalsProperty(): array } $totals[] = [ - 'file' => basename((string) $file), - 'pdf' => $pdfRow['name'] ?? null, - 'done' => (bool) ($pdfRow['done'] ?? false), - 'unit' => $unit, + 'file' => basename((string) $file), + 'pdf' => $pdfRow['name'] ?? null, + 'done' => (bool) ($pdfRow['done'] ?? false), + 'unit' => $unit, 'versamenti' => $versamenti, - 'rate_ord' => $rateOrd, + 'rate_ord' => $rateOrd, 'totale_ord' => $totaleOrd, ]; } @@ -2015,10 +2014,10 @@ private function parseGestioneOrdinariaFromContent(string $content, int $year): return []; } - $lines = preg_split('/\R/u', $content) ?: []; - $rows = []; + $lines = preg_split('/\R/u', $content) ?: []; + $rows = []; $inSection = false; - $needle = 'Gestione Ordinaria ' . $year; + $needle = 'Gestione Ordinaria ' . $year; foreach ($lines as $line) { $line = trim((string) $line); @@ -2054,16 +2053,16 @@ private function parseGestioneOrdinariaFromContent(string $content, int $year): continue; } - $tipo = null; + $tipo = null; $colonna = null; if (preg_match('/^\(?\s*versamento/i', $rest)) { - $tipo = 'versamento'; + $tipo = 'versamento'; $colonna = 'versamenti'; } elseif (preg_match('/\bORD\b/i', $rest) || preg_match('/\bOrdinaria\b/i', $rest)) { - $tipo = 'rata'; + $tipo = 'rata'; $colonna = 'rate'; } elseif (preg_match('/\bCong\b/i', $rest)) { - $tipo = 'conguaglio'; + $tipo = 'conguaglio'; $colonna = 'rate'; } @@ -2075,7 +2074,7 @@ private function parseGestioneOrdinariaFromContent(string $content, int $year): if (empty($amounts)) { continue; } - $importo = $amounts[0]; + $importo = $amounts[0]; $hasDashSeparator = preg_match('/-\s+-€/u', $line) === 1; if ($tipo === 'versamento' && $importo < 0 && ! $hasDashSeparator) { $importo = abs($importo); @@ -2086,12 +2085,12 @@ private function parseGestioneOrdinariaFromContent(string $content, int $year): } $rows[] = [ - 'data' => $data, + 'data' => $data, 'descrizione' => $rest, - 'importo' => $importo, - 'tipo' => $tipo, - 'colonna' => $colonna ?? 'rate', - 'raw_line' => $line, + 'importo' => $importo, + 'tipo' => $tipo, + 'colonna' => $colonna ?? 'rate', + 'raw_line' => $line, ]; } @@ -2129,7 +2128,7 @@ private function resolveLinkedUnitaForTxt(string $file): ?array return null; } - $pdfRow = null; + $pdfRow = null; $txtBase = pathinfo($txtRel, PATHINFO_FILENAME); foreach ($this->pdfFiles as $row) { if (($row['txt_rel'] ?? null) === $txtRel) { @@ -2150,8 +2149,8 @@ private function resolveLinkedUnitaForTxt(string $file): ?array if (! $pdfRow) { $path = $this->getPdfStatePath(); if (File::exists($path)) { - $raw = File::get($path); - $data = is_string($raw) ? json_decode($raw, true) : null; + $raw = File::get($path); + $data = is_string($raw) ? json_decode($raw, true) : null; $links = is_array($data['links'] ?? null) ? $data['links'] : []; foreach ($links as $pdfName => $unitId) { if (pathinfo((string) $pdfName, PATHINFO_FILENAME) === $txtBase) { @@ -2184,13 +2183,13 @@ public function importEstrattoGestioneOrdinaria(): void return; } - $imported = 0; + $imported = 0; $duplicates = 0; $sourceFile = $this->estrattoFile ? basename((string) $this->estrattoFile) : null; DB::transaction(function () use ($rows, $sourceFile, &$imported, &$duplicates) { foreach ($rows as $row) { - $data = $row['data'] ?? null; + $data = $row['data'] ?? null; $dataIso = null; if ($data) { try { @@ -2222,20 +2221,20 @@ public function importEstrattoGestioneOrdinaria(): void RevisionePrecedenteMovimentoBanca::query()->create([ 'revisione_precedente_amministratore_id' => $this->record->id, - 'stabile_id' => $this->record->stabile_id, - 'data' => $dataIso, - 'numero' => null, - 'tipo_riga' => $row['tipo'] ?? null, - 'descrizione' => $row['descrizione'] ?? null, - 'descrizione_estesa' => null, - 'importo' => $row['importo'] ?? null, - 'saldo' => null, - 'source_file' => $sourceFile, - 'raw_line' => $row['raw_line'] ?? null, - 'row_hash' => $rowHash, - 'payload' => [ - 'estratto_tipo' => 'gestione_ordinaria_2023', - 'colonna' => $row['colonna'] ?? null, + 'stabile_id' => $this->record->stabile_id, + 'data' => $dataIso, + 'numero' => null, + 'tipo_riga' => $row['tipo'] ?? null, + 'descrizione' => $row['descrizione'] ?? null, + 'descrizione_estesa' => null, + 'importo' => $row['importo'] ?? null, + 'saldo' => null, + 'source_file' => $sourceFile, + 'raw_line' => $row['raw_line'] ?? null, + 'row_hash' => $rowHash, + 'payload' => [ + 'estratto_tipo' => 'gestione_ordinaria_2023', + 'colonna' => $row['colonna'] ?? null, 'tipo_dettaglio' => $row['tipo'] ?? null, ], ]); @@ -2293,7 +2292,7 @@ public function openVersamentiNominativoByName(string $nominativo): void return; } - $key = $this->normalizeMatchText($nominativo); + $key = $this->normalizeMatchText($nominativo); $unitId = null; foreach ($this->unitaImmobiliariList as $u) { $name = trim((string) ($u['denominazione'] ?? '')); @@ -2333,7 +2332,7 @@ public function openVersamentiNominativoByName(string $nominativo): void public function filterRiepilogoRowsByDate(array $rows): array { $from = $this->parseDateInput($this->riepilogoDateFrom); - $to = $this->parseDateInput($this->riepilogoDateTo); + $to = $this->parseDateInput($this->riepilogoDateTo); if (! $from && ! $to) { return $rows; @@ -2385,13 +2384,13 @@ public function importEstrattoGestioneOrdinaria2024(): void return; } - $imported = 0; + $imported = 0; $duplicates = 0; $sourceFile = $this->estrattoFile ? basename((string) $this->estrattoFile) : null; DB::transaction(function () use ($rows, $sourceFile, &$imported, &$duplicates) { foreach ($rows as $row) { - $data = $row['data'] ?? null; + $data = $row['data'] ?? null; $dataIso = null; if ($data) { try { @@ -2425,20 +2424,20 @@ public function importEstrattoGestioneOrdinaria2024(): void RevisionePrecedenteMovimentoBanca::query()->create([ 'revisione_precedente_amministratore_id' => $this->record->id, - 'stabile_id' => $this->record->stabile_id, - 'data' => $dataIso, - 'numero' => null, - 'tipo_riga' => $row['tipo'] ?? null, - 'descrizione' => $row['descrizione'] ?? null, - 'descrizione_estesa' => null, - 'importo' => $row['importo'] ?? null, - 'saldo' => null, - 'source_file' => $sourceFile, - 'raw_line' => $row['raw_line'] ?? null, - 'row_hash' => $rowHash, - 'payload' => [ - 'estratto_tipo' => 'gestione_ordinaria_2024', - 'colonna' => $row['colonna'] ?? null, + 'stabile_id' => $this->record->stabile_id, + 'data' => $dataIso, + 'numero' => null, + 'tipo_riga' => $row['tipo'] ?? null, + 'descrizione' => $row['descrizione'] ?? null, + 'descrizione_estesa' => null, + 'importo' => $row['importo'] ?? null, + 'saldo' => null, + 'source_file' => $sourceFile, + 'raw_line' => $row['raw_line'] ?? null, + 'row_hash' => $rowHash, + 'payload' => [ + 'estratto_tipo' => 'gestione_ordinaria_2024', + 'colonna' => $row['colonna'] ?? null, 'tipo_dettaglio' => $row['tipo'] ?? null, ], ]); @@ -2525,11 +2524,11 @@ public function getUnitaImmobiliariListProperty(): array ->orderByRaw('CAST(interno AS UNSIGNED)') ->orderBy('interno') ->get() - ->map(fn (UnitaImmobiliare $unita): array => [ - 'id' => $unita->id, - 'codice_unita' => $unita->codice_unita, - 'scala' => $unita->scala, - 'interno' => $unita->interno, + ->map(fn(UnitaImmobiliare $unita): array=> [ + 'id' => $unita->id, + 'codice_unita' => $unita->codice_unita, + 'scala' => $unita->scala, + 'interno' => $unita->interno, 'denominazione' => $unita->denominazione, ]) ->all(); @@ -2545,8 +2544,8 @@ public function getEstrattoStraordinariaProperty(): array return []; } - $lines = preg_split('/\R/u', $content) ?: []; - $rows = []; + $lines = preg_split('/\R/u', $content) ?: []; + $rows = []; $inSection = false; foreach ($lines as $line) { @@ -2579,13 +2578,13 @@ public function getEstrattoStraordinariaProperty(): array continue; } - $tipo = null; + $tipo = null; $colonna = null; if (preg_match('/^\(?\s*versamento/i', $rest)) { - $tipo = 'versamento'; + $tipo = 'versamento'; $colonna = 'versamenti'; } elseif (preg_match('/rata\s+straordinaria/i', $rest)) { - $tipo = 'rata'; + $tipo = 'rata'; $colonna = 'rate'; } @@ -2600,12 +2599,12 @@ public function getEstrattoStraordinariaProperty(): array $importo = $amounts[0]; $rows[] = [ - 'data' => $data, + 'data' => $data, 'descrizione' => $rest, - 'importo' => $importo, - 'tipo' => $tipo, - 'colonna' => $colonna ?? 'rate', - 'raw_line' => $line, + 'importo' => $importo, + 'tipo' => $tipo, + 'colonna' => $colonna ?? 'rate', + 'raw_line' => $line, ]; } @@ -2620,13 +2619,13 @@ public function importEstrattoStraordinaria(): void return; } - $imported = 0; + $imported = 0; $duplicates = 0; $sourceFile = $this->estrattoFile ? basename((string) $this->estrattoFile) : null; DB::transaction(function () use ($rows, $sourceFile, &$imported, &$duplicates) { foreach ($rows as $row) { - $data = $row['data'] ?? null; + $data = $row['data'] ?? null; $dataIso = null; if ($data) { try { @@ -2658,20 +2657,20 @@ public function importEstrattoStraordinaria(): void RevisionePrecedenteMovimentoBanca::query()->create([ 'revisione_precedente_amministratore_id' => $this->record->id, - 'stabile_id' => $this->record->stabile_id, - 'data' => $dataIso, - 'numero' => null, - 'tipo_riga' => $row['tipo'] ?? null, - 'descrizione' => $row['descrizione'] ?? null, - 'descrizione_estesa' => null, - 'importo' => $row['importo'] ?? null, - 'saldo' => null, - 'source_file' => $sourceFile, - 'raw_line' => $row['raw_line'] ?? null, - 'row_hash' => $rowHash, - 'payload' => [ - 'estratto_tipo' => 'gestione_straordinaria_levelon', - 'colonna' => $row['colonna'] ?? null, + 'stabile_id' => $this->record->stabile_id, + 'data' => $dataIso, + 'numero' => null, + 'tipo_riga' => $row['tipo'] ?? null, + 'descrizione' => $row['descrizione'] ?? null, + 'descrizione_estesa' => null, + 'importo' => $row['importo'] ?? null, + 'saldo' => null, + 'source_file' => $sourceFile, + 'raw_line' => $row['raw_line'] ?? null, + 'row_hash' => $rowHash, + 'payload' => [ + 'estratto_tipo' => 'gestione_straordinaria_levelon', + 'colonna' => $row['colonna'] ?? null, 'tipo_dettaglio' => $row['tipo'] ?? null, ], ]); @@ -2688,7 +2687,7 @@ public function importEstrattoStraordinaria(): void private function loadEstrattiFiles(): void { - $dir = storage_path('app/reports'); + $dir = storage_path('app/reports'); $txtFiles = []; if (File::isDirectory($dir)) { $files = File::files($dir); @@ -2765,13 +2764,13 @@ public function importMovimentiCedhouse(): void } $parser = new CedhouseCsvParser(); - $ext = strtolower((string) pathinfo($this->movimentiFile->getClientOriginalName() ?? $path, PATHINFO_EXTENSION)); + $ext = strtolower((string) pathinfo($this->movimentiFile->getClientOriginalName() ?? $path, PATHINFO_EXTENSION)); if (in_array($ext, ['xls', 'xlsx'], true)) { try { $spreadsheet = IOFactory::load($path); - $sheet = $spreadsheet->getActiveSheet(); - $rows = $sheet->toArray(null, true, true, false); - $parsed = $parser->parseSpreadsheetRows($rows); + $sheet = $spreadsheet->getActiveSheet(); + $rows = $sheet->toArray(null, true, true, false); + $parsed = $parser->parseSpreadsheetRows($rows); } catch (\Throwable $e) { Notification::make()->title('Errore lettura Excel')->body($e->getMessage())->danger()->send(); return; @@ -2785,32 +2784,32 @@ public function importMovimentiCedhouse(): void $parsed = $parser->parse($content); } - $imported = 0; + $imported = 0; $duplicates = 0; - $removed = 0; + $removed = 0; DB::transaction(function () use ($parsed, &$imported, &$duplicates, &$removed) { foreach ($parsed['rows'] as $row) { - $rowData = $row['data']?->toDateString(); - $rowNumero = $row['numero'] ?? null; - $rowTipo = $row['tipo_riga'] ?? null; + $rowData = $row['data']?->toDateString(); + $rowNumero = $row['numero'] ?? null; + $rowTipo = $row['tipo_riga'] ?? null; $rowDescrizione = $row['descrizione'] ?? null; - $rowImporto = $row['importo'] ?? null; - $rowSaldo = $row['saldo'] ?? null; + $rowImporto = $row['importo'] ?? null; + $rowSaldo = $row['saldo'] ?? null; $matchQuery = RevisionePrecedenteMovimentoBanca::query() ->where('revisione_precedente_amministratore_id', $this->record->id) - ->when($rowData !== null, fn ($q) => $q->whereDate('data', $rowData)) - ->when($rowNumero !== null && $rowNumero !== '', fn ($q) => $q->where('numero', $rowNumero)) - ->when($rowTipo !== null && $rowTipo !== '', fn ($q) => $q->where('tipo_riga', $rowTipo)) - ->when($rowDescrizione !== null && $rowDescrizione !== '', fn ($q) => $q->where('descrizione', $rowDescrizione)); + ->when($rowData !== null, fn($q) => $q->whereDate('data', $rowData)) + ->when($rowNumero !== null && $rowNumero !== '', fn($q) => $q->where('numero', $rowNumero)) + ->when($rowTipo !== null && $rowTipo !== '', fn($q) => $q->where('tipo_riga', $rowTipo)) + ->when($rowDescrizione !== null && $rowDescrizione !== '', fn($q) => $q->where('descrizione', $rowDescrizione)); $matchedRows = $matchQuery->get(); if ($matchedRows->isNotEmpty()) { foreach ($matchedRows as $existing) { - $payload = $this->decodePayload($existing->payload) ?? []; + $payload = $this->decodePayload($existing->payload) ?? []; $payload['gestione'] = $row['gestione'] ?? $payload['gestione'] ?? null; - $payload['format'] = $parsed['meta']['format_hint'] ?? $payload['format'] ?? null; + $payload['format'] = $parsed['meta']['format_hint'] ?? $payload['format'] ?? null; $hashBase = implode('|', [ $this->record->id, @@ -2823,17 +2822,17 @@ public function importMovimentiCedhouse(): void $rowHash = hash('sha256', $hashBase); $existing->fill([ - 'data' => $rowData, - 'numero' => $rowNumero, - 'tipo_riga' => $rowTipo, - 'descrizione' => $rowDescrizione, + 'data' => $rowData, + 'numero' => $rowNumero, + 'tipo_riga' => $rowTipo, + 'descrizione' => $rowDescrizione, 'descrizione_estesa' => $row['descrizione_estesa'] ?? null, - 'importo' => $rowImporto, - 'saldo' => $rowSaldo, - 'source_file' => $this->movimentiFile->getClientOriginalName() ?? null, - 'raw_line' => $row['raw_line'] ?? null, - 'row_hash' => $rowHash, - 'payload' => $payload, + 'importo' => $rowImporto, + 'saldo' => $rowSaldo, + 'source_file' => $this->movimentiFile->getClientOriginalName() ?? null, + 'raw_line' => $row['raw_line'] ?? null, + 'row_hash' => $rowHash, + 'payload' => $payload, ]); $existing->save(); } @@ -2858,13 +2857,13 @@ public function importMovimentiCedhouse(): void if ($existing) { $needsUpdate = false; - $payload = $this->decodePayload($existing->payload) ?? []; + $payload = $this->decodePayload($existing->payload) ?? []; if (! empty($row['gestione'] ?? null)) { $newGestione = (string) $row['gestione']; $oldGestione = (string) ($payload['gestione'] ?? ''); if ($newGestione !== $oldGestione) { $payload['gestione'] = $newGestione; - $needsUpdate = true; + $needsUpdate = true; } } if ($needsUpdate) { @@ -2877,19 +2876,19 @@ public function importMovimentiCedhouse(): void RevisionePrecedenteMovimentoBanca::query()->create([ 'revisione_precedente_amministratore_id' => $this->record->id, - 'stabile_id' => $this->record->stabile_id, - 'data' => $row['data']?->toDateString(), - 'numero' => $row['numero'] ?? null, - 'tipo_riga' => $row['tipo_riga'] ?? null, - 'descrizione' => $row['descrizione'] ?? null, - 'descrizione_estesa' => $row['descrizione_estesa'] ?? null, - 'importo' => $row['importo'] ?? null, - 'saldo' => $row['saldo'] ?? null, - 'source_file' => $this->movimentiFile->getClientOriginalName() ?? null, - 'raw_line' => $row['raw_line'] ?? null, - 'row_hash' => $rowHash, - 'payload' => [ - 'format' => $parsed['meta']['format_hint'] ?? null, + 'stabile_id' => $this->record->stabile_id, + 'data' => $row['data']?->toDateString(), + 'numero' => $row['numero'] ?? null, + 'tipo_riga' => $row['tipo_riga'] ?? null, + 'descrizione' => $row['descrizione'] ?? null, + 'descrizione_estesa' => $row['descrizione_estesa'] ?? null, + 'importo' => $row['importo'] ?? null, + 'saldo' => $row['saldo'] ?? null, + 'source_file' => $this->movimentiFile->getClientOriginalName() ?? null, + 'raw_line' => $row['raw_line'] ?? null, + 'row_hash' => $rowHash, + 'payload' => [ + 'format' => $parsed['meta']['format_hint'] ?? null, 'gestione' => $row['gestione'] ?? null, ], ]); @@ -2899,9 +2898,9 @@ public function importMovimentiCedhouse(): void $removed = $this->removeDuplicateMovimentiByNumero(); }); - $this->movimentiImported = $imported; + $this->movimentiImported = $imported; $this->movimentiDuplicates = $duplicates; - $this->movimentiFile = null; + $this->movimentiFile = null; Notification::make() ->title('Import completato') @@ -2956,13 +2955,13 @@ public function cleanupMovimentiSenzaNumero(): void public function applyMovimentiSaldoAnchor(): void { - $this->movimentiSaldoAnchorAppliedDate = $this->movimentiSaldoAnchorDate; + $this->movimentiSaldoAnchorAppliedDate = $this->movimentiSaldoAnchorDate; $this->movimentiSaldoAnchorAppliedValue = $this->movimentiSaldoAnchorValue; } public function clearMovimentiSaldoAnchor(): void { - $this->movimentiSaldoAnchorAppliedDate = null; + $this->movimentiSaldoAnchorAppliedDate = null; $this->movimentiSaldoAnchorAppliedValue = null; } @@ -2985,7 +2984,7 @@ public function getMovimentiProperty() $gestioneFilter = trim($this->movimentiFilterGestione); if ($gestioneFilter !== '') { $rows = $rows->filter(function ($m) use ($gestioneFilter) { - $payload = $this->decodePayload($m->payload) ?? []; + $payload = $this->decodePayload($m->payload) ?? []; $gestione = trim((string) ($payload['gestione'] ?? '')); return $gestione === $gestioneFilter; })->values(); @@ -3001,7 +3000,7 @@ public function getMovimentiGestioneOptionsProperty(): array { $gestioni = []; foreach ($this->movimenti as $m) { - $payload = $this->decodePayload($m->payload) ?? []; + $payload = $this->decodePayload($m->payload) ?? []; $gestione = trim((string) ($payload['gestione'] ?? '')); if ($gestione !== '') { $gestioni[$gestione] = $gestione; @@ -3036,8 +3035,8 @@ public function getMovimentiMatchRowsProperty(): array */ public function getRiepilogoMovimentiComparisonProperty(): array { - $estratti = $this->getSelectedGestioneMatchRows(); - $estratti = $this->filterRiepilogoRowsByDate($estratti); + $estratti = $this->getSelectedGestioneMatchRows(); + $estratti = $this->filterRiepilogoRowsByDate($estratti); $movimenti = $this->getMovimentiMatchEntriesForGestione(); $estrattiMap = []; @@ -3047,12 +3046,12 @@ public function getRiepilogoMovimentiComparisonProperty(): array continue; } $nameKey = $this->normalizeMatchText((string) ($row['descrizione'] ?? '')); - $key = $dateKey . '|' . $nameKey; + $key = $dateKey . '|' . $nameKey; if (! isset($estrattiMap[$key])) { $estrattiMap[$key] = [ - 'data' => $dateKey, - 'nominativo' => $nameKey, - 'estratti_importo' => 0.0, + 'data' => $dateKey, + 'nominativo' => $nameKey, + 'estratti_importo' => 0.0, 'movimenti_importo' => 0.0, ]; } @@ -3066,37 +3065,37 @@ public function getRiepilogoMovimentiComparisonProperty(): array continue; } $nameKey = $this->normalizeMatchText((string) ($row['descrizione'] ?? '')); - $key = $dateKey . '|' . $nameKey; + $key = $dateKey . '|' . $nameKey; if (! isset($movimentiMap[$key])) { $movimentiMap[$key] = [ - 'data' => $dateKey, - 'nominativo' => $nameKey, - 'estratti_importo' => 0.0, + 'data' => $dateKey, + 'nominativo' => $nameKey, + 'estratti_importo' => 0.0, 'movimenti_importo' => 0.0, ]; } $movimentiMap[$key]['movimenti_importo'] += (float) ($row['importo'] ?? 0); } - $allKeys = array_unique(array_merge(array_keys($estrattiMap), array_keys($movimentiMap))); - $rows = []; + $allKeys = array_unique(array_merge(array_keys($estrattiMap), array_keys($movimentiMap))); + $rows = []; foreach ($allKeys as $key) { - $e = $estrattiMap[$key] ?? null; - $m = $movimentiMap[$key] ?? null; - $data = $e['data'] ?? ($m['data'] ?? ''); - $nominativo = $e['nominativo'] ?? ($m['nominativo'] ?? ''); + $e = $estrattiMap[$key] ?? null; + $m = $movimentiMap[$key] ?? null; + $data = $e['data'] ?? ($m['data'] ?? ''); + $nominativo = $e['nominativo'] ?? ($m['nominativo'] ?? ''); $estrattiImporto = (float) ($e['estratti_importo'] ?? 0); - $movImporto = (float) ($m['movimenti_importo'] ?? 0); - $rows[] = [ - 'data' => $data, - 'nominativo' => $nominativo, - 'estratti_importo' => $estrattiImporto, + $movImporto = (float) ($m['movimenti_importo'] ?? 0); + $rows[] = [ + 'data' => $data, + 'nominativo' => $nominativo, + 'estratti_importo' => $estrattiImporto, 'movimenti_importo' => $movImporto, - 'delta' => $estrattiImporto - $movImporto, + 'delta' => $estrattiImporto - $movImporto, ]; } - usort($rows, fn ($a, $b) => strcmp($a['data'], $b['data'])); + usort($rows, fn($a, $b) => strcmp($a['data'], $b['data'])); return $rows; } @@ -3110,24 +3109,24 @@ private function buildRiepilogoMovimentiMatchData(): array $riepilogoEntries = $this->filterRiepilogoRowsByDate($riepilogoEntries); $movimentiEntries = $this->getMovimentiMatchEntriesForGestione(); - $movimentiCounts = []; - $movimentiDescCounts = []; + $movimentiCounts = []; + $movimentiDescCounts = []; $movimentiDateAmountCounts = []; foreach ($movimentiEntries as $m) { - $key = $this->makeMatchKeyWithDate($m['descrizione'] ?? '', (float) ($m['importo'] ?? 0), $m['data'] ?? null); + $key = $this->makeMatchKeyWithDate($m['descrizione'] ?? '', (float) ($m['importo'] ?? 0), $m['data'] ?? null); $movimentiCounts[$key] = ($movimentiCounts[$key] ?? 0) + 1; - $descKey = $this->normalizeMatchText((string) ($m['descrizione'] ?? '')); + $descKey = $this->normalizeMatchText((string) ($m['descrizione'] ?? '')); $movimentiDescCounts[$descKey] = ($movimentiDescCounts[$descKey] ?? 0) + 1; - $dateAmountKey = $this->normalizeMatchDate($m['data'] ?? null) . '|' . number_format(abs((float) ($m['importo'] ?? 0)), 2, '.', ''); + $dateAmountKey = $this->normalizeMatchDate($m['data'] ?? null) . '|' . number_format(abs((float) ($m['importo'] ?? 0)), 2, '.', ''); $movimentiDateAmountCounts[$dateAmountKey] = ($movimentiDateAmountCounts[$dateAmountKey] ?? 0) + 1; } $riepilogoRows = []; foreach ($riepilogoEntries as $row) { - $key = $this->makeMatchKeyWithDate($row['descrizione'] ?? '', (float) ($row['importo'] ?? 0), $row['data'] ?? null); - $descKey = $this->normalizeMatchText((string) ($row['descrizione'] ?? '')); + $key = $this->makeMatchKeyWithDate($row['descrizione'] ?? '', (float) ($row['importo'] ?? 0), $row['data'] ?? null); + $descKey = $this->normalizeMatchText((string) ($row['descrizione'] ?? '')); $dateAmountKey = $this->normalizeMatchDate($row['data'] ?? null) . '|' . number_format(abs((float) ($row['importo'] ?? 0)), 2, '.', ''); $matched = false; @@ -3140,30 +3139,30 @@ private function buildRiepilogoMovimentiMatchData(): array } $riepilogoRows[] = $row + [ - 'matched' => $matched, - 'match_key' => $key, - 'norm_descr' => $descKey, - 'date_amount_key' => $dateAmountKey, - 'desc_matches' => (int) ($movimentiDescCounts[$descKey] ?? 0), + 'matched' => $matched, + 'match_key' => $key, + 'norm_descr' => $descKey, + 'date_amount_key' => $dateAmountKey, + 'desc_matches' => (int) ($movimentiDescCounts[$descKey] ?? 0), 'date_amount_matches' => (int) ($movimentiDateAmountCounts[$dateAmountKey] ?? 0), ]; } - $riepilogoCounts = []; + $riepilogoCounts = []; $riepilogoDateAmountCounts = []; foreach ($riepilogoEntries as $row) { - $key = $this->makeMatchKeyWithDate($row['descrizione'] ?? '', (float) ($row['importo'] ?? 0), $row['data'] ?? null); - $riepilogoCounts[$key] = ($riepilogoCounts[$key] ?? 0) + 1; - $dateAmountKey = $this->normalizeMatchDate($row['data'] ?? null) . '|' . number_format(abs((float) ($row['importo'] ?? 0)), 2, '.', ''); + $key = $this->makeMatchKeyWithDate($row['descrizione'] ?? '', (float) ($row['importo'] ?? 0), $row['data'] ?? null); + $riepilogoCounts[$key] = ($riepilogoCounts[$key] ?? 0) + 1; + $dateAmountKey = $this->normalizeMatchDate($row['data'] ?? null) . '|' . number_format(abs((float) ($row['importo'] ?? 0)), 2, '.', ''); $riepilogoDateAmountCounts[$dateAmountKey] = ($riepilogoDateAmountCounts[$dateAmountKey] ?? 0) + 1; } $movimentiRows = []; foreach ($movimentiEntries as $row) { - $key = $this->makeMatchKeyWithDate($row['descrizione'] ?? '', (float) ($row['importo'] ?? 0), $row['data'] ?? null); + $key = $this->makeMatchKeyWithDate($row['descrizione'] ?? '', (float) ($row['importo'] ?? 0), $row['data'] ?? null); $dateAmountKey = $this->normalizeMatchDate($row['data'] ?? null) . '|' . number_format(abs((float) ($row['importo'] ?? 0)), 2, '.', ''); - $descKey = $this->normalizeMatchText((string) ($row['descrizione'] ?? '')); - $matched = false; + $descKey = $this->normalizeMatchText((string) ($row['descrizione'] ?? '')); + $matched = false; if (($riepilogoCounts[$key] ?? 0) > 0) { $matched = true; $riepilogoCounts[$key]--; @@ -3172,9 +3171,9 @@ private function buildRiepilogoMovimentiMatchData(): array $riepilogoDateAmountCounts[$dateAmountKey]--; } $movimentiRows[] = $row + [ - 'matched' => $matched, - 'match_key' => $key, - 'norm_descr' => $descKey, + 'matched' => $matched, + 'match_key' => $key, + 'norm_descr' => $descKey, 'date_amount_key' => $dateAmountKey, ]; } @@ -3190,34 +3189,34 @@ private function buildRiepilogoMovimentiMatchData(): array */ private function getSelectedGestioneMatchRows(): array { - $entries = []; + $entries = []; $gestione = $this->riepilogoGestione; if ($gestione === 'riepilogo') { foreach ($this->estrattiRiepilogoAll as $row) { - $descr = (string) ($row['descrizione'] ?? ''); - $file = (string) ($row['file'] ?? ''); - $data = $row['data'] ?? null; + $descr = (string) ($row['descrizione'] ?? ''); + $file = (string) ($row['file'] ?? ''); + $data = $row['data'] ?? null; $importo = (float) ($row['importo'] ?? 0); - $alt = $row['importo_altre'] ?? null; + $alt = $row['importo_altre'] ?? null; if ($importo !== 0.0) { $entries[] = [ - 'file' => $file, - 'data' => $data, + 'file' => $file, + 'data' => $data, 'descrizione' => $descr, - 'colonna' => 'principale', - 'importo' => $importo, + 'colonna' => 'principale', + 'importo' => $importo, ]; } if ($alt !== null && (float) $alt !== 0.0) { $entries[] = [ - 'file' => $file, - 'data' => $data, + 'file' => $file, + 'data' => $data, 'descrizione' => $descr, - 'colonna' => 'altre_gestioni', - 'importo' => (float) $alt, + 'colonna' => 'altre_gestioni', + 'importo' => (float) $alt, ]; } } @@ -3234,11 +3233,11 @@ private function getSelectedGestioneMatchRows(): array continue; } $entries[] = [ - 'file' => (string) ($row['file'] ?? ''), - 'data' => $row['data'] ?? null, + 'file' => (string) ($row['file'] ?? ''), + 'data' => $row['data'] ?? null, 'descrizione' => (string) ($row['descrizione'] ?? ''), - 'colonna' => 'versamenti', - 'importo' => (float) ($row['importo'] ?? 0), + 'colonna' => 'versamenti', + 'importo' => (float) ($row['importo'] ?? 0), ]; } @@ -3256,16 +3255,16 @@ private function getMovimentiVersamentiList(): array if (is_array($payloadArray) && array_key_exists('estratto_tipo', $payloadArray)) { continue; } - $descr = (string) ($m->descrizione ?? ''); - $tipo = strtolower((string) ($m->tipo_riga ?? '')); + $descr = (string) ($m->descrizione ?? ''); + $tipo = strtolower((string) ($m->tipo_riga ?? '')); $isVersamento = str_contains(strtolower($descr), 'versamento') || $tipo === 'versamento' || $tipo === 'versamento_altre_gestioni'; if (! $isVersamento) { continue; } $rows[] = [ - 'data' => $m->data ? Carbon::parse($m->data)->format('d/m/Y') : null, + 'data' => $m->data ? Carbon::parse($m->data)->format('d/m/Y') : null, 'descrizione' => $descr !== '' ? $descr : (string) ($m->descrizione_estesa ?? ''), - 'importo' => (float) ($m->importo ?? 0), + 'importo' => (float) ($m->importo ?? 0), ]; } @@ -3277,27 +3276,27 @@ private function getMovimentiVersamentiList(): array */ private function getMovimentiMatchEntriesForGestione(): array { - $rows = []; + $rows = []; $gestioneKey = $this->riepilogoGestione; foreach ($this->movimenti as $m) { - $tipo = strtolower((string) ($m->tipo_riga ?? '')); - $descr = (string) ($m->descrizione ?? ''); - $descLower = strtolower($descr); + $tipo = strtolower((string) ($m->tipo_riga ?? '')); + $descr = (string) ($m->descrizione ?? ''); + $descLower = strtolower($descr); $isVersamento = str_contains($descLower, 'versamento') || $tipo === 'versamento' || $tipo === 'versamento_altre_gestioni'; if (! $isVersamento) { continue; } if ($gestioneKey !== 'riepilogo') { - $payload = $this->decodePayload($m->payload) ?? []; + $payload = $this->decodePayload($m->payload) ?? []; $movGestione = $this->normalizeMovimentoGestioneKey($payload['gestione'] ?? null); if ($movGestione !== $gestioneKey) { continue; } } $rows[] = [ - 'data' => $m->data ? Carbon::parse($m->data)->format('d/m/Y') : null, + 'data' => $m->data ? Carbon::parse($m->data)->format('d/m/Y') : null, 'descrizione' => $descr !== '' ? $descr : (string) ($m->descrizione_estesa ?? ''), - 'importo' => (float) ($m->importo ?? 0), + 'importo' => (float) ($m->importo ?? 0), ]; } @@ -3306,8 +3305,8 @@ private function getMovimentiMatchEntriesForGestione(): array private function makeMatchKeyWithDate(string $descrizione, float $importo, ?string $data): string { - $norm = $this->normalizeMatchText($descrizione); - $amount = number_format(abs($importo), 2, '.', ''); + $norm = $this->normalizeMatchText($descrizione); + $amount = number_format(abs($importo), 2, '.', ''); $dateKey = $this->normalizeMatchDate($data); return $norm . '|' . $dateKey . '|' . $amount; } @@ -3399,8 +3398,8 @@ private function removeDuplicateMovimentiByNumero(): int } $keep = $group->sortByDesc(function ($m) { - $payload = $this->decodePayload($m->payload) ?? []; - $formatScore = ($payload['format'] ?? null) === 'cedhouse_xls' ? 1000000000 : 0; + $payload = $this->decodePayload($m->payload) ?? []; + $formatScore = ($payload['format'] ?? null) === 'cedhouse_xls' ? 1000000000 : 0; $updatedScore = $m->updated_at ? $m->updated_at->getTimestamp() : 0; return $formatScore + $updatedScore; })->first(); @@ -3430,7 +3429,7 @@ public function computeMovimentiSaldo(iterable $rows): array return '999999999999-' . $date . '-' . str_pad((string) $m->id, 10, '0', STR_PAD_LEFT); })->values(); - $map = []; + $map = []; $anchorIndex = null; $anchorValue = null; @@ -3472,24 +3471,24 @@ public function computeMovimentiSaldo(iterable $rows): array if ($anchorIndex === null) { $running = 0.0; foreach ($list as $m) { - $running += (float) ($m->importo ?? 0); - $map[$m->id] = $running; + $running += (float) ($m->importo ?? 0); + $map[$m->id] = $running; } return $map; } - $running = $anchorValue ?? (float) ($list[$anchorIndex]->saldo ?? 0); + $running = $anchorValue ?? (float) ($list[$anchorIndex]->saldo ?? 0); $map[$list[$anchorIndex]->id] = $running; for ($i = $anchorIndex + 1; $i < $list->count(); $i++) { - $running += (float) ($list[$i]->importo ?? 0); - $map[$list[$i]->id] = $running; + $running += (float) ($list[$i]->importo ?? 0); + $map[$list[$i]->id] = $running; } for ($i = $anchorIndex - 1; $i >= 0; $i--) { - $running -= (float) ($list[$i + 1]->importo ?? 0); - $map[$list[$i]->id] = $running; + $running -= (float) ($list[$i + 1]->importo ?? 0); + $map[$list[$i]->id] = $running; } return $map; @@ -3516,7 +3515,7 @@ public static function canAccess(): bool { $user = Auth::user(); return $user instanceof User - && ($user->hasRole('super-admin') || $user->can('manage-accounting') || $user->can('contabilita.casse-banche')); + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); } /** * @return float[] @@ -3530,10 +3529,10 @@ private function parseSignedAmounts(string $text): array } foreach ($m as $ma) { - $neg = false; + $neg = false; $importoRaw = ''; if (! empty($ma[1])) { - $neg = true; + $neg = true; $importoRaw = (string) $ma[1]; } elseif (! empty($ma[2])) { $importoRaw = (string) $ma[2]; @@ -3541,7 +3540,7 @@ private function parseSignedAmounts(string $text): array $importoRaw = (string) $ma[3]; } $importoRaw = trim($importoRaw); - $importo = (float) str_replace(',', '.', str_replace('.', '', $importoRaw)); + $importo = (float) str_replace(',', '.', str_replace('.', '', $importoRaw)); if ($neg) { $importo = -1 * $importo; } diff --git a/app/Filament/Pages/RevisioneContabile/PrecedentiAmministratoriElenco.php b/app/Filament/Pages/RevisioneContabile/PrecedentiAmministratoriElenco.php index b9be0c9..105c6f2 100644 --- a/app/Filament/Pages/RevisioneContabile/PrecedentiAmministratoriElenco.php +++ b/app/Filament/Pages/RevisioneContabile/PrecedentiAmministratoriElenco.php @@ -1,5 +1,4 @@ create([ - 'stabile_id' => (int) $data['stabile_id'], - 'rubrica_id' => (int) $rubrica->id, + 'stabile_id' => (int) $data['stabile_id'], + 'rubrica_id' => (int) $rubrica->id, 'precedente_amministratore' => (string) ($rubrica->nome_completo ?? '—'), - 'gestionale' => isset($data['gestionale']) ? trim((string) $data['gestionale']) : null, - 'passaggio_consegne_path' => isset($data['passaggio_consegne_path']) ? (string) $data['passaggio_consegne_path'] : null, - 'note' => isset($data['note']) ? trim((string) $data['note']) : null, - 'creato_da' => (int) $user->id, - 'modificato_da' => (int) $user->id, + 'gestionale' => isset($data['gestionale']) ? trim((string) $data['gestionale']) : null, + 'passaggio_consegne_path' => isset($data['passaggio_consegne_path']) ? (string) $data['passaggio_consegne_path'] : null, + 'note' => isset($data['note']) ? trim((string) $data['note']) : null, + 'creato_da' => (int) $user->id, + 'modificato_da' => (int) $user->id, ]); Notification::make() @@ -137,6 +135,6 @@ public static function canAccess(): bool { $user = Auth::user(); return $user instanceof User - && ($user->hasRole('super-admin') || $user->can('manage-accounting') || $user->can('contabilita.casse-banche')); + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); } } diff --git a/app/Filament/Pages/Strumenti/PostItGestione.php b/app/Filament/Pages/Strumenti/PostItGestione.php index 0fd24bd..2fd6f8f 100644 --- a/app/Filament/Pages/Strumenti/PostItGestione.php +++ b/app/Filament/Pages/Strumenti/PostItGestione.php @@ -1,7 +1,10 @@ */ public array $riaperturaNote = []; + /** @var array */ + private array $rubricaPhoneCache = []; + public function getPostItInserimentoUrl(): string { return PostIt::getUrl(panel: 'admin-filament'); @@ -121,13 +129,13 @@ public function riapriPostIt(int $postItId): void } $noteInput = trim((string) ($this->riaperturaNote[$postItId] ?? '')); - $stamp = now()->format('d/m/Y H:i'); - $noteLine = $noteInput !== '' + $stamp = now()->format('d/m/Y H:i'); + $noteLine = $noteInput !== '' ? '[' . $stamp . '] Riapertura: ' . $noteInput : '[' . $stamp . '] Riapertura da gestione Post-it'; - $existing = trim((string) ($postIt->nota ?? '')); - $postIt->nota = $existing !== '' ? ($existing . "\n" . $noteLine) : $noteLine; + $existing = trim((string) ($postIt->nota ?? '')); + $postIt->nota = $existing !== '' ? ($existing . "\n" . $noteLine) : $noteLine; $postIt->stato = $postIt->ticket_id ? 'ticket' : 'post_it'; if (Schema::hasColumn('chiamate_post_it', 'chiusa_il')) { @@ -153,7 +161,7 @@ public function riapriPostIt(int $postItId): void if (method_exists($ticket, 'messages') && Schema::hasTable('ticket_messages')) { $ticket->messages()->create([ - 'user_id' => Auth::id(), + 'user_id' => Auth::id(), 'messaggio' => $noteLine, ]); } @@ -186,11 +194,150 @@ public function getRecentiProperty() } } + public function getChiamateTecnicheProperty() + { + if (! Schema::hasTable('communication_messages')) { + return collect(); + } + + try { + return CommunicationMessage::query() + ->with(['assignedUser:id,name', 'stabile:id,denominazione']) + ->where('channel', 'smdr') + ->latest('id') + ->limit(250) + ->get(); + } catch (QueryException) { + return collect(); + } + } + + public function creaPostItDaMessaggio(int $messageId): void + { + if (! $this->isPostItTableReady()) { + Notification::make()->title('Tabella post-it non pronta')->danger()->send(); + return; + } + + $message = CommunicationMessage::query()->find($messageId); + if (! $message) { + return; + } + + if (! empty($message->post_it_id)) { + Notification::make()->title('Post-it già associato')->warning()->send(); + return; + } + + $smdr = (array) data_get($message->metadata, 'smdr', []); + $line = trim((string) ($message->message_text ?? '')); + + $postIt = ChiamataPostIt::query()->create([ + 'stabile_id' => $message->stabile_id, + 'creato_da_user_id' => Auth::id(), + 'telefono' => $message->phone_number, + 'nome_chiamante' => 'SMDR ' . strtoupper((string) $message->direction), + 'direzione' => $this->normalizePostItDirection((string) $message->direction), + 'origine' => 'smdr_table', + 'origine_id' => (string) $message->id, + 'durata_secondi' => is_numeric($smdr['duration_seconds'] ?? null) ? (int) $smdr['duration_seconds'] : null, + 'oggetto' => 'Chiamata da pannello tecnico SMDR', + 'nota' => $line !== '' ? $line : 'Evento SMDR senza testo riga', + 'priorita' => 'Media', + 'stato' => 'post_it', + 'chiamata_il' => $message->received_at ?? now(), + ]); + + $message->post_it_id = $postIt->id; + $message->save(); + + Notification::make() + ->title('Post-it creato da riga tecnica') + ->body('Post-it #' . $postIt->id . ' collegato al messaggio #' . $message->id) + ->success() + ->send(); + } + + private function normalizePostItDirection(string $direction): string + { + $d = strtolower(trim($direction)); + if ($d === 'inbound') { + return 'in_arrivo'; + } + if ($d === 'outbound' || $d === 'internal') { + return 'in_uscita'; + } + + return 'in_arrivo'; + } + public function isPostItTableReady(): bool { return Schema::hasTable('chiamate_post_it'); } + public function getRubricaUrlByPhone(?string $phone): ?string + { + $rubricaId = $this->resolveRubricaIdByPhone($phone); + if (! $rubricaId) { + return null; + } + + return RubricaUniversaleScheda::getUrl(['record' => $rubricaId], panel: 'admin-filament'); + } + + public function getRubricaNomeByPhone(?string $phone): ?string + { + $rubricaId = $this->resolveRubricaIdByPhone($phone); + if (! $rubricaId) { + return null; + } + + $rubrica = RubricaUniversale::query()->find($rubricaId, ['id', 'ragione_sociale', 'nome', 'cognome']); + if (! $rubrica) { + return null; + } + + $nome = trim((string) ($rubrica->ragione_sociale ?: ((string) ($rubrica->nome ?? '') . ' ' . (string) ($rubrica->cognome ?? '')))); + return $nome !== '' ? $nome : null; + } + + private function resolveRubricaIdByPhone(?string $phone): ?int + { + $digits = preg_replace('/\D+/', '', (string) $phone); + if (! is_string($digits) || $digits === '') { + return null; + } + + if (array_key_exists($digits, $this->rubricaPhoneCache)) { + return $this->rubricaPhoneCache[$digits]; + } + + $adminId = (int) (Auth::user()?->amministratore?->id ?? 0); + + $query = RubricaUniversale::query(); + if ($adminId > 0 && Schema::hasColumn('rubrica_universale', 'amministratore_id')) { + $query->where(function ($q) use ($adminId): void { + $q->where('amministratore_id', $adminId) + ->orWhereNull('amministratore_id'); + }); + } + + $rubricaId = $query + ->where(function ($q) use ($digits): void { + $q->orWhereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio,''), ' ', ''), '+', ''), '-', '') LIKE ?", ['%' . $digits . '%']) + ->orWhereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare,''), ' ', ''), '+', ''), '-', '') LIKE ?", ['%' . $digits . '%']) + ->orWhereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa,''), ' ', ''), '+', ''), '-', '') LIKE ?", ['%' . $digits . '%']); + }) + ->orderByDesc('id') + ->value('id'); + + $result = is_numeric($rubricaId) ? (int) $rubricaId : null; + $this->rubricaPhoneCache[$digits] = $result; + + return $result; + } + public static function canAccess(): bool { $user = Auth::user(); diff --git a/app/Filament/Pages/Supporto/Modifiche.php b/app/Filament/Pages/Supporto/Modifiche.php index caf2bd5..afe7b3e 100644 --- a/app/Filament/Pages/Supporto/Modifiche.php +++ b/app/Filament/Pages/Supporto/Modifiche.php @@ -1,7 +1,9 @@ */ + public array $bugRegistry = []; + + public int $nextBugId = 1; + + /** @var array */ + public array $runtimeErrors = []; + + /** @var array */ + public array $commitNotes = []; + /** @var array */ public array $latestCommits = []; + /** @var array */ + public array $recentFilamentPages = []; + + /** @var array */ + public array $functionalHighlights = []; + public function mount(): void { $this->reloadDashboardData(); @@ -68,13 +124,25 @@ public function canRunUpdate(): bool $user = Auth::user(); return $user && method_exists($user, 'hasAnyRole') - && $user->hasAnyRole(['super-admin', 'admin', 'amministratore']); + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore']); } public function reloadDashboardData(): void { $this->loadVersionInfo(); $this->loadLatestCommits(); + $this->loadCommitNotes(); + $this->loadRuntimeErrors(); + $this->loadRecentFilamentPages(); + $this->loadFunctionalHighlights(); + + if ($this->selectedCommitHash === null && isset($this->latestCommits[0]['hash'])) { + $this->selectedCommitHash = (string) $this->latestCommits[0]['hash']; + } + + if ($this->selectedCommitHash !== null) { + $this->newCommitNote = (string) ($this->commitNotes[$this->selectedCommitHash] ?? ''); + } } public function runUpdate(): void @@ -89,45 +157,185 @@ public function runUpdate(): void return; } - $params = [ - '--channel' => $this->updateChannel, - '--apply' => true, - ]; - - if ($this->updateDryRun) { - $params['--dry-run'] = true; - } - - if ($this->updateForce) { - $params['--force'] = true; - } - - try { - $exitCode = Artisan::call('netgescon:update', $params); - $output = trim((string) Artisan::output()); - - $this->lastUpdateExitCode = $exitCode; - $this->lastUpdateOutput = $output; - $this->lastUpdateAt = now()->format('d/m/Y H:i:s'); - - $this->reloadDashboardData(); + $this->startUpdateJob(false); + } + public function runUpdateFallback(): void + { + if (! $this->canRunUpdate()) { Notification::make() - ->title($exitCode === 0 ? 'Aggiornamento completato' : 'Aggiornamento terminato con errori') - ->body($exitCode === 0 ? 'Procedura conclusa. Versione e commit aggiornati.' : 'Controlla il log tecnico nella sezione output.') - ->{$exitCode === 0 ? 'success' : 'danger'}() - ->send(); - } catch (Throwable $e) { - $this->lastUpdateExitCode = 1; - $this->lastUpdateAt = now()->format('d/m/Y H:i:s'); - $this->lastUpdateOutput = 'Errore durante esecuzione update: ' . $e->getMessage(); - - Notification::make() - ->title('Errore durante update') - ->body($e->getMessage()) + ->title('Permessi insufficienti') + ->body('Solo super-admin/admin/amministratore possono lanciare gli aggiornamenti.') ->danger() ->send(); + + return; } + + $this->startUpdateJob(true); + } + + public function refreshUpdateProgress(): void + { + $jobId = trim((string) ($this->updateJobId ?? '')); + if ($jobId === '') { + return; + } + + $progressPath = storage_path('app/support/update-jobs/' . $jobId . '.progress.json'); + $logPath = storage_path('app/support/update-jobs/' . $jobId . '.log'); + + if (is_file($progressPath)) { + $decoded = json_decode((string) @file_get_contents($progressPath), true); + if (is_array($decoded)) { + $this->updateProgressPercent = (int) ($decoded['percent'] ?? $this->updateProgressPercent); + $this->updateProgressMessage = (string) ($decoded['message'] ?? $this->updateProgressMessage); + $this->updateProgressStatus = (string) ($decoded['status'] ?? $this->updateProgressStatus); + + if (in_array($this->updateProgressStatus, ['completed', 'failed'], true)) { + $exitCode = isset($decoded['exit_code']) && is_numeric($decoded['exit_code']) + ? (int) $decoded['exit_code'] + : ($this->updateProgressStatus === 'completed' ? 0 : 1); + + $this->lastUpdateExitCode = $exitCode; + $this->lastUpdateAt = now()->format('d/m/Y H:i:s'); + $this->lastUpdateOutput = is_file($logPath) ? trim((string) @file_get_contents($logPath)) : ($this->updateProgressMessage ?: ''); + $this->lastUpdateIssue = $this->detectUpdateIssue((string) ($this->lastUpdateOutput ?? ''), $exitCode); + $this->updateInProgress = false; + + if ($exitCode !== 0) { + $this->appendSupportEvent('update_async', 'Aggiornamento asincrono fallito', (string) ($this->lastUpdateOutput ?? '')); + } + + $this->reloadDashboardData(); + } + } + } + } + + public function runMaintenanceOptimizeClear(): void + { + if (! $this->canRunUpdate()) { + Notification::make()->title('Permessi insufficienti')->danger()->send(); + return; + } + + $result = Process::path(base_path())->timeout(180)->run(['php', 'artisan', 'optimize:clear']); + $out = trim((string) ($result->output() !== '' ? $result->output() : $result->errorOutput())); + + $this->lastUpdateAt = now()->format('d/m/Y H:i:s'); + $this->lastUpdateExitCode = $result->exitCode(); + $this->lastUpdateOutput = 'MANUTENZIONE: optimize:clear' . PHP_EOL . $out; + $this->lastUpdateIssue = $result->successful() ? null : 'Errore durante optimize:clear.'; + + if (! $result->successful()) { + $this->appendSupportEvent('maintenance', 'optimize:clear fallito', $out); + } + + Notification::make() + ->title($result->successful() ? 'Cache pulite' : 'Errore pulizia cache') + ->{$result->successful() ? 'success' : 'danger'}() + ->send(); + } + + public function runMaintenanceViewRebuild(): void + { + if (! $this->canRunUpdate()) { + Notification::make()->title('Permessi insufficienti')->danger()->send(); + return; + } + + $clear = Process::path(base_path())->timeout(180)->run(['php', 'artisan', 'view:clear']); + $cache = Process::path(base_path())->timeout(180)->run(['php', 'artisan', 'view:cache']); + + $ok = $clear->successful() && $cache->successful(); + $out = trim(implode(PHP_EOL . PHP_EOL, array_filter([ + 'MANUTENZIONE: view:clear', + trim((string) ($clear->output() !== '' ? $clear->output() : $clear->errorOutput())), + 'MANUTENZIONE: view:cache', + trim((string) ($cache->output() !== '' ? $cache->output() : $cache->errorOutput())), + ], fn(string $v): bool => $v !== ''))); + + $this->lastUpdateAt = now()->format('d/m/Y H:i:s'); + $this->lastUpdateExitCode = $ok ? 0 : 1; + $this->lastUpdateOutput = $out; + $this->lastUpdateIssue = $ok ? null : 'Errore durante rebuild delle view Blade.'; + + if (! $ok) { + $this->appendSupportEvent('maintenance', 'view rebuild fallito', $out); + } + + Notification::make() + ->title($ok ? 'View ricostruite' : 'Errore rebuild view') + ->{$ok ? 'success' : 'danger'}() + ->send(); + } + + /** + * @return array + */ + public function getUpdatePlannedStepsProperty(): array + { + $steps = [ + 'Verifica canale update: ' . $this->updateChannel, + 'Backup pre-update automatico (snapshot differenziale + indice record)', + 'Upload backup su Google Drive se account amministratore disponibile', + 'Esecuzione in modalita ' . ($this->updateDryRun ? 'dry-run (nessuna scrittura)' : 'apply (aggiornamento reale)'), + 'Flag force: ' . ($this->updateForce ? 'abilitato' : 'disabilitato'), + 'Check endpoint update consigliato prima del lancio', + 'Aggiornamento versione e ricarica dashboard a fine procedura', + ]; + + return $steps; + } + + public function runUpdateConnectivityCheck(): void + { + $host = 'updates.netgescon.it'; + $url = 'https://updates.netgescon.it/api/v1/distribution/updates/manifest?channel=' . $this->updateChannel; + + $resolveArg = ''; + $resolveRaw = trim((string) config('distribution.http_resolve', '')); + if ($resolveRaw !== '') { + $entries = array_filter(array_map('trim', explode(',', $resolveRaw))); + foreach ($entries as $entry) { + if (str_starts_with($entry, $host . ':')) { + $resolveArg = ' --resolve ' . escapeshellarg($entry); + break; + } + } + } + + $dnsResult = Process::path(base_path())->run('getent hosts ' . escapeshellarg($host)); + $headResult = Process::path(base_path())->run('curl -I -sS --max-time 12' . $resolveArg . ' ' . escapeshellarg($url)); + + $out = []; + $out[] = 'DNS:'; + $out[] = trim($dnsResult->output() !== '' ? $dnsResult->output() : $dnsResult->errorOutput()); + $out[] = ''; + $out[] = 'HTTP HEAD:'; + if ($resolveArg !== '') { + $out[] = '(resolve override attivo da NETGESCON_UPDATE_RESOLVE)'; + } + $out[] = trim($headResult->output() !== '' ? $headResult->output() : $headResult->errorOutput()); + + $this->lastConnectivityCheckOutput = trim(implode(PHP_EOL, $out)); + + $combined = Str::lower($this->lastConnectivityCheckOutput); + if (str_contains($combined, 'timed out') || str_contains($combined, 'curl: (28)')) { + $this->appendSupportEvent('update_connectivity', 'Timeout endpoint updates', $this->lastConnectivityCheckOutput); + Notification::make() + ->title('Check con timeout') + ->warning() + ->body('Endpoint update non raggiungibile entro timeout.') + ->send(); + return; + } + + Notification::make() + ->title('Check connettivita completato') + ->success() + ->send(); } private function loadVersionInfo(): void @@ -137,23 +345,23 @@ private function loadVersionInfo(): void $fileVersion = trim((string) File::get($versionFile)); if ($fileVersion !== '') { $this->currentVersion = $fileVersion; - $this->versionSource = 'VERSION'; + $this->versionSource = 'VERSION'; return; } } $this->currentVersion = (string) config('netgescon.version', '0.0.0'); - $this->versionSource = 'config'; + $this->versionSource = 'config'; } private function loadLatestCommits(): void { $this->latestCommits = []; - $format = '%H|%h|%an|%ad|%s'; + $format = '%H|%h|%an|%ad|%s'; $command = 'git log -n 20 --date=format:"%d/%m/%Y %H:%M" --pretty=format:"' . $format . '"'; - $result = Process::path(base_path())->run($command); + $result = Process::path(base_path())->run($command); if (! $result->successful()) { return; @@ -167,12 +375,700 @@ private function loadLatestCommits(): void } $this->latestCommits[] = [ - 'hash' => (string) $parts[0], + 'hash' => (string) $parts[0], 'short_hash' => (string) $parts[1], - 'author' => (string) $parts[2], - 'date' => (string) $parts[3], - 'message' => (string) $parts[4], + 'author' => (string) $parts[2], + 'date' => (string) $parts[3], + 'message' => (string) $parts[4], ]; } } + + private function startUpdateJob(bool $fallback): void + { + $this->registerPlannedUpdateSummary(); + + $jobId = now()->format('YmdHis') . '-' . Str::lower(Str::random(6)); + $jobsDir = storage_path('app/support/update-jobs'); + if (! is_dir($jobsDir)) { + @mkdir($jobsDir, 0775, true); + } + + $progressPath = $jobsDir . '/' . $jobId . '.progress.json'; + $logPath = $jobsDir . '/' . $jobId . '.log'; + + $this->updateJobId = $jobId; + $this->updateInProgress = true; + $this->updateProgressPercent = 1; + $this->updateProgressMessage = 'Preparazione backup pre-update...'; + $this->updateProgressStatus = 'running'; + + @file_put_contents($progressPath, json_encode([ + 'timestamp' => now()->toIso8601String(), + 'percent' => 1, + 'message' => 'Preparazione backup pre-update...', + 'status' => 'running', + 'exit_code' => null, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + + $adminId = $this->resolveAmministratoreId(); + $backupParams = [ + '--differential' => true, + '--tag' => 'update-' . $jobId, + ]; + + if ($adminId > 0) { + $backupParams['--admin-id'] = (string) $adminId; + $backupParams['--drive'] = true; + } + + try { + $backupExit = Artisan::call('netgescon:preupdate-backup', $backupParams); + $backupOut = trim((string) Artisan::output()); + if ($backupExit !== 0) { + $this->updateInProgress = false; + $this->updateProgressStatus = 'failed'; + $this->updateProgressPercent = 100; + $this->updateProgressMessage = 'Backup pre-update fallito'; + $this->lastUpdateExitCode = 1; + $this->lastUpdateOutput = $backupOut; + $this->lastUpdateIssue = 'Backup pre-update fallito: aggiornamento bloccato per sicurezza.'; + @file_put_contents($progressPath, json_encode([ + 'timestamp' => now()->toIso8601String(), + 'percent' => 100, + 'message' => 'Backup pre-update fallito', + 'status' => 'failed', + 'exit_code' => 1, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return; + } + } catch (Throwable $e) { + $this->updateInProgress = false; + $this->updateProgressStatus = 'failed'; + $this->updateProgressPercent = 100; + $this->updateProgressMessage = 'Eccezione backup pre-update'; + $this->lastUpdateExitCode = 1; + $this->lastUpdateOutput = $e->getMessage(); + $this->lastUpdateIssue = 'Eccezione durante backup pre-update: aggiornamento bloccato.'; + @file_put_contents($progressPath, json_encode([ + 'timestamp' => now()->toIso8601String(), + 'percent' => 100, + 'message' => 'Eccezione backup pre-update', + 'status' => 'failed', + 'exit_code' => 1, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return; + } + + $args = [ + 'php', + 'artisan', + 'netgescon:update', + '--channel=' . $this->updateChannel, + '--apply', + '--progress-file=' . $progressPath, + ]; + + if ($this->updateDryRun) { + $args[] = '--dry-run'; + } + if ($this->updateForce || $fallback) { + $args[] = '--force'; + } + + $escaped = implode(' ', array_map(static fn(string $arg): string => escapeshellarg($arg), $args)); + $shell = 'nohup ' . $escaped . ' > ' . escapeshellarg($logPath) . ' 2>&1 & echo $!'; + $started = Process::path(base_path())->run(['bash', '-lc', $shell]); + + if (! $started->successful()) { + $this->updateInProgress = false; + $this->updateProgressStatus = 'failed'; + $this->updateProgressPercent = 100; + $this->updateProgressMessage = 'Impossibile avviare job update'; + $this->lastUpdateExitCode = 1; + $this->lastUpdateOutput = trim((string) ($started->output() !== '' ? $started->output() : $started->errorOutput())); + $this->lastUpdateIssue = 'Avvio job update fallito.'; + return; + } + + $this->updateProgressPercent = 5; + $this->updateProgressMessage = 'Job update avviato (PID ' . trim($started->output()) . ')'; + $this->updateProgressStatus = 'running'; + + Notification::make() + ->title('Aggiornamento avviato') + ->body('Backup pre-update completato. Avanzamento visibile in tempo reale.') + ->success() + ->send(); + } + + private function resolveAmministratoreId(): int + { + $user = Auth::user(); + if (! $user || ! method_exists($user, 'hasAnyRole')) { + return 0; + } + + $admin = $user->amministratore; + if ($admin instanceof Amministratore) { + return (int) $admin->id; + } + + if ($user->hasAnyRole(['super-admin', 'admin'])) { + $stabile = StabileContext::getActiveStabile($user); + if ($stabile instanceof Stabile) { + return (int) ($stabile->amministratore_id ?? 0); + } + } + + return 0; + } + + private function registerPlannedUpdateSummary(): void + { + $summary = implode(' | ', $this->getUpdatePlannedStepsProperty()); + $this->lastPlannedUpdateSummary = $summary; + $this->appendSupportEvent('update_plan', 'Piano aggiornamento registrato', $summary); + } + + private function loadRecentFilamentPages(): void + { + $this->recentFilamentPages = []; + + $command = 'git log -n 30 --name-only --pretty=format:"@@@%h|%s" -- app/Filament/Pages resources/views/filament/pages'; + $result = Process::path(base_path())->run($command); + if (! $result->successful()) { + return; + } + + $currentCommit = null; + $currentMessage = ''; + $seen = []; + $lines = preg_split('/\r\n|\r|\n/', (string) $result->output()) ?: []; + + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + + if (str_starts_with($line, '@@@')) { + $parts = explode('|', substr($line, 3), 2); + $currentCommit = trim((string) ($parts[0] ?? '')); + $currentMessage = trim((string) ($parts[1] ?? '')); + continue; + } + + if ($currentCommit === null) { + continue; + } + + if (! str_starts_with($line, 'app/Filament/Pages/') && ! str_starts_with($line, 'resources/views/filament/pages/')) { + continue; + } + + $key = $line; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $this->recentFilamentPages[] = [ + 'page' => $this->humanizePagePath($line), + 'path' => $line, + 'commit' => $currentCommit, + 'message' => $currentMessage, + ]; + + if (count($this->recentFilamentPages) >= 24) { + break; + } + } + } + + private function loadFunctionalHighlights(): void + { + $this->functionalHighlights = []; + + $path = base_path('docs/NETGESCON-MODIFICHE.md'); + if (! is_file($path)) { + return; + } + + $lines = preg_split('/\r\n|\r|\n/', (string) @file_get_contents($path)) ?: []; + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + + if (! str_starts_with($line, '-')) { + continue; + } + + if (! str_contains($line, '[U]') && ! str_contains($line, '[P]')) { + continue; + } + + $this->functionalHighlights[] = trim(ltrim($line, '- ')); + if (count($this->functionalHighlights) >= 30) { + break; + } + } + } + + private function humanizePagePath(string $path): string + { + $base = basename($path); + $name = preg_replace('/\.(php|blade\.php)$/', '', $base) ?? $base; + $name = preg_replace('/(?activeTab = $tab; + } + + public function setBugFilterStatus(string $status): void + { + if (! in_array($status, ['all', 'open', 'resolved'], true)) { + return; + } + + $this->bugFilterStatus = $status; + $this->loadRuntimeErrors(); + } + + public function markBugResolved(string $fingerprint): void + { + if (! isset($this->bugRegistry[$fingerprint])) { + return; + } + + $this->bugRegistry[$fingerprint]['status'] = 'resolved'; + $this->bugRegistry[$fingerprint]['resolved_at'] = now()->toIso8601String(); + if (trim($this->bugResolutionNote) !== '' && $this->selectedBugFingerprint === $fingerprint) { + $this->bugRegistry[$fingerprint]['resolution_note'] = mb_substr(trim($this->bugResolutionNote), 0, 2000); + } + $this->persistBugRegistry(); + $this->loadRuntimeErrors(); + + Notification::make()->title('BUG segnato come risolto')->success()->send(); + } + + public function reopenBug(string $fingerprint): void + { + if (! isset($this->bugRegistry[$fingerprint])) { + return; + } + + $this->bugRegistry[$fingerprint]['status'] = 'open'; + $this->bugRegistry[$fingerprint]['resolved_at'] = null; + $this->persistBugRegistry(); + $this->loadRuntimeErrors(); + + Notification::make()->title('BUG riaperto')->warning()->send(); + } + + public function selectBugForResolution(string $fingerprint): void + { + if (! isset($this->bugRegistry[$fingerprint])) { + return; + } + + $this->selectedBugFingerprint = $fingerprint; + $this->bugResolutionNote = (string) ($this->bugRegistry[$fingerprint]['resolution_note'] ?? ''); + } + + public function saveBugResolutionNote(): void + { + $fingerprint = trim((string) ($this->selectedBugFingerprint ?? '')); + if ($fingerprint === '' || ! isset($this->bugRegistry[$fingerprint])) { + Notification::make()->title('Seleziona un BUG')->warning()->send(); + return; + } + + $this->bugRegistry[$fingerprint]['resolution_note'] = mb_substr(trim($this->bugResolutionNote), 0, 2000); + $this->persistBugRegistry(); + $this->loadRuntimeErrors(); + + Notification::make()->title('Nota risoluzione salvata')->success()->send(); + } + + public function createManualBug(): void + { + $title = trim($this->manualBugTitle); + $details = trim($this->manualBugDetails); + + if ($title === '') { + Notification::make()->title('Titolo BUG mancante')->warning()->send(); + return; + } + + $context = $details !== '' ? $details : 'Nessun dettaglio aggiuntivo.'; + $this->appendSupportEvent('manual_bug', $title, $context); + + $this->manualBugTitle = ''; + $this->manualBugDetails = ''; + $this->loadRuntimeErrors(); + + Notification::make()->title('BUG manuale creato')->success()->send(); + } + + public function onSelectedCommitHashUpdated(): void + { + $hash = trim((string) ($this->selectedCommitHash ?? '')); + $this->newCommitNote = $hash !== '' ? (string) ($this->commitNotes[$hash] ?? '') : ''; + } + + public function saveCommitNote(): void + { + $hash = trim((string) ($this->selectedCommitHash ?? '')); + $note = trim($this->newCommitNote); + + if ($hash === '') { + Notification::make() + ->title('Seleziona un commit') + ->warning() + ->send(); + return; + } + + if ($note === '') { + unset($this->commitNotes[$hash]); + } else { + $this->commitNotes[$hash] = mb_substr($note, 0, 2000); + } + + $this->persistCommitNotes(); + + Notification::make() + ->title('Nota commit salvata') + ->success() + ->send(); + } + + public function getCommitNote(string $hash): string + { + return (string) ($this->commitNotes[$hash] ?? ''); + } + + private function detectUpdateIssue(string $output, int $exitCode): ?string + { + if ($exitCode === 0) { + return null; + } + + $haystack = Str::lower($output); + + if (str_contains($haystack, 'curl error 28') || str_contains($haystack, 'operation timed out') || str_contains($haystack, 'timeout')) { + return 'Timeout di rete durante update (cURL error 28). Verifica connettivita server/repository e riprova.'; + } + + if (str_contains($haystack, 'could not resolve host') || str_contains($haystack, 'temporary failure in name resolution')) { + return 'Errore DNS o host remoto non raggiungibile durante update.'; + } + + if (str_contains($haystack, 'permission denied')) { + return 'Permessi insufficienti sul filesystem o su git durante update.'; + } + + return 'Aggiornamento terminato con errore tecnico. Controlla l\'output completo.'; + } + + private function appendSupportEvent(string $type, string $message, string $context): void + { + $record = [ + 'timestamp' => now()->toIso8601String(), + 'type' => $type, + 'message' => $message, + 'context' => mb_substr(trim($context), 0, 2000), + ]; + + $json = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (! is_string($json) || $json === '') { + return; + } + + @file_put_contents(storage_path('logs/support-events.ndjson'), $json . PHP_EOL, FILE_APPEND); + } + + private function loadRuntimeErrors(): void + { + $events = []; + + $this->appendFromNdjson(storage_path('logs/runtime-errors.ndjson'), 'runtime-errors.ndjson', $events); + $this->appendFromNdjson(storage_path('logs/support-events.ndjson'), 'support-events.ndjson', $events); + $this->appendFromNdjson(storage_path('app/support/online-runtime-errors.ndjson'), 'online-runtime-errors.ndjson', $events); + $this->appendFromNdjson(base_path('docs/support/online-runtime-errors.ndjson'), 'online-runtime-errors.git', $events); + $this->appendFromLaravelLog(storage_path('logs/laravel.log'), $events); + + usort($events, static function (array $a, array $b): int { + return strcmp((string) ($b['timestamp'] ?? ''), (string) ($a['timestamp'] ?? '')); + }); + + $this->loadBugRegistry(); + $this->runtimeErrors = $this->collapseEventsToBugRows($events); + $this->persistBugRegistry(); + + $this->openBugCount = 0; + $this->resolvedBugCount = 0; + foreach ($this->bugRegistry as $meta) { + if (($meta['status'] ?? 'open') === 'resolved') { + $this->resolvedBugCount++; + } else { + $this->openBugCount++; + } + } + } + + private function appendFromNdjson(string $file, string $sourceLabel, array &$events): void + { + if (! is_file($file)) { + return; + } + + $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if (! is_array($lines) || $lines === []) { + return; + } + + foreach (array_reverse($lines) as $line) { + $decoded = json_decode((string) $line, true); + if (! is_array($decoded)) { + continue; + } + + $events[] = [ + 'timestamp' => (string) ($decoded['timestamp'] ?? '-'), + 'type' => (string) ($decoded['type'] ?? 'runtime'), + 'message' => (string) ($decoded['message'] ?? 'Errore runtime'), + 'context' => (string) ($decoded['context'] ?? ''), + 'source' => $sourceLabel, + ]; + + if (count($events) >= 250) { + break; + } + } + } + + private function appendFromLaravelLog(string $file, array &$events): void + { + if (! is_file($file)) { + return; + } + + $lines = @file($file, FILE_IGNORE_NEW_LINES); + if (! is_array($lines) || $lines === []) { + return; + } + + $slice = array_slice($lines, -4000); + foreach ($slice as $line) { + if (! is_string($line) || trim($line) === '') { + continue; + } + + if (preg_match('/^\[(?[^\]]+)\]\s+[^.]+\.(?[A-Z]+):\s+(?.+)$/', $line, $m) !== 1) { + continue; + } + + $level = strtolower((string) ($m['level'] ?? '')); + if (! in_array($level, ['error', 'critical', 'emergency', 'alert'], true)) { + continue; + } + + $events[] = [ + 'timestamp' => (string) ($m['ts'] ?? '-'), + 'type' => 'laravel_' . $level, + 'message' => Str::limit(trim((string) ($m['msg'] ?? '')), 300), + 'context' => '', + 'source' => 'laravel.log', + ]; + + if (count($events) >= 450) { + break; + } + } + } + + /** + * @param array $events + * @return array + */ + private function collapseEventsToBugRows(array $events): array + { + $rows = []; + + foreach ($events as $event) { + $fingerprint = sha1(implode('|', [ + (string) ($event['source'] ?? ''), + (string) ($event['type'] ?? ''), + (string) ($event['message'] ?? ''), + (string) ($event['context'] ?? ''), + ])); + + if (! isset($this->bugRegistry[$fingerprint])) { + $this->bugRegistry[$fingerprint] = [ + 'id' => $this->nextBugId, + 'status' => 'open', + 'first_seen' => (string) ($event['timestamp'] ?? '-'), + 'last_seen' => (string) ($event['timestamp'] ?? '-'), + 'resolved_at' => null, + 'resolution_note' => '', + 'title' => Str::limit((string) ($event['message'] ?? 'Errore runtime'), 160), + ]; + $this->nextBugId++; + } + + $this->bugRegistry[$fingerprint]['last_seen'] = (string) ($event['timestamp'] ?? '-'); + $bugStatus = (string) ($this->bugRegistry[$fingerprint]['status'] ?? 'open'); + + if (! isset($rows[$fingerprint])) { + $rows[$fingerprint] = [ + 'timestamp' => (string) ($event['timestamp'] ?? '-'), + 'type' => (string) ($event['type'] ?? 'runtime'), + 'message' => (string) ($event['message'] ?? 'Errore runtime'), + 'context' => (string) ($event['context'] ?? ''), + 'source' => (string) ($event['source'] ?? '-'), + 'fingerprint' => $fingerprint, + 'bug_id' => (int) $this->bugRegistry[$fingerprint]['id'], + 'bug_code' => sprintf('BUG-%04d', (int) $this->bugRegistry[$fingerprint]['id']), + 'bug_status' => $bugStatus, + 'occurrences' => 0, + 'resolution_note' => (string) ($this->bugRegistry[$fingerprint]['resolution_note'] ?? ''), + 'resolved_at' => isset($this->bugRegistry[$fingerprint]['resolved_at']) && is_string($this->bugRegistry[$fingerprint]['resolved_at']) + ? $this->bugRegistry[$fingerprint]['resolved_at'] + : null, + ]; + } + + $rows[$fingerprint]['occurrences']++; + $rows[$fingerprint]['bug_status'] = $bugStatus; + $rows[$fingerprint]['resolution_note'] = (string) ($this->bugRegistry[$fingerprint]['resolution_note'] ?? ''); + $rows[$fingerprint]['resolved_at'] = isset($this->bugRegistry[$fingerprint]['resolved_at']) && is_string($this->bugRegistry[$fingerprint]['resolved_at']) + ? $this->bugRegistry[$fingerprint]['resolved_at'] + : null; + } + + $final = array_values($rows); + usort($final, static function (array $a, array $b): int { + return strcmp((string) ($b['timestamp'] ?? ''), (string) ($a['timestamp'] ?? '')); + }); + + if ($this->bugFilterStatus !== 'all') { + $final = array_values(array_filter($final, fn(array $row): bool => ($row['bug_status'] ?? 'open') === $this->bugFilterStatus)); + } + + return array_slice($final, 0, 120); + } + + private function loadBugRegistry(): void + { + $this->bugRegistry = []; + $this->nextBugId = 1; + + $path = storage_path('app/support/bug-tracker.json'); + if (! is_file($path)) { + return; + } + + $decoded = json_decode((string) @file_get_contents($path), true); + if (! is_array($decoded)) { + return; + } + + $next = isset($decoded['next_bug_id']) && is_numeric($decoded['next_bug_id']) + ? (int) $decoded['next_bug_id'] + : 1; + + $this->nextBugId = max(1, $next); + + if (! is_array($decoded['bugs'] ?? null)) { + return; + } + + foreach ($decoded['bugs'] as $fingerprint => $meta) { + if (! is_string($fingerprint) || ! is_array($meta)) { + continue; + } + + $id = isset($meta['id']) && is_numeric($meta['id']) ? (int) $meta['id'] : null; + if (! $id) { + continue; + } + + $this->bugRegistry[$fingerprint] = [ + 'id' => $id, + 'status' => (string) ($meta['status'] ?? 'open'), + 'first_seen' => (string) ($meta['first_seen'] ?? '-'), + 'last_seen' => (string) ($meta['last_seen'] ?? '-'), + 'resolved_at' => isset($meta['resolved_at']) && is_string($meta['resolved_at']) ? $meta['resolved_at'] : null, + 'resolution_note' => (string) ($meta['resolution_note'] ?? ''), + 'title' => (string) ($meta['title'] ?? ''), + ]; + } + } + + private function persistBugRegistry(): void + { + $path = storage_path('app/support/bug-tracker.json'); + $dir = dirname($path); + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + + $payload = [ + 'next_bug_id' => $this->nextBugId, + 'bugs' => $this->bugRegistry, + ]; + + @file_put_contents( + $path, + json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } + + private function loadCommitNotes(): void + { + $this->commitNotes = []; + + $path = storage_path('app/support/commit-notes.json'); + if (! is_file($path)) { + return; + } + + $decoded = json_decode((string) @file_get_contents($path), true); + if (! is_array($decoded)) { + return; + } + + foreach ($decoded as $hash => $note) { + if (! is_string($hash) || ! is_string($note)) { + continue; + } + $this->commitNotes[$hash] = $note; + } + } + + private function persistCommitNotes(): void + { + $path = storage_path('app/support/commit-notes.json'); + $dir = dirname($path); + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + + @file_put_contents( + $path, + json_encode($this->commitNotes, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } } diff --git a/app/Filament/Pages/Supporto/RichiestaHelp.php b/app/Filament/Pages/Supporto/RichiestaHelp.php index b17c81d..1a0c33d 100644 --- a/app/Filament/Pages/Supporto/RichiestaHelp.php +++ b/app/Filament/Pages/Supporto/RichiestaHelp.php @@ -17,6 +17,7 @@ use Filament\Pages\Page; use Filament\Schemas\Schema; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; use Livewire\WithFileUploads; use UnitEnum; @@ -61,6 +62,16 @@ public function form(Schema $schema): Schema return $schema ->statePath('data') ->components([ + Select::make('tipo_richiesta') + ->label('Tipo richiesta') + ->options([ + 'bug' => 'Bug', + 'implementazione' => 'Implementazione', + 'modulo_nuovo' => 'Nuovo modulo', + 'supporto_operativo' => 'Supporto operativo', + ]) + ->default('supporto_operativo') + ->required(), TextInput::make('titolo') ->label('Titolo') ->required() @@ -78,6 +89,10 @@ public function form(Schema $schema): Schema 'Urgente' => 'Urgente', ]) ->required(), + Textarea::make('procedura_attivata') + ->label('Procedura attivata / passi già fatti') + ->rows(4) + ->helperText('Inserisci i passaggi eseguiti, test fatti e risultato.'), FileUpload::make('allegati') ->label('Foto / allegati') ->helperText('Da smartphone puoi scattare una foto al momento o selezionarla dalla galleria.') @@ -115,6 +130,21 @@ public function submit(): void $state = $this->getSchema('form')?->getState() ?? []; + $gitBranch = trim((string) Process::path(base_path())->run('git rev-parse --abbrev-ref HEAD')->output()); + $gitHash = trim((string) Process::path(base_path())->run('git rev-parse --short HEAD')->output()); + $gitContext = 'Branch: ' . ($gitBranch !== '' ? $gitBranch : 'n/a') . ' | Commit: ' . ($gitHash !== '' ? $gitHash : 'n/a'); + + $tipoRichiesta = (string) ($state['tipo_richiesta'] ?? 'supporto_operativo'); + $procedura = trim((string) ($state['procedura_attivata'] ?? '')); + $descrizioneBase = trim((string) ($state['descrizione'] ?? '')); + $descrizione = $descrizioneBase; + $descrizione .= "\n\n[Tracking Sviluppo]"; + $descrizione .= "\nTipo richiesta: " . $tipoRichiesta; + $descrizione .= "\n" . $gitContext; + if ($procedura !== '') { + $descrizione .= "\nProcedura attivata: " . $procedura; + } + $categoria = CategoriaTicket::query()->firstOrCreate( ['nome' => 'Supporto'], ['descrizione' => 'Richieste help, bug e supporto operativo'] @@ -124,8 +154,8 @@ public function submit(): void 'stabile_id' => $stabileId, 'aperto_da_user_id' => $user->id, 'categoria_ticket_id' => $categoria->id, - 'titolo' => (string) ($state['titolo'] ?? ''), - 'descrizione' => (string) ($state['descrizione'] ?? ''), + 'titolo' => '[' . strtoupper($tipoRichiesta) . '] ' . (string) ($state['titolo'] ?? ''), + 'descrizione' => $descrizione, 'priorita' => (string) ($state['priorita'] ?? 'Media'), 'stato' => 'Aperto', ]); @@ -155,10 +185,12 @@ public function submit(): void } $this->getSchema('form')?->fill([ - 'titolo' => null, - 'descrizione' => null, - 'priorita' => 'Media', - 'allegati' => [], + 'titolo' => null, + 'descrizione' => null, + 'tipo_richiesta' => 'supporto_operativo', + 'priorita' => 'Media', + 'procedura_attivata' => null, + 'allegati' => [], ]); Notification::make() diff --git a/app/Filament/Pages/Supporto/SupportoDashboard.php b/app/Filament/Pages/Supporto/SupportoDashboard.php new file mode 100644 index 0000000..39a6dcb --- /dev/null +++ b/app/Filament/Pages/Supporto/SupportoDashboard.php @@ -0,0 +1,23 @@ + */ + public array $categorieOptions = []; + /** @var array */ public array $fornitoriOptions = []; @@ -97,6 +112,7 @@ public function mount(): void } $this->loadFornitoriOptions(); + $this->loadCategorieTicketOptions(); $this->refreshFornitoriAttiviRows(); if ($this->selectedTicket) { $this->fornitoreId = (int) ($this->selectedTicket->assegnato_a_fornitore_id ?? 0) ?: null; @@ -105,6 +121,72 @@ public function mount(): void $this->refreshData(); } + public function updatedSelectedCategoriaId($value): void + { + $id = (int) $value; + if ($id <= 0) { + $this->categoriaNome = null; + $this->categoriaDescrizione = null; + return; + } + + $categoria = CategoriaTicket::query()->find($id); + if (! $categoria instanceof CategoriaTicket) { + return; + } + + $this->categoriaNome = (string) $categoria->nome; + $this->categoriaDescrizione = (string) ($categoria->descrizione ?? ''); + } + + public function creaCategoriaTicket(): void + { + $this->validate([ + 'categoriaNome' => ['required', 'string', 'max:255', Rule::unique('categorie_ticket', 'nome')], + 'categoriaDescrizione' => ['nullable', 'string', 'max:2000'], + ]); + + $categoria = CategoriaTicket::query()->create([ + 'nome' => trim((string) $this->categoriaNome), + 'descrizione' => filled($this->categoriaDescrizione) ? trim((string) $this->categoriaDescrizione) : null, + ]); + + $this->loadCategorieTicketOptions(); + $this->selectedCategoriaId = (int) $categoria->id; + $this->updatedSelectedCategoriaId($this->selectedCategoriaId); + + Notification::make()->title('Categoria creata')->success()->send(); + } + + public function aggiornaCategoriaTicket(): void + { + $id = (int) ($this->selectedCategoriaId ?? 0); + if ($id <= 0) { + Notification::make()->title('Seleziona una categoria da modificare')->warning()->send(); + return; + } + + $this->validate([ + 'categoriaNome' => ['required', 'string', 'max:255', Rule::unique('categorie_ticket', 'nome')->ignore($id)], + 'categoriaDescrizione' => ['nullable', 'string', 'max:2000'], + ]); + + $categoria = CategoriaTicket::query()->find($id); + if (! $categoria instanceof CategoriaTicket) { + Notification::make()->title('Categoria non trovata')->danger()->send(); + return; + } + + $categoria->update([ + 'nome' => trim((string) $this->categoriaNome), + 'descrizione' => filled($this->categoriaDescrizione) ? trim((string) $this->categoriaDescrizione) : null, + ]); + + $this->loadCategorieTicketOptions(); + Notification::make()->title('Categoria aggiornata')->success()->send(); + $this->refreshData(); + } + public function updatedStatus(): void { $this->refreshData(); @@ -153,7 +235,7 @@ public function getFornitoreAnagraficaUrl(int $fornitoreId): string public function getAttachmentPublicUrl(TicketAttachment $attachment): string { - return route('admin.tickets.attachments.view', ['attachment' => (int) $attachment->id]); + return route('filament.tickets.attachments.view', ['attachment' => (int) $attachment->id]); } public function openAttachmentPreview(int $attachmentId): void @@ -289,6 +371,9 @@ public function aggiungiNotaInterna(): void $this->notaInterna = null; + // Force refresh of selected ticket relations so the new note is visible immediately. + $this->refreshData(); + Notification::make()->title('Nota aggiunta')->success()->send(); } @@ -344,6 +429,7 @@ public function caricaAllegati(): void $this->nuoveFoto = []; $this->nuoviAllegati = []; $this->descrizioneAllegati = null; + $this->refreshData(); Notification::make() ->title('Allegati caricati') @@ -372,6 +458,110 @@ public function chiudiTicket(int $ticketId): void $this->aggiornaStatoTicket($ticketId, 'Chiuso'); } + public function abilitaAccessoFornitoreDaTicket(int $fornitoreId): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + Notification::make()->title('Stabile attivo non disponibile')->warning()->send(); + return; + } + + $stabile = \App\Models\Stabile::query()->find($stabileId); + $adminId = (int) ($stabile->amministratore_id ?? 0); + + $fornitore = Fornitore::query()->find($fornitoreId); + if (! $fornitore instanceof Fornitore) { + Notification::make()->title('Fornitore non trovato')->danger()->send(); + return; + } + + if ($adminId > 0 && (int) ($fornitore->amministratore_id ?? 0) > 0 && (int) ($fornitore->amministratore_id ?? 0) !== $adminId) { + Notification::make()->title('Fornitore non valido per questo stabile')->danger()->send(); + return; + } + + $email = trim((string) ($fornitore->email ?? '')); + if ($email === '') { + Notification::make()->title('Email fornitore mancante')->warning()->send(); + return; + } + + $dipendente = FornitoreDipendente::query() + ->where('fornitore_id', (int) $fornitore->id) + ->whereRaw('LOWER(email) = ?', [mb_strtolower($email)]) + ->first(); + + if (! $dipendente instanceof FornitoreDipendente) { + $dipendente = FornitoreDipendente::query()->create([ + 'fornitore_id' => (int) $fornitore->id, + 'nome' => (string) ($fornitore->ragione_sociale ?: ($fornitore->nome ?: 'Referente fornitore')), + 'cognome' => $fornitore->ragione_sociale ? null : (($fornitore->cognome ?? null) ?: null), + 'email' => $email, + 'telefono' => $fornitore->telefono ?: $fornitore->cellulare, + 'attivo' => true, + 'created_by_user_id' => Auth::id(), + 'updated_by_user_id' => Auth::id(), + ]); + } + + $this->abilitaAccessoDipendente($dipendente); + $this->refreshData(); + } + + public function resetPasswordFornitoreUser(int $userId): void + { + $user = Auth::user(); + if (! $user instanceof User) { + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + Notification::make()->title('Stabile attivo non disponibile')->warning()->send(); + return; + } + + $stabile = \App\Models\Stabile::query()->find($stabileId); + $adminId = (int) ($stabile->amministratore_id ?? 0); + + $canReset = FornitoreDipendente::query() + ->where('user_id', $userId) + ->whereHas('fornitore', function ($q) use ($adminId): void { + if ($adminId > 0) { + $q->where('amministratore_id', $adminId); + } + }) + ->exists(); + + if (! $canReset) { + Notification::make()->title('Utente non autorizzato per reset')->danger()->send(); + return; + } + + $target = User::query()->find($userId); + if (! $target instanceof User) { + Notification::make()->title('Utente non trovato')->danger()->send(); + return; + } + + $newPassword = Str::random(12); + $target->password = Hash::make($newPassword); + $target->save(); + + $this->lastGeneratedPassword = $newPassword; + + Notification::make() + ->title('Password fornitore resettata') + ->body('Nuova password temporanea: ' . $newPassword) + ->success() + ->send(); + } + private function loadTickets(): void { $user = Auth::user(); @@ -437,8 +627,8 @@ private function refreshFornitoriAttiviRows(): void })->values()->all(); }); - $openInterventiByFornitore = collect(); - $interventoStatiByFornitore = collect(); + $openInterventiByFornitore = collect(); + $interventoStatiByFornitore = collect(); $lastInterventoAtByFornitore = collect(); if (Schema::hasTable('ticket_interventi')) { $openInterventi = TicketIntervento::query() @@ -483,18 +673,33 @@ private function refreshFornitoriAttiviRows(): void return; } - $rows = []; + $rows = []; + $accessRows = FornitoreDipendente::query() + ->whereIn('fornitore_id', $fornitoriIds->all()) + ->get(['fornitore_id', 'user_id']); + + $dipCountByFornitore = $accessRows + ->groupBy('fornitore_id') + ->map(fn($r) => $r->count()); + + $linkedUserByFornitore = $accessRows + ->filter(fn($r) => (int) ($r->user_id ?? 0) > 0) + ->groupBy('fornitore_id') + ->map(fn($r) => (int) ($r->first()->user_id ?? 0)); + foreach (Fornitore::query()->whereIn('id', $fornitoriIds->all())->orderBy('ragione_sociale')->orderBy('cognome')->orderBy('nome')->get() as $fornitore) { $rows[] = [ - 'id' => (int) $fornitore->id, - 'nome' => trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? '')))), - 'email' => (string) ($fornitore->email ?? ''), - 'telefono' => (string) ($fornitore->telefono ?: $fornitore->cellulare), - 'ticket_attivi' => (int) ($assignedByFornitore->get((string) $fornitore->id) ?? $assignedByFornitore->get((int) $fornitore->id) ?? 0), - 'interventi_aperti' => (int) ($openInterventiByFornitore->get((string) $fornitore->id) ?? $openInterventiByFornitore->get((int) $fornitore->id) ?? 0), - 'ticket_focus' => (array) ($ticketSummaries->get((string) $fornitore->id) ?? $ticketSummaries->get((int) $fornitore->id) ?? []), - 'intervento_stati' => (string) ($interventoStatiByFornitore->get((string) $fornitore->id) ?? $interventoStatiByFornitore->get((int) $fornitore->id) ?? ''), + 'id' => (int) $fornitore->id, + 'nome' => trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? '')))), + 'email' => (string) ($fornitore->email ?? ''), + 'telefono' => (string) ($fornitore->telefono ?: $fornitore->cellulare), + 'ticket_attivi' => (int) ($assignedByFornitore->get((string) $fornitore->id) ?? $assignedByFornitore->get((int) $fornitore->id) ?? 0), + 'interventi_aperti' => (int) ($openInterventiByFornitore->get((string) $fornitore->id) ?? $openInterventiByFornitore->get((int) $fornitore->id) ?? 0), + 'ticket_focus' => (array) ($ticketSummaries->get((string) $fornitore->id) ?? $ticketSummaries->get((int) $fornitore->id) ?? []), + 'intervento_stati' => (string) ($interventoStatiByFornitore->get((string) $fornitore->id) ?? $interventoStatiByFornitore->get((int) $fornitore->id) ?? ''), 'ultimo_aggiornamento' => (string) ($lastInterventoAtByFornitore->get((string) $fornitore->id) ?? $lastInterventoAtByFornitore->get((int) $fornitore->id) ?? ''), + 'dipendenti_count' => (int) ($dipCountByFornitore->get((string) $fornitore->id) ?? $dipCountByFornitore->get((int) $fornitore->id) ?? 0), + 'linked_user_id' => (int) ($linkedUserByFornitore->get((string) $fornitore->id) ?? $linkedUserByFornitore->get((int) $fornitore->id) ?? 0), ]; } @@ -507,6 +712,43 @@ private function refreshFornitoriAttiviRows(): void $this->fornitoriAttiviRows = $rows; } + private function abilitaAccessoDipendente(FornitoreDipendente $dipendente): void + { + $email = trim((string) ($dipendente->email ?? '')); + if ($email === '') { + Notification::make()->title('Email dipendente mancante')->warning()->send(); + return; + } + + $existingUser = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first(); + $created = false; + if (! $existingUser instanceof User) { + $generatedPassword = Str::random(12); + $existingUser = User::query()->create([ + 'name' => trim((string) ($dipendente->nome_completo ?: 'Utente fornitore')), + 'email' => $email, + 'password' => Hash::make($generatedPassword), + 'email_verified_at' => now(), + 'is_active' => true, + ]); + + $this->lastGeneratedPassword = $generatedPassword; + $created = true; + } + + $existingUser->assignRole('fornitore'); + + $dipendente->user_id = (int) $existingUser->id; + $dipendente->updated_by_user_id = Auth::id(); + $dipendente->save(); + + $body = $created + ? 'Accesso creato. Password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)') + : 'Accesso attivato/aggiornato per utente esistente.'; + + Notification::make()->title('Accesso fornitore abilitato')->body($body)->success()->send(); + } + private function loadFornitoriOptions(): void { $user = Auth::user(); @@ -540,6 +782,44 @@ private function loadFornitoriOptions(): void })->all(); } + private function loadCategorieTicketOptions(): void + { + if (! Schema::hasTable('categorie_ticket')) { + $this->categorieOptions = []; + return; + } + + $this->categorieOptions = CategoriaTicket::query() + ->orderBy('nome') + ->get(['id', 'nome', 'descrizione']) + ->map(fn(CategoriaTicket $c): array=> [ + 'id' => (int) $c->id, + 'nome' => (string) $c->nome, + 'descrizione' => (string) ($c->descrizione ?? ''), + ]) + ->all(); + } + + public function getTipoInterventoLabel(Ticket $ticket): string + { + $categoria = trim((string) optional($ticket->categoriaTicket)->nome); + $titolo = trim((string) ($ticket->titolo ?? '')); + + if ($categoria !== '' && $titolo !== '') { + return $categoria . ' - ' . $titolo; + } + + if ($categoria !== '') { + return $categoria; + } + + if ($titolo !== '') { + return $titolo; + } + + return 'N/D'; + } + private function loadCounters(): void { $user = Auth::user(); diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index d7630b7..61acbb8 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -1,8 +1,14 @@ */ public $callerMatches; + /** @var array|null */ + public ?array $liveIncomingCall = null; + + public bool $liveCallCanClickToCall = false; + /** @var array */ public array $ticketCounters = [ 'open' => 0, @@ -117,6 +128,7 @@ public function mount(): void $this->status = 'all'; } + $this->refreshLiveCallBanner(); $this->refreshData(); } @@ -135,6 +147,195 @@ 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; + } + + $userExtension = trim((string) ($authUser->pbx_extension ?? '')); + $popupRestricted = (bool) ($authUser->pbx_popup_enabled ?? false) && $userExtension !== ''; + + $latest = CommunicationMessage::query() + ->where('channel', 'smdr') + ->where('direction', 'inbound') + ->whereNotNull('received_at') + ->where('received_at', '>=', now()->subMinutes(8)) + ->when($popupRestricted, function ($q) use ($userExtension): void { + $q->where('target_extension', $userExtension); + }) + ->latest('id') + ->first(['id', 'phone_number', 'target_extension', 'received_at', 'message_text', 'metadata']); + + if (! $latest) { + $this->liveIncomingCall = null; + $this->liveCallCanClickToCall = false; + return; + } + + $phone = $this->normalizePhoneDigits((string) ($latest->phone_number ?? '')); + if ($phone === '') { + $phone = $this->normalizePhoneDigits((string) data_get($latest->metadata, 'smdr.dial_number', '')); + } + + if ($phone === '') { + $this->liveIncomingCall = null; + $this->liveCallCanClickToCall = false; + return; + } + + $rubrica = $this->matchRubricaByPhone($phone); + $soggetto = $this->matchSoggetto($rubrica, $phone); + + $this->liveIncomingCall = [ + 'message_id' => (int) $latest->id, + 'phone' => $phone, + 'received_at' => optional($latest->received_at)->format('d/m/Y H:i:s'), + 'line' => (string) ($latest->message_text ?? ''), + 'target_extension' => (string) ($latest->target_extension ?? ''), + 'rubrica_id' => (int) ($rubrica?->id ?? 0), + 'rubrica_nome' => (string) ($rubrica?->nome_completo ?: $rubrica?->ragione_sociale ?: ''), + 'soggetto_id' => (int) ($soggetto?->id ?? 0), + ]; + + $this->liveCallCanClickToCall = (bool) ($authUser->pbx_click_to_call_enabled ?? false) + && $userExtension !== ''; + } + + 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); + + 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' => 'Richiesta telefonica da valutare', + 'nota' => $line !== '' ? $line : ('Chiamata in ingresso da ' . $phone), + 'priorita' => 'Media', + 'stato' => 'post_it', + 'chiamata_il' => now(), + ]); + + Notification::make() + ->title('Post-it creato dalla chiamata in arrivo') + ->success() + ->send(); + } + + 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 + { + $rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0); + if ($rubricaId <= 0) { + return null; + } + + return RubricaUniversaleScheda::getUrl(['record' => $rubricaId], panel: 'admin-filament'); + } + + public function getLiveEstrattoContoUrl(): ?string + { + $soggettoId = (int) ($this->liveIncomingCall['soggetto_id'] ?? 0); + if ($soggettoId <= 0) { + return null; + } + + return EstrattoContoSoggetto::getUrl(['record' => $soggettoId], panel: 'admin-filament'); } public function getRubricaFilamentUrl(): string @@ -362,7 +563,7 @@ public function creaTicketRapido(): void $descrizione .= "\nTipo intervento: " . $label; } - $ticket = Ticket::query()->create([ + $ticket = Ticket::query()->create([ 'stabile_id' => $stabileId, 'aperto_da_user_id' => $user->id, 'titolo' => (string) $this->newTicketTitolo, @@ -374,7 +575,7 @@ public function creaTicketRapido(): void 'priorita' => $this->newTicketPriorita, ]); - $savedAttachments = $this->salvaAllegatiTicket($ticket, $user->id); + $savedAttachments = $this->salvaAllegatiTicket($ticket, $user->id); Notification::make() ->title('Ticket creato') @@ -544,6 +745,86 @@ private function resolveAttachmentDescription(int $index): string return 'Allegato da Ticket Mobile'; } + private function prefillTicketFromLiveCall(string $phone, string $line): void + { + $this->callerSearch = $phone; + $this->searchCaller(); + + $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 = 'Chiamata in ingresso ' . $phone; + } + + if (blank($this->newTicketDescrizione)) { + $this->newTicketDescrizione = 'Segnalazione aperta durante chiamata in ingresso da ' . $phone . ".\n" . $line; + } + } + + private function normalizePhoneDigits(string $value): string + { + return preg_replace('/\D+/', '', $value) ?: ''; + } + + private function matchRubricaByPhone(string $digits): ?RubricaUniversale + { + if ($digits === '') { + return null; + } + + $needle = '%' . $digits . '%'; + $tail = strlen($digits) >= 7 ? substr($digits, -7) : $digits; + $tailNeedle = '%' . $tail . '%'; + + return RubricaUniversale::query() + ->where(function ($q) use ($needle, $tailNeedle): void { + $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]) + ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle]) + ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle]) + ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle]); + }) + ->orderByDesc('updated_at') + ->first(); + } + + private function matchSoggetto(?RubricaUniversale $rubrica, string $phone): ?Soggetto + { + if ($rubrica) { + $cf = trim((string) ($rubrica->codice_fiscale ?? '')); + if ($cf !== '') { + $found = Soggetto::query()->where('codice_fiscale', $cf)->first(); + if ($found) { + return $found; + } + } + + $piva = trim((string) ($rubrica->partita_iva ?? '')); + if ($piva !== '') { + $found = Soggetto::query()->where('partita_iva', $piva)->first(); + if ($found) { + return $found; + } + } + } + + $tail = strlen($phone) >= 7 ? substr($phone, -7) : $phone; + if ($tail === '') { + return null; + } + + return Soggetto::query()->where('telefono', 'like', '%' . $tail . '%')->first(); + } + public function excerpt(?string $text, int $limit = 160): string { return Str::limit((string) $text, $limit); diff --git a/app/Filament/Widgets/GoogleScadenziarioOverview.php b/app/Filament/Widgets/GoogleScadenziarioOverview.php new file mode 100644 index 0000000..4c22bf8 --- /dev/null +++ b/app/Filament/Widgets/GoogleScadenziarioOverview.php @@ -0,0 +1,7 @@ +resolveAmministratore(); @@ -91,6 +94,11 @@ public function getTicketApertiUrl(): string return TicketMobile::getUrl(panel: 'admin-filament'); } + public function getTicketGestioneUrl(int $ticketId): string + { + return TicketGestione::getUrl(['ticket' => $ticketId, 'tab' => 'scheda'], panel: 'admin-filament'); + } + public function importContactsToRubrica(): void { $amministratore = $this->resolveAmministratore(); @@ -756,8 +764,21 @@ private function loadContactsPreview(string $token): void private function loadOpenTickets(): void { + $user = Auth::user(); + if (! $user instanceof User) { + $this->openTickets = []; + return; + } + + $stabileId = StabileContext::resolveActiveStabileId($user); + if (! $stabileId) { + $this->openTickets = []; + return; + } + $items = Ticket::query() ->with('stabile:id,denominazione') + ->where('stabile_id', $stabileId) ->aperti() ->orderByDesc('created_at') ->limit(8) diff --git a/app/Http/Controllers/Admin/TicketAttachmentViewController.php b/app/Http/Controllers/Admin/TicketAttachmentViewController.php index 6506e62..b443c3d 100644 --- a/app/Http/Controllers/Admin/TicketAttachmentViewController.php +++ b/app/Http/Controllers/Admin/TicketAttachmentViewController.php @@ -1,5 +1,4 @@ path($path); - $mime = (string) ($attachment->mime_type ?: $disk->mimeType($path) ?: 'application/octet-stream'); + $mime = (string) ($attachment->mime_type ?: $disk->mimeType($path) ?: 'application/octet-stream'); return response()->file($absolutePath, [ - 'Content-Type' => $mime, - 'Cache-Control' => 'private, max-age=300', + 'Content-Type' => $mime, + 'Cache-Control' => 'private, max-age=300', 'Content-Disposition' => 'inline; filename="' . addslashes((string) ($attachment->original_file_name ?: basename($path))) . '"', ]); } diff --git a/app/Http/Controllers/Admin/TicketController.php b/app/Http/Controllers/Admin/TicketController.php index 226b27f..7133035 100755 --- a/app/Http/Controllers/Admin/TicketController.php +++ b/app/Http/Controllers/Admin/TicketController.php @@ -5,6 +5,7 @@ use App\Models\CategoriaTicket; use App\Models\Documento; use App\Models\Fornitore; +use App\Models\InsuranceClaim; use App\Models\Stabile; use App\Models\Ticket; use App\Models\User; @@ -110,6 +111,7 @@ public function show(Ticket $ticket) 'messages.emlDocumento', 'interventi.fornitore', 'interventi.presoInCaricoDaUser', + 'insuranceClaim', ]); $comunicazioni = $ticket->messages() @@ -199,6 +201,45 @@ public function storeEmailMessage(Request $request, Ticket $ticket) ->with('success', 'Email archiviata e collegata al ticket.'); } + public function openInsuranceClaim(Request $request, Ticket $ticket) + { + if ($ticket->stabile->amministratore_id !== Auth::user()->amministratore->id_amministratore ?? null) { + abort(403); + } + + $validated = $request->validate([ + 'policy_reference' => 'nullable|string|max:255', + 'claim_number' => 'nullable|string|max:255', + 'notes' => 'nullable|string|max:4000', + ]); + + $claim = InsuranceClaim::query()->updateOrCreate( + ['ticket_id' => $ticket->id], + [ + 'stabile_id' => $ticket->stabile_id, + 'policy_reference' => $validated['policy_reference'] ?? null, + 'claim_number' => $validated['claim_number'] ?? null, + 'status' => 'aperta', + 'opened_at' => now(), + 'notes' => $validated['notes'] ?? null, + ] + ); + + $ticket->messages()->create([ + 'user_id' => Auth::id(), + 'messaggio' => 'Apertura sinistro assicurativo collegata al ticket. Riferimento sinistro: ' . ($claim->claim_number ?: 'da definire'), + 'canale' => 'assicurazione', + 'direzione' => 'outbound', + 'inviato_il' => now(), + 'metadata' => [ + 'insurance_claim_id' => $claim->id, + ], + ]); + + return redirect()->route('admin.tickets.show', $ticket) + ->with('success', 'Sinistro assicurativo collegato al ticket.'); + } + /** * Estrae gli header principali da un file EML senza dipendenze esterne. * diff --git a/app/Http/Controllers/Api/CommunicationWebhookController.php b/app/Http/Controllers/Api/CommunicationWebhookController.php new file mode 100644 index 0000000..cf651b2 --- /dev/null +++ b/app/Http/Controllers/Api/CommunicationWebhookController.php @@ -0,0 +1,212 @@ +validate([ + 'chat_id' => ['required', 'string'], + 'text' => ['required', 'string', 'max:4000'], + ]); + + $botToken = (string) SystemSetting::getValue('telegram_bot_token', ''); + if ($botToken === '') { + return response()->json(['ok' => false, 'error' => 'telegram_bot_token_not_configured'], 422); + } + + $response = Http::post("https://api.telegram.org/bot{$botToken}/sendMessage", [ + 'chat_id' => $validated['chat_id'], + 'text' => $validated['text'], + ]); + + return response()->json([ + 'ok' => $response->ok(), + 'status' => $response->status(), + 'response' => $response->json(), + ], $response->ok() ? 200 : 422); + } + + public function sendWhatsappTest(Request $request): JsonResponse + { + $validated = $request->validate([ + 'to' => ['required', 'string'], + 'text' => ['required', 'string', 'max:4000'], + ]); + + $phoneNumberId = (string) SystemSetting::getValue('whatsapp_phone_number_id', ''); + $accessToken = (string) SystemSetting::getValue('whatsapp_access_token', ''); + + if ($phoneNumberId === '' || $accessToken === '') { + return response()->json(['ok' => false, 'error' => 'whatsapp_credentials_not_configured'], 422); + } + + $response = Http::withToken($accessToken) + ->post("https://graph.facebook.com/v22.0/{$phoneNumberId}/messages", [ + 'messaging_product' => 'whatsapp', + 'to' => $validated['to'], + 'type' => 'text', + 'text' => [ + 'preview_url' => false, + 'body' => $validated['text'], + ], + ]); + + return response()->json([ + 'ok' => $response->ok(), + 'status' => $response->status(), + 'response' => $response->json(), + ], $response->ok() ? 200 : 422); + } + + public function telegram(Request $request): JsonResponse + { + if (! $this->authorizeWebhook($request, 'telegram_webhook_token')) { + return response()->json(['ok' => false, 'error' => 'unauthorized'], 401); + } + + $payload = $request->all(); + $message = data_get($payload, 'message', []); + + return $this->storeIncoming([ + 'channel' => 'telegram', + 'external_chat_id' => (string) data_get($message, 'chat.id', ''), + 'external_message_id' => (string) data_get($message, 'message_id', ''), + 'sender_name' => trim((string) data_get($message, 'from.first_name', '') . ' ' . (string) data_get($message, 'from.last_name', '')), + 'phone_number' => null, + 'message_text' => (string) data_get($message, 'text', ''), + 'attachments' => null, + 'received_at' => now(), + 'metadata' => ['raw' => $payload], + ], $request); + } + + public function whatsapp(Request $request): JsonResponse + { + if (! $this->authorizeWebhook($request, 'whatsapp_webhook_token')) { + return response()->json(['ok' => false, 'error' => 'unauthorized'], 401); + } + + $payload = $request->all(); + $entry = data_get($payload, 'entry.0.changes.0.value', []); + $message = data_get($entry, 'messages.0', []); + $contact = data_get($entry, 'contacts.0', []); + + $attachments = []; + foreach (['image', 'document', 'video', 'audio'] as $type) { + if (data_get($message, $type)) { + $attachments[] = [ + 'type' => $type, + 'id' => (string) data_get($message, $type . '.id', ''), + 'mime_type' => (string) data_get($message, $type . '.mime_type', ''), + 'caption' => (string) data_get($message, $type . '.caption', ''), + ]; + } + } + + return $this->storeIncoming([ + 'channel' => 'whatsapp', + 'external_chat_id' => (string) data_get($message, 'from', ''), + 'external_message_id' => (string) data_get($message, 'id', ''), + 'sender_name' => (string) data_get($contact, 'profile.name', ''), + 'phone_number' => (string) data_get($message, 'from', ''), + 'message_text' => (string) data_get($message, 'text.body', ''), + 'attachments' => $attachments, + 'received_at' => now(), + 'metadata' => ['raw' => $payload], + ], $request); + } + + private function storeIncoming(array $data, Request $request): JsonResponse + { + $handlingMode = (string) $request->query('mode', 'log_only'); + $ticketId = (int) $request->query('ticket_id', 0); + $postItId = (int) $request->query('post_it_id', 0); + $insuranceClaimId = (int) $request->query('insurance_claim_id', 0); + + $ticket = $ticketId > 0 ? Ticket::query()->find($ticketId) : null; + $postIt = $postItId > 0 ? ChiamataPostIt::query()->find($postItId) : null; + $insuranceClaim = $insuranceClaimId > 0 ? InsuranceClaim::query()->find($insuranceClaimId) : null; + + if ($handlingMode === 'auto_post_it' && ! $ticket && ! $postIt) { + $postIt = ChiamataPostIt::query()->create([ + 'telefono' => $data['phone_number'] ?? null, + 'nome_chiamante' => $data['sender_name'] ?: 'Contatto esterno', + 'oggetto' => strtoupper((string) $data['channel']) . ' messaggio in ingresso', + 'nota' => (string) ($data['message_text'] ?? ''), + 'stato' => 'post_it', + 'priorita' => 'Media', + 'chiamata_il' => now(), + ]); + } + + $record = CommunicationMessage::query()->create([ + 'channel' => $data['channel'], + 'direction' => 'inbound', + 'external_chat_id' => $data['external_chat_id'] ?: null, + 'external_message_id' => $data['external_message_id'] ?: null, + 'phone_number' => $data['phone_number'] ?: null, + 'sender_name' => $data['sender_name'] ?: null, + 'message_text' => $data['message_text'] ?: null, + 'attachments' => $data['attachments'], + 'ticket_id' => $ticket?->id, + 'post_it_id' => $postIt?->id, + 'insurance_claim_id' => $insuranceClaim?->id, + 'status' => 'received', + 'received_at' => $data['received_at'], + 'metadata' => array_merge((array) $data['metadata'], ['handling_mode' => $handlingMode]), + ]); + + if ($ticket) { + TicketMessage::query()->create([ + 'ticket_id' => (int) $ticket->id, + 'user_id' => null, + 'messaggio' => (string) ($data['message_text'] ?: 'Messaggio in ingresso da canale esterno.'), + 'canale' => (string) $data['channel'], + 'direzione' => 'inbound', + 'external_message_id' => (string) ($data['external_message_id'] ?: null), + 'inviato_il' => $data['received_at'], + 'metadata' => ['communication_message_id' => $record->id], + ]); + } + + if ($postIt && ! $ticket) { + $existing = trim((string) ($postIt->nota ?? '')); + $line = '[' . now()->format('d/m/Y H:i') . '] ' . strtoupper((string) $data['channel']) . ': ' . (string) ($data['message_text'] ?: '(allegato)'); + $postIt->nota = $existing !== '' ? ($existing . "\n" . $line) : $line; + $postIt->save(); + } + + return response()->json([ + 'ok' => true, + 'protocol' => $record->protocol_number, + 'communication_message_id' => $record->id, + 'ticket_id' => $ticket?->id, + 'post_it_id' => $postIt?->id, + 'insurance_claim_id' => $insuranceClaim?->id, + ]); + } + + private function authorizeWebhook(Request $request, string $settingKey): bool + { + $expected = (string) SystemSetting::getValue($settingKey, ''); + if ($expected === '') { + return true; + } + + $provided = (string) ($request->header('X-Netgescon-Webhook-Token') ?: $request->query('token', '')); + + return $provided !== '' && hash_equals($expected, $provided); + } +} diff --git a/app/Http/Controllers/Api/PanasonicCstaController.php b/app/Http/Controllers/Api/PanasonicCstaController.php index 65e3b72..a18a6d3 100644 --- a/app/Http/Controllers/Api/PanasonicCstaController.php +++ b/app/Http/Controllers/Api/PanasonicCstaController.php @@ -1,5 +1,4 @@ classifyCall($payload); $this->logTrace('incoming.received', $request, $payload, [ - 'trace_id' => $traceId, + 'trace_id' => $traceId, 'classification' => $classification, - 'is_test' => $isTest, + 'is_test' => $isTest, ]); $normalized = PhoneNumber::normalizeForMatch((string) $payload['phone']); - $rubrica = $this->findRubricaByPhone($normalized); + $rubrica = $this->findRubricaByPhone($normalized); $postIt = null; if (Schema::hasTable('chiamate_post_it')) { @@ -70,26 +69,26 @@ public function incoming(Request $request): JsonResponse $this->mirrorToPeerIfEnabled($request, 'incoming', $payload); $this->logTrace('incoming.stored', $request, $payload, [ - 'trace_id' => $traceId, - 'post_it_id' => $postIt?->id, - 'rubrica_id' => $rubrica?->id, + 'trace_id' => $traceId, + 'post_it_id' => $postIt?->id, + 'rubrica_id' => $rubrica?->id, 'classification' => $classification, - 'is_test' => $isTest, + 'is_test' => $isTest, ]); return response()->json([ - 'ok' => true, - 'source' => 'panasonic_ns1000', - 'trace_id' => $traceId, - 'phone_normalized' => $normalized, - 'matched' => (bool) $rubrica, - 'rubrica' => $rubrica ? [ + 'ok' => true, + 'source' => 'panasonic_ns1000', + 'trace_id' => $traceId, + 'phone_normalized' => $normalized, + 'matched' => (bool) $rubrica, + 'rubrica' => $rubrica ? [ 'id' => $rubrica->id, 'nome_completo' => $rubrica->nome_completo, 'categoria' => $rubrica->categoria, ] : null, - 'post_it_id' => $postIt?->id, - 'called_extension' => $payload['called_extension'] ?? null, + 'post_it_id' => $postIt?->id, + 'called_extension' => $payload['called_extension'] ?? null, ]); } @@ -105,19 +104,19 @@ public function lookup(Request $request): JsonResponse ]); $normalized = PhoneNumber::normalizeForMatch((string) $data['phone']); - $rubrica = $this->findRubricaByPhone($normalized); + $rubrica = $this->findRubricaByPhone($normalized); return response()->json([ 'ok' => true, 'phone_normalized' => $normalized, 'matched' => (bool) $rubrica, 'rubrica' => $rubrica ? [ - 'id' => $rubrica->id, - 'nome_completo' => $rubrica->nome_completo, - 'telefono_ufficio' => $rubrica->telefono_ufficio, + 'id' => $rubrica->id, + 'nome_completo' => $rubrica->nome_completo, + 'telefono_ufficio' => $rubrica->telefono_ufficio, 'telefono_cellulare' => $rubrica->telefono_cellulare, - 'telefono_casa' => $rubrica->telefono_casa, - 'categoria' => $rubrica->categoria, + 'telefono_casa' => $rubrica->telefono_casa, + 'categoria' => $rubrica->categoria, ] : null, ]); } @@ -132,26 +131,26 @@ public function callEnded(Request $request): JsonResponse $traceId = (string) Str::uuid(); $payload = $request->validate([ - 'phone' => ['nullable', 'string', 'max:64', 'required_without:event_id'], - 'event_id' => ['nullable', 'string', 'max:100', 'required_without:phone'], - 'outcome' => ['nullable', 'string', 'max:255'], - 'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], - 'ended_at' => ['nullable', 'date'], - 'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'], - 'called_extension' => ['nullable', 'string', 'max:30'], - 'note' => ['nullable', 'string'], - 'is_test' => ['nullable', 'boolean'], + 'phone' => ['nullable', 'string', 'max:64', 'required_without:event_id'], + 'event_id' => ['nullable', 'string', 'max:100', 'required_without:phone'], + 'outcome' => ['nullable', 'string', 'max:255'], + 'duration_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'], + 'ended_at' => ['nullable', 'date'], + 'direction' => ['nullable', 'in:in_arrivo,in_uscita,persa'], + 'called_extension' => ['nullable', 'string', 'max:30'], + 'note' => ['nullable', 'string'], + 'is_test' => ['nullable', 'boolean'], ]); [$isTest, $classification] = $this->classifyCall($payload); $this->logTrace('call-ended.received', $request, $payload, [ - 'trace_id' => $traceId, + 'trace_id' => $traceId, 'classification' => $classification, - 'is_test' => $isTest, + 'is_test' => $isTest, ]); $normalized = PhoneNumber::normalizeForMatch((string) ($payload['phone'] ?? '')); - $postIt = $this->findOpenPostIt( + $postIt = $this->findOpenPostIt( (string) ($payload['event_id'] ?? ''), $normalized !== '' ? $normalized : (string) ($payload['phone'] ?? '') ); @@ -182,14 +181,14 @@ public function callEnded(Request $request): JsonResponse if (! $postIt) { $this->logTrace('call-ended.not-found', $request, $payload, [ - 'trace_id' => $traceId, + 'trace_id' => $traceId, 'classification' => $classification, - 'is_test' => $isTest, + 'is_test' => $isTest, ]); return response()->json([ - 'ok' => false, - 'message' => 'No matching Post-it and table unavailable', + 'ok' => false, + 'message' => 'No matching Post-it and table unavailable', 'trace_id' => $traceId, ], 404); } @@ -206,8 +205,8 @@ public function callEnded(Request $request): JsonResponse } if (! empty($payload['note'])) { - $existing = trim((string) ($postIt->nota ?? '')); - $suffix = 'Note chiusura PBX: ' . $payload['note']; + $existing = trim((string) ($postIt->nota ?? '')); + $suffix = 'Note chiusura PBX: ' . $payload['note']; $postIt->nota = $existing !== '' ? ($existing . "\n" . $suffix) : $suffix; } @@ -216,23 +215,23 @@ public function callEnded(Request $request): JsonResponse $this->mirrorToPeerIfEnabled($request, 'call-ended', $payload); $this->logTrace('call-ended.closed', $request, $payload, [ - 'trace_id' => $traceId, - 'post_it_id' => $postIt->id, - 'created_new' => $created, + 'trace_id' => $traceId, + 'post_it_id' => $postIt->id, + 'created_new' => $created, 'classification' => $classification, - 'is_test' => $isTest, + 'is_test' => $isTest, ]); return response()->json([ - 'ok' => true, - 'source' => 'panasonic_ns1000', - 'trace_id' => $traceId, - 'closed' => true, - 'created_new' => $created, - 'post_it_id' => $postIt->id, - 'stato' => $postIt->stato, - 'esito' => $postIt->esito, - 'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null, + 'ok' => true, + 'source' => 'panasonic_ns1000', + 'trace_id' => $traceId, + 'closed' => true, + 'created_new' => $created, + 'post_it_id' => $postIt->id, + 'stato' => $postIt->stato, + 'esito' => $postIt->esito, + 'durata_secondi' => Schema::hasColumn('chiamate_post_it', 'durata_secondi') ? $postIt->durata_secondi : null, ]); } @@ -293,20 +292,20 @@ private function mirrorToPeerIfEnabled(Request $request, string $endpoint, array } $timeout = (int) env('NETGESCON_CTI_MIRROR_TIMEOUT_SECONDS', 2); - $url = $baseUrl . '/api/v1/cti/panasonic/' . ltrim($endpoint, '/'); + $url = $baseUrl . '/api/v1/cti/panasonic/' . ltrim($endpoint, '/'); try { Http::timeout(max(1, $timeout)) ->withHeaders([ - 'X-CTI-Token' => $token, + 'X-CTI-Token' => $token, 'X-CTI-Mirrored' => '1', ]) ->post($url, $payload); } catch (\Throwable $e) { Log::warning('CTI mirror failed', [ 'endpoint' => $endpoint, - 'url' => $url, - 'error' => $e->getMessage(), + 'url' => $url, + 'error' => $e->getMessage(), ]); } } @@ -427,27 +426,27 @@ private function classifyCall(array $payload): array private function logUnauthorizedAttempt(Request $request, string $endpoint): void { Log::warning('CTI Panasonic unauthorized', [ - 'endpoint' => $endpoint, - 'ip' => $request->ip(), - 'user_agent' => (string) $request->userAgent(), + 'endpoint' => $endpoint, + 'ip' => $request->ip(), + 'user_agent' => (string) $request->userAgent(), 'has_token_header' => $request->headers->has('X-CTI-Token'), ]); } private function logTrace(string $event, Request $request, array $payload, array $extra = []): void { - $phoneRaw = (string) ($payload['phone'] ?? ''); + $phoneRaw = (string) ($payload['phone'] ?? ''); $phoneDigits = PhoneNumber::normalizeDigits($phoneRaw); Log::info('CTI Panasonic trace', array_merge([ - 'event' => $event, - 'ip' => $request->ip(), - 'user_agent' => (string) $request->userAgent(), - 'mirrored' => (string) $request->header('X-CTI-Mirrored', '0') === '1', - 'event_id' => (string) ($payload['event_id'] ?? ''), + 'event' => $event, + 'ip' => $request->ip(), + 'user_agent' => (string) $request->userAgent(), + 'mirrored' => (string) $request->header('X-CTI-Mirrored', '0') === '1', + 'event_id' => (string) ($payload['event_id'] ?? ''), 'called_extension' => (string) ($payload['called_extension'] ?? ''), - 'direction' => (string) ($payload['direction'] ?? ''), - 'phone_tail' => $phoneDigits !== '' ? substr($phoneDigits, -4) : '', + 'direction' => (string) ($payload['direction'] ?? ''), + 'phone_tail' => $phoneDigits !== '' ? substr($phoneDigits, -4) : '', ], $extra)); } } diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 613bcd9..c34b603 100755 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -1,12 +1,13 @@ authenticate(); + $user = $request->user(); + + if ($user && ! $user->is_active) { + Auth::guard('web')->logout(); + throw ValidationException::withMessages([ + 'email' => 'Account disattivato. Contatta il super-admin.', + ]); + } + + if ($user && $user->registration_status !== 'approved') { + Auth::guard('web')->logout(); + throw ValidationException::withMessages([ + 'email' => 'Registrazione in attesa di approvazione del super-admin.', + ]); + } + + $requireEmailVerification = SystemSetting::bool('require_email_verification', true); + $isSelfRegistered = $user && ! empty($user->requested_role); + if ($user && $isSelfRegistered && $requireEmailVerification && ! $user->hasVerifiedEmail()) { + Auth::guard('web')->logout(); + throw ValidationException::withMessages([ + 'email' => 'Devi confermare la tua email prima di accedere.', + ]); + } + $request->session()->regenerate(); return redirect()->intended(route('dashboard', absolute: false)); diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 25c3a63..c37ed44 100755 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -1,19 +1,18 @@ SystemSetting::bool('registration_enabled', true), + 'recaptchaVersion' => (string) SystemSetting::getValue('recaptcha_version', 'none'), + 'recaptchaSiteKey' => (string) SystemSetting::getValue('recaptcha_site_key', ''), + ]); } /** @@ -32,17 +35,24 @@ public function create(): View */ public function store(Request $request): RedirectResponse { + if (! SystemSetting::bool('registration_enabled', true)) { + return redirect()->route('login')->withErrors([ + 'email' => 'La registrazione e attualmente disabilitata. Contatta il supporto.', + ]); + } + $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class], + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class], 'password' => ['required', 'confirmed', Rules\Password::defaults()], - 'role' => ['nullable', 'in:amministratore,collaboratore,condomino,inquilino'], - 'cognome' => ['required_if:role,amministratore', 'nullable', 'string', 'max:255'], + 'role' => ['nullable', 'in:amministratore,collaboratore,condomino,inquilino'], ]); + $this->validateRecaptchaIfEnabled($request); + $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, + 'name' => $request->name, + 'email' => $request->email, 'password' => Hash::make($request->password), ]); @@ -55,38 +65,50 @@ public function store(Request $request): RedirectResponse // Role selection (optional): defaults to 'amministratore' $requestedRole = $request->string('role')->toString(); - $role = in_array($requestedRole, ['amministratore', 'collaboratore', 'condomino', 'inquilino'], true) + $role = in_array($requestedRole, ['amministratore', 'collaboratore', 'condomino', 'inquilino'], true) ? $requestedRole : 'amministratore'; + $user->forceFill([ + 'requested_role' => $role, + 'registration_status' => 'pending', + 'is_active' => false, + ])->save(); + $user->assignRole($role); - // Auto-create Amministratore profile ONLY for role 'amministratore' - if ($role === 'amministratore') { - Amministratore::firstOrCreate(['user_id' => $user->id], [ - 'nome' => $user->name, - 'cognome' => (string) $request->string('cognome'), - ]); - } - - if (in_array($role, ['condomino', 'inquilino'], true)) { - Soggetto::firstOrCreate( - ['email' => $user->email], - [ - 'nome' => $user->name, - 'cognome' => (string) $request->string('cognome'), - 'tipo' => $role === 'condomino' ? 'proprietario' : 'inquilino', - ] - ); - } - event(new Registered($user)); - Auth::login($user); + return redirect()->route('login')->with('status', 'Registrazione ricevuta. Conferma la tua email e attendi l\'approvazione del super-admin.'); + } - if (in_array($role, ['condomino', 'inquilino'], true)) { - return redirect()->route('condomino.tickets.create'); + private function validateRecaptchaIfEnabled(Request $request): void + { + $version = (string) SystemSetting::getValue('recaptcha_version', 'none'); + $siteKey = (string) SystemSetting::getValue('recaptcha_site_key', ''); + $secret = (string) SystemSetting::getValue('recaptcha_secret_key', ''); + + if ($version === 'none' || $siteKey === '' || $secret === '') { + return; } - return redirect()->route('admin.dashboard'); + $token = (string) $request->input('g-recaptcha-response', ''); + + if ($token === '') { + throw ValidationException::withMessages([ + 'g-recaptcha-response' => 'Conferma reCAPTCHA obbligatoria.', + ]); + } + + $response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [ + 'secret' => $secret, + 'response' => $token, + 'remoteip' => $request->ip(), + ]); + + if (! $response->ok() || ! ((bool) data_get($response->json(), 'success', false))) { + throw ValidationException::withMessages([ + 'g-recaptcha-response' => 'Verifica reCAPTCHA non valida.', + ]); + } } } diff --git a/app/Http/Controllers/Fornitore/DipendenteController.php b/app/Http/Controllers/Fornitore/DipendenteController.php index cafc361..107d881 100644 --- a/app/Http/Controllers/Fornitore/DipendenteController.php +++ b/app/Http/Controllers/Fornitore/DipendenteController.php @@ -68,8 +68,7 @@ public function store(Request $request): RedirectResponse $dipendente->updated_by_user_id = Auth::id(); if ((bool) ($validated['create_user_access'] ?? false) && $email !== '') { - $user = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first(); - $generatedPassword = null; + $user = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first(); if (! $user) { $generatedPassword = Str::random(12); $user = User::query()->create([ @@ -86,7 +85,6 @@ public function store(Request $request): RedirectResponse } $dipendente->user_id = (int) $user->id; - $dipendente->note = trim((string) (($dipendente->note ?? '') . ($generatedPassword ? ('\nCredenziali iniziali generate il ' . now()->format('d/m/Y H:i') . ': ' . $generatedPassword) : ''))); } $dipendente->save(); diff --git a/app/Http/Controllers/Fornitore/TicketOperativoController.php b/app/Http/Controllers/Fornitore/TicketOperativoController.php index d6f5993..bef8051 100644 --- a/app/Http/Controllers/Fornitore/TicketOperativoController.php +++ b/app/Http/Controllers/Fornitore/TicketOperativoController.php @@ -7,7 +7,6 @@ use App\Models\TicketAttachment; use App\Models\TicketIntervento; use Illuminate\Http\Request; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Schema; @@ -34,7 +33,7 @@ public function index(Request $request) $interventi = $query->paginate(15)->withQueryString(); $rows = collect($interventi->items()) - ->map(fn(TicketIntervento $intervento): array => $this->buildInterventoRow($intervento)); + ->map(fn(TicketIntervento $intervento): array=> $this->buildInterventoRow($intervento)); $fatturabili = TicketIntervento::query() ->where('fornitore_id', $fornitore->id) @@ -176,7 +175,7 @@ public function complete(Request $request, TicketIntervento $intervento) $docFiles = $docFiles ? [$docFiles] : []; } - $allFiles = array_merge($fotoFiles, $docFiles); + $allFiles = array_merge($fotoFiles, $docFiles); $descriptions = (array) ($validated['descrizione_file'] ?? []); if (Schema::hasTable('ticket_attachments')) { foreach ($allFiles as $index => $file) { @@ -216,7 +215,7 @@ public function complete(Request $request, TicketIntervento $intervento) ]); $proformaCodice = trim((string) ($validated['proforma_codice'] ?? '')); - $proformaNote = trim((string) ($validated['proforma_note'] ?? '')); + $proformaNote = trim((string) ($validated['proforma_note'] ?? '')); if (! empty($validated['richiesta_proforma']) || $proformaCodice !== '' || $proformaNote !== '') { $intervento->ticket->messages()->create([ 'user_id' => Auth::id(), @@ -321,7 +320,7 @@ private function authorizeIntervento(TicketIntervento $intervento, Fornitore $fo private function buildInterventoRow(TicketIntervento $intervento): array { $descrizione = (string) ($intervento->ticket->descrizione ?? ''); - $caller = $this->extractCallerData($descrizione); + $caller = $this->extractCallerData($descrizione); return [ 'ingresso' => optional($intervento->created_at)->format('d/m/Y H:i') ?: '-', @@ -378,7 +377,7 @@ private function extractApparato(string $rapporto): string private function buildApparatoSummary(string $marca, string $modello, string $seriale): string { - $marca = trim($marca); + $marca = trim($marca); $modello = trim($modello); $seriale = trim($seriale); diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index a48eb8d..7e58f2a 100755 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -1,12 +1,14 @@ user(); + $pendingSecret = session('two_factor_pending_secret'); + $pendingUri = null; + + if ($pendingSecret && $user) { + $pendingUri = app(TotpService::class)->buildOtpAuthUri( + config('app.name', 'NetGescon'), + (string) $user->email, + (string) $pendingSecret + ); + } + return view('profile.edit', [ - 'user' => $request->user(), + 'user' => $user, + 'twoFactorPendingSecret' => $pendingSecret, + 'twoFactorPendingUri' => $pendingUri, ]); } @@ -57,4 +73,75 @@ public function destroy(Request $request): RedirectResponse return Redirect::to('/'); } + + public function twoFactorEnable(Request $request, TotpService $totp): RedirectResponse + { + $user = $request->user(); + if (! $user) { + abort(403); + } + + $secret = $totp->generateSecret(); + $request->session()->put('two_factor_pending_secret', $secret); + + return Redirect::route('profile.edit')->with('status', 'two-factor-pending'); + } + + public function twoFactorConfirm(Request $request, TotpService $totp): RedirectResponse + { + $request->validate([ + 'code' => ['required', 'digits:6'], + ]); + + $user = $request->user(); + $secret = (string) $request->session()->get('two_factor_pending_secret', ''); + + if (! $user || $secret === '') { + throw ValidationException::withMessages([ + 'code' => 'Sessione 2FA scaduta. Riavvia la procedura.', + ]); + } + + if (! $totp->verify($secret, (string) $request->input('code'))) { + throw ValidationException::withMessages([ + 'code' => 'Codice 2FA non valido.', + ]); + } + + $recoveryCodes = collect(range(1, 8)) + ->map(fn() => strtoupper(bin2hex(random_bytes(4)))) + ->all(); + + $user->forceFill([ + 'two_factor_secret' => Crypt::encryptString($secret), + 'two_factor_recovery_codes' => Crypt::encryptString(json_encode($recoveryCodes)), + 'two_factor_confirmed_at' => now(), + ])->save(); + + $request->session()->forget('two_factor_pending_secret'); + + return Redirect::route('profile.edit')->with('status', 'two-factor-enabled'); + } + + public function twoFactorDisable(Request $request): RedirectResponse + { + $request->validate([ + 'password' => ['required', 'current_password'], + ]); + + $user = $request->user(); + if (! $user) { + abort(403); + } + + $user->forceFill([ + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + 'two_factor_confirmed_at' => null, + ])->save(); + + $request->session()->forget('two_factor_pending_secret'); + + return Redirect::route('profile.edit')->with('status', 'two-factor-disabled'); + } } diff --git a/app/Http/Controllers/SuperAdmin/ImpostazioniController.php b/app/Http/Controllers/SuperAdmin/ImpostazioniController.php index c20d2c6..217033f 100755 --- a/app/Http/Controllers/SuperAdmin/ImpostazioniController.php +++ b/app/Http/Controllers/SuperAdmin/ImpostazioniController.php @@ -1,10 +1,13 @@ UserSetting::get('dark_mode', 'false'), - 'bg_color' => UserSetting::get('bg_color', '#ffffff'), - 'text_color' => UserSetting::get('text_color', '#1e293b'), - 'accent_color' => UserSetting::get('accent_color', '#6366f1'), - 'sidebar_bg_color' => UserSetting::get('sidebar_bg_color', '#fde047'), - 'sidebar_text_color' => UserSetting::get('sidebar_text_color', '#1e293b'), - 'sidebar_accent_color' => UserSetting::get('sidebar_accent_color', '#6366f1'), + 'dark_mode' => UserSetting::get('dark_mode', 'false'), + 'bg_color' => UserSetting::get('bg_color', '#ffffff'), + 'text_color' => UserSetting::get('text_color', '#1e293b'), + 'accent_color' => UserSetting::get('accent_color', '#6366f1'), + 'sidebar_bg_color' => UserSetting::get('sidebar_bg_color', '#fde047'), + 'sidebar_text_color' => UserSetting::get('sidebar_text_color', '#1e293b'), + 'sidebar_accent_color' => UserSetting::get('sidebar_accent_color', '#6366f1'), + 'registration_enabled' => (string) (SystemSetting::bool('registration_enabled', true) ? 'true' : 'false'), + 'require_email_verification' => (string) (SystemSetting::bool('require_email_verification', true) ? 'true' : 'false'), + 'recaptcha_version' => (string) SystemSetting::getValue('recaptcha_version', 'none'), + 'recaptcha_site_key' => (string) SystemSetting::getValue('recaptcha_site_key', ''), + 'recaptcha_secret_key' => (string) SystemSetting::getValue('recaptcha_secret_key', ''), + 'telegram_bot_token' => (string) SystemSetting::getValue('telegram_bot_token', ''), + 'telegram_webhook_token' => (string) SystemSetting::getValue('telegram_webhook_token', ''), + 'whatsapp_phone_number_id' => (string) SystemSetting::getValue('whatsapp_phone_number_id', ''), + 'whatsapp_business_account_id' => (string) SystemSetting::getValue('whatsapp_business_account_id', ''), + 'whatsapp_access_token' => (string) SystemSetting::getValue('whatsapp_access_token', ''), + 'whatsapp_webhook_token' => (string) SystemSetting::getValue('whatsapp_webhook_token', ''), ]; - return view('superadmin.impostazioni.index', compact('settings')); + $googleStatus = $this->googleStatus(); + + return view('superadmin.impostazioni.index', compact('settings', 'googleStatus')); } public function store(Request $request) { $validated = $request->validate([ - 'dark_mode' => 'string|in:true,false', - 'bg_color' => 'string|max:7', - 'text_color' => 'string|max:7', - 'accent_color' => 'string|max:7', - 'sidebar_bg_color' => 'string|max:7', - 'sidebar_text_color' => 'string|max:7', - 'sidebar_accent_color' => 'string|max:7', + 'dark_mode' => 'string|in:true,false', + 'bg_color' => 'string|max:7', + 'text_color' => 'string|max:7', + 'accent_color' => 'string|max:7', + 'sidebar_bg_color' => 'string|max:7', + 'sidebar_text_color' => 'string|max:7', + 'sidebar_accent_color' => 'string|max:7', + 'registration_enabled' => 'nullable|string|in:true,false', + 'require_email_verification' => 'nullable|string|in:true,false', + 'recaptcha_version' => 'nullable|string|in:none,v2,v3', + 'recaptcha_site_key' => 'nullable|string|max:255', + 'recaptcha_secret_key' => 'nullable|string|max:255', + 'telegram_bot_token' => 'nullable|string|max:255', + 'telegram_webhook_token' => 'nullable|string|max:255', + 'whatsapp_phone_number_id' => 'nullable|string|max:255', + 'whatsapp_business_account_id' => 'nullable|string|max:255', + 'whatsapp_access_token' => 'nullable|string|max:500', + 'whatsapp_webhook_token' => 'nullable|string|max:255', ]); - // Salva le impostazioni per l'utente corrente + $globalKeys = [ + 'registration_enabled', + 'require_email_verification', + 'recaptcha_version', + 'recaptcha_site_key', + 'recaptcha_secret_key', + 'telegram_bot_token', + 'telegram_webhook_token', + 'whatsapp_phone_number_id', + 'whatsapp_business_account_id', + 'whatsapp_access_token', + 'whatsapp_webhook_token', + ]; + foreach ($validated as $key => $value) { - UserSetting::set($key, $value); + if (in_array($key, $globalKeys, true)) { + SystemSetting::setValue($key, $value); + } else { + UserSetting::set($key, $value); + } } - return response()->json(['success' => true, 'message' => 'Impostazioni salvate con successo!']); + if ($request->expectsJson()) { + return response()->json(['success' => true, 'message' => 'Impostazioni salvate con successo!']); + } + + return back()->with('success', 'Impostazioni salvate con successo.'); } public function theme(Request $request) { $theme = $request->input('theme', 'default'); - + $themes = [ 'default' => [ - 'bg_color' => '#ffffff', - 'text_color' => '#1e293b', - 'accent_color' => '#6366f1', - 'sidebar_bg_color' => '#fde047', - 'sidebar_text_color' => '#1e293b', + 'bg_color' => '#ffffff', + 'text_color' => '#1e293b', + 'accent_color' => '#6366f1', + 'sidebar_bg_color' => '#fde047', + 'sidebar_text_color' => '#1e293b', 'sidebar_accent_color' => '#6366f1', ], - 'dark' => [ - 'bg_color' => '#1e293b', - 'text_color' => '#f1f5f9', - 'accent_color' => '#fbbf24', - 'sidebar_bg_color' => '#374151', - 'sidebar_text_color' => '#f1f5f9', + 'dark' => [ + 'bg_color' => '#1e293b', + 'text_color' => '#f1f5f9', + 'accent_color' => '#fbbf24', + 'sidebar_bg_color' => '#374151', + 'sidebar_text_color' => '#f1f5f9', 'sidebar_accent_color' => '#fbbf24', ], - 'ocean' => [ - 'bg_color' => '#f0f9ff', - 'text_color' => '#0c4a6e', - 'accent_color' => '#0ea5e9', - 'sidebar_bg_color' => '#0ea5e9', - 'sidebar_text_color' => '#ffffff', + 'ocean' => [ + 'bg_color' => '#f0f9ff', + 'text_color' => '#0c4a6e', + 'accent_color' => '#0ea5e9', + 'sidebar_bg_color' => '#0ea5e9', + 'sidebar_text_color' => '#ffffff', 'sidebar_accent_color' => '#f0f9ff', ], ]; @@ -83,4 +131,34 @@ public function theme(Request $request) return response()->json(['success' => true, 'settings' => $themes[$theme] ?? []]); } + + private function googleStatus(): array + { + $clientId = (string) config('services.google.client_id', ''); + $clientSecret = (string) config('services.google.client_secret', ''); + $redirect = (string) config('services.google.redirect', ''); + + $admin = null; + $user = Auth::user(); + if ($user && method_exists($user, 'amministratore') && $user->amministratore) { + $admin = $user->amministratore; + } + if (! $admin instanceof Amministratore) { + $admin = Amministratore::query()->latest('id')->first(); + } + + $google = Arr::get($admin?->impostazioni ?? [], 'google', []); + $oauth = Arr::get($google, 'oauth', []); + + return [ + 'client_id_configured' => $clientId !== '', + 'client_secret_configured' => $clientSecret !== '', + 'redirect_configured' => $redirect !== '', + 'oauth_connected' => (bool) Arr::get($oauth, 'connected', false), + 'oauth_email' => (string) Arr::get($oauth, 'email', ''), + 'calendar_enabled' => (bool) Arr::get($oauth, 'connected', false), + 'contacts_enabled' => (bool) Arr::get($oauth, 'connected', false), + 'drive_enabled' => (bool) Arr::get($oauth, 'connected', false), + ]; + } } diff --git a/app/Http/Controllers/SuperAdmin/UserController.php b/app/Http/Controllers/SuperAdmin/UserController.php index a5f146d..9c40645 100755 --- a/app/Http/Controllers/SuperAdmin/UserController.php +++ b/app/Http/Controllers/SuperAdmin/UserController.php @@ -1,65 +1,77 @@ middleware('permission:view-users', ['only' => ['index']]); $this->middleware('permission:create-users', ['only' => ['create', 'store']]); - $this->middleware('permission:manage-users', ['only' => ['create', 'store', 'edit', 'update', 'destroy', 'updateRole']]); + $this->middleware('permission:manage-users', ['only' => ['create', 'store', 'edit', 'update', 'destroy', 'updateRole', 'approve', 'reject', 'toggleActive', 'assignPlan']]); $this->middleware('permission:impersonate-users', ['only' => ['impersonate']]); } - /** * Display a listing of the resource. */ public function index() { - - $users = User::with('roles')->paginate(10); + $users = User::with(['roles', 'subscriptionPlan'])->latest('id')->paginate(10); $roles = Role::all(); // Per la selezione dei ruoli nella vista - return view('superadmin.users.index', compact('users', 'roles')); + $plans = SubscriptionPlan::query()->where('is_active', true)->orderBy('name')->get(); + + return view('superadmin.users.index', compact('users', 'roles', 'plans')); } public function create() - { // <-- QUESTA PARENTESI MANCAVA! - $roles = Role::all(); // Definisci $roles qui - return view('superadmin.users.create', compact('roles')); + { // <-- QUESTA PARENTESI MANCAVA! + $roles = Role::all(); // Definisci $roles qui + $plans = SubscriptionPlan::query()->where('is_active', true)->orderBy('name')->get(); + + return view('superadmin.users.create', compact('roles', 'plans')); } public function store(Request $request) { $request->validate([ - 'name' => 'required|string|max:255', // Aggiunto 'name' alla validazione - 'email' => 'required|string|email|max:255|unique:users', - 'password' => 'required|string|min:8|confirmed', - 'role' => 'required|exists:roles,name', - 'expires_at' => 'nullable|date', - 'permissions' => 'array', - 'permissions.*' => 'string|exists:permissions,name', + 'name' => 'required|string|max:255', // Aggiunto 'name' alla validazione + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + 'role' => 'required|exists:roles,name', + 'expires_at' => 'nullable|date', + 'subscription_plan_id' => 'nullable|exists:subscription_plans,id', + 'permissions' => 'array', + 'permissions.*' => 'string|exists:permissions,name', ]); $user = User::create([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => Hash::make($request->password), - 'expires_at' => $request->filled('expires_at') ? $request->date('expires_at') : null, + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + 'expires_at' => $request->filled('expires_at') ? $request->date('expires_at') : null, + 'subscription_plan_id' => $request->input('subscription_plan_id'), + 'registration_status' => 'approved', + 'is_active' => true, + 'approved_by_user_id' => Auth::id(), + 'approved_at' => now(), ]); $user->assignRole($request->role); + $this->ensureDomainProfiles($user, $request->role); // Sincronizza permessi espliciti (oltre ai ruoli) $selectedPerms = collect($request->input('permissions', []))->unique()->values()->all(); @@ -71,28 +83,38 @@ public function store(Request $request) public function edit(User $user) { $roles = Role::all(); + $plans = SubscriptionPlan::query()->where('is_active', true)->orderBy('name')->get(); // Verifica che l'utente corrente abbia i permessi per modificare gli utenti - return view('superadmin.users.edit', compact('user', 'roles')); + return view('superadmin.users.edit', compact('user', 'roles', 'plans')); } public function update(Request $request, User $user) { $request->validate([ - 'name' => 'required|string|max:255', - 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user)], - 'role' => 'required|exists:roles,name', - 'expires_at' => 'nullable|date', - 'permissions' => 'array', - 'permissions.*' => 'string|exists:permissions,name', + 'name' => 'required|string|max:255', + 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user)], + 'role' => 'required|exists:roles,name', + 'expires_at' => 'nullable|date', + 'registration_status' => 'required|in:pending,approved,rejected', + 'is_active' => 'nullable|boolean', + 'subscription_plan_id' => 'nullable|exists:subscription_plans,id', + 'rejection_reason' => 'nullable|string|max:500', + 'permissions' => 'array', + 'permissions.*' => 'string|exists:permissions,name', ]); $user->update([ - 'name' => $request->name, - 'email' => $request->email, - 'expires_at' => $request->filled('expires_at') ? $request->date('expires_at') : null, + 'name' => $request->name, + 'email' => $request->email, + 'expires_at' => $request->filled('expires_at') ? $request->date('expires_at') : null, + 'registration_status' => $request->input('registration_status', 'approved'), + 'is_active' => $request->boolean('is_active'), + 'subscription_plan_id' => $request->input('subscription_plan_id'), + 'rejection_reason' => $request->input('rejection_reason'), ]); $user->syncRoles([$request->role]); // Assegna il nuovo ruolo all'utente + $this->ensureDomainProfiles($user, $request->role); $selectedPerms = collect($request->input('permissions', []))->unique()->values()->all(); $user->syncPermissions($selectedPerms); @@ -130,16 +152,114 @@ public function impersonate(User $user) $impersonator = Auth::user(); // Verifica che l'utente corrente possa impersonare e che l'utente target possa essere impersonato - if (!Gate::allows('impersonate', $impersonator)) { + if (! Gate::allows('impersonate', $impersonator)) { return back()->with('error', 'Non hai i permessi per impersonare utenti.'); } // Verifica se l'utente target può essere impersonato - if (!Gate::allows('canBeImpersonated', $user)) { + if (! Gate::allows('canBeImpersonated', $user)) { return back()->with('error', 'Questo utente non può essere impersonato.'); } $impersonator->impersonate($user); return redirect('/dashboard')->with('status', 'Ora stai impersonando ' . $user->name); } + + public function approve(Request $request, User $user): RedirectResponse + { + $request->validate([ + 'role' => 'nullable|exists:roles,name', + ]); + + if ($user->id === Auth::id()) { + return redirect()->route('superadmin.users.index')->with('error', 'Non puoi approvare te stesso.'); + } + + $role = $request->input('role', $user->requested_role ?: 'amministratore'); + + $requireEmailVerification = SystemSetting::bool('require_email_verification', true); + if ($requireEmailVerification && ! $user->hasVerifiedEmail()) { + return redirect()->route('superadmin.users.index')->with('error', 'Utente non approvabile: email non ancora verificata.'); + } + + $user->forceFill([ + 'registration_status' => 'approved', + 'is_active' => true, + 'approved_by_user_id' => Auth::id(), + 'approved_at' => now(), + 'rejected_at' => null, + 'rejection_reason' => null, + ])->save(); + + $user->syncRoles([$role]); + $this->ensureDomainProfiles($user, $role); + + return redirect()->route('superadmin.users.index')->with('success', 'Utente approvato con successo.'); + } + + public function reject(Request $request, User $user): RedirectResponse + { + $request->validate([ + 'reason' => 'nullable|string|max:500', + ]); + + if ($user->id === Auth::id()) { + return redirect()->route('superadmin.users.index')->with('error', 'Non puoi rifiutare te stesso.'); + } + + $user->forceFill([ + 'registration_status' => 'rejected', + 'is_active' => false, + 'rejected_at' => now(), + 'rejection_reason' => $request->input('reason'), + ])->save(); + + return redirect()->route('superadmin.users.index')->with('success', 'Richiesta registrazione rifiutata.'); + } + + public function toggleActive(User $user): RedirectResponse + { + if ($user->id === Auth::id()) { + return redirect()->route('superadmin.users.index')->with('error', 'Non puoi disattivare il tuo stesso account.'); + } + + $user->forceFill([ + 'is_active' => ! $user->is_active, + ])->save(); + + $status = $user->is_active ? 'attivato' : 'disattivato'; + + return redirect()->route('superadmin.users.index')->with('success', "Utente {$status} con successo."); + } + + public function assignPlan(Request $request, User $user): RedirectResponse + { + $request->validate([ + 'subscription_plan_id' => 'nullable|exists:subscription_plans,id', + ]); + + $user->forceFill([ + 'subscription_plan_id' => $request->input('subscription_plan_id'), + ])->save(); + + return redirect()->route('superadmin.users.index')->with('success', 'Piano assegnato correttamente.'); + } + + private function ensureDomainProfiles(User $user, string $role): void + { + if ($role === 'amministratore') { + Amministratore::firstOrCreate(['user_id' => $user->id], [ + 'nome' => $user->name, + 'cognome' => $user->name, + ]); + } + + if (in_array($role, ['condomino', 'inquilino'], true)) { + Soggetto::firstOrCreate(['email' => $user->email], [ + 'nome' => $user->name, + 'cognome' => $user->name, + 'tipo' => $role === 'condomino' ? 'proprietario' : 'inquilino', + ]); + } + } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index cea5291..d07db8b 100755 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -1,11 +1,8 @@ [ @@ -56,19 +53,19 @@ class Kernel extends HttpKernel * @var array */ protected $routeMiddleware = [ - 'auth' => \App\Http\Middleware\Authenticate::class, - 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, - 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, - 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, - 'can' => \Illuminate\Auth\Middleware\Authorize::class, - 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, // Ripristina il middleware guest predefinito di Laravel + 'auth' => \App\Http\Middleware\Authenticate::class, + 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, + 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, + 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, + 'can' => \Illuminate\Auth\Middleware\Authorize::class, + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, // Ripristina il middleware guest predefinito di Laravel 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, - 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, - 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, - 'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, - 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, - 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, - 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, - 'expired' => \App\Http\Middleware\ExpireUser::class, + 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, + 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, + 'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, + 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, + 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, + 'verified' => \App\Http\Middleware\EnsureEmailIsVerifiedForNetgescon::class, + 'expired' => \App\Http\Middleware\ExpireUser::class, ]; } diff --git a/app/Http/Middleware/EnsureEmailIsVerifiedForNetgescon.php b/app/Http/Middleware/EnsureEmailIsVerifiedForNetgescon.php new file mode 100644 index 0000000..94573d1 --- /dev/null +++ b/app/Http/Middleware/EnsureEmailIsVerifiedForNetgescon.php @@ -0,0 +1,24 @@ +user(); + if ($user && method_exists($user, 'hasAnyRole') && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) { + return $next($request); + } + + return parent::handle($request, $next, $redirectToRoute); + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index 2574642..fed800a 100755 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -1,7 +1,7 @@ ['required', 'string', 'email'], + 'email' => ['required', 'string', 'email'], 'password' => ['required', 'string'], + 'otp_code' => ['nullable', 'string'], ]; } @@ -49,6 +50,22 @@ public function authenticate(): void ]); } + $user = Auth::user(); + if ($user && method_exists($user, 'hasTwoFactorEnabled') && $user->hasTwoFactorEnabled()) { + $otpCode = trim((string) $this->input('otp_code', '')); + $totpSecret = $user->getTwoFactorSecretDecrypted(); + $totp = app(TotpService::class); + + if ($otpCode === '' || ! $totpSecret || ! $totp->verify($totpSecret, $otpCode)) { + Auth::logout(); + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'otp_code' => 'Codice autenticatore non valido.', + ]); + } + } + RateLimiter::clear($this->throttleKey()); } @@ -80,6 +97,6 @@ public function ensureIsNotRateLimited(): void */ public function throttleKey(): string { - return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); + return Str::transliterate(Str::lower($this->string('email')) . '|' . $this->ip()); } } diff --git a/app/Http/Responses/FortifyVerifyEmailViewResponse.php b/app/Http/Responses/FortifyVerifyEmailViewResponse.php new file mode 100644 index 0000000..238bdbf --- /dev/null +++ b/app/Http/Responses/FortifyVerifyEmailViewResponse.php @@ -0,0 +1,21 @@ +user()?->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false)); + } + + return view('auth.verify-email'); + } +} diff --git a/app/Models/CommunicationMessage.php b/app/Models/CommunicationMessage.php new file mode 100644 index 0000000..5d5d182 --- /dev/null +++ b/app/Models/CommunicationMessage.php @@ -0,0 +1,75 @@ + 'array', + 'received_at' => 'datetime', + 'metadata' => 'array', + ]; + + protected static function booted(): void + { + static::creating(function (self $message): void { + if (! empty($message->protocol_number)) { + return; + } + + $stamp = now()->format('YmdHis'); + $rand = strtoupper(bin2hex(random_bytes(2))); + $message->protocol_number = "COM-{$stamp}-{$rand}"; + }); + } + + public function ticket() + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } + + public function assignedUser() + { + return $this->belongsTo(User::class, 'assigned_user_id'); + } + + public function stabile() + { + return $this->belongsTo(Stabile::class, 'stabile_id'); + } + + public function postIt() + { + return $this->belongsTo(ChiamataPostIt::class, 'post_it_id'); + } + + public function insuranceClaim() + { + return $this->belongsTo(InsuranceClaim::class, 'insurance_claim_id'); + } +} diff --git a/app/Models/InsuranceClaim.php b/app/Models/InsuranceClaim.php new file mode 100644 index 0000000..36a8dfa --- /dev/null +++ b/app/Models/InsuranceClaim.php @@ -0,0 +1,36 @@ + 'datetime', + 'metadata' => 'array', + ]; + + public function ticket() + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } + + public function communicationMessages() + { + return $this->hasMany(CommunicationMessage::class, 'insurance_claim_id'); + } +} diff --git a/app/Models/PaymentMethod.php b/app/Models/PaymentMethod.php new file mode 100644 index 0000000..7bbd6a5 --- /dev/null +++ b/app/Models/PaymentMethod.php @@ -0,0 +1,22 @@ + 'boolean', + 'details' => 'array', + ]; +} diff --git a/app/Models/PbxClickToCallRequest.php b/app/Models/PbxClickToCallRequest.php new file mode 100644 index 0000000..64687d8 --- /dev/null +++ b/app/Models/PbxClickToCallRequest.php @@ -0,0 +1,30 @@ + 'datetime', + 'processed_at' => 'datetime', + 'metadata' => 'array', + ]; +} diff --git a/app/Models/SubscriptionPlan.php b/app/Models/SubscriptionPlan.php new file mode 100644 index 0000000..7f9cd14 --- /dev/null +++ b/app/Models/SubscriptionPlan.php @@ -0,0 +1,24 @@ + 'decimal:2', + 'is_active' => 'boolean', + 'features' => 'array', + ]; +} diff --git a/app/Models/SystemSetting.php b/app/Models/SystemSetting.php new file mode 100644 index 0000000..4751f0b --- /dev/null +++ b/app/Models/SystemSetting.php @@ -0,0 +1,40 @@ +where('key', $key)->value('value'); + + if ($setting === null) { + return $default; + } + + $decoded = json_decode($setting, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : $setting; + } + + public static function setValue(string $key, mixed $value): void + { + $storedValue = is_string($value) ? $value : json_encode($value); + + static::query()->updateOrCreate( + ['key' => $key], + ['value' => $storedValue] + ); + } + + public static function bool(string $key, bool $default = false): bool + { + return filter_var(static::getValue($key, $default), FILTER_VALIDATE_BOOL); + } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 71773d8..22f8f4a 100755 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -122,6 +122,16 @@ public function interventi() return $this->hasMany(TicketIntervento::class, 'ticket_id', 'id'); } + public function insuranceClaim() + { + return $this->hasOne(InsuranceClaim::class, 'ticket_id', 'id'); + } + + public function communicationMessages() + { + return $this->hasMany(CommunicationMessage::class, 'ticket_id', 'id'); + } + /** * Scope per ticket aperti */ diff --git a/app/Models/User.php b/app/Models/User.php index 22dbc21..95dbc33 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -1,37 +1,109 @@ 'datetime', - 'expires_at' => 'datetime', - 'password' => 'hashed' + 'email_verified_at' => 'datetime', + 'expires_at' => 'datetime', + 'is_active' => 'boolean', + 'approved_at' => 'datetime', + 'rejected_at' => 'datetime', + 'two_factor_confirmed_at' => 'datetime', + 'pbx_popup_enabled' => 'boolean', + 'pbx_click_to_call_enabled' => 'boolean', + 'password' => 'hashed', ]; } + public function hasTwoFactorEnabled(): bool + { + return ! empty($this->two_factor_secret) && $this->two_factor_confirmed_at !== null; + } + + public function getTwoFactorSecretDecrypted(): ?string + { + if (! $this->two_factor_secret) { + return null; + } + + try { + return Crypt::decryptString($this->two_factor_secret); + } catch (\Throwable) { + return null; + } + } + + public function getTwoFactorRecoveryCodes(): array + { + if (! $this->two_factor_recovery_codes) { + return []; + } + + try { + return json_decode(Crypt::decryptString($this->two_factor_recovery_codes), true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable) { + return []; + } + } + + public function subscriptionPlan(): BelongsTo + { + return $this->belongsTo(SubscriptionPlan::class, 'subscription_plan_id'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by_user_id'); + } + + public function isPendingApproval(): bool + { + return $this->registration_status === 'pending'; + } + /** * Ritorna true se l'utente è scaduto. */ @@ -73,7 +145,6 @@ public function stabiliAssegnati(): BelongsToMany * ha il permesso di impersonare altri. */ public function canImpersonate(): bool - { // Solo il Super-Admin può farlo. return $this->hasRole('super-admin'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0a8208b..2c2a3bb 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -26,6 +26,13 @@ public function register(): void return $connection->getSchemaBuilder(); }); + + if (interface_exists(\Laravel\Fortify\Contracts\VerifyEmailViewResponse::class)) { + $this->app->singleton( + \Laravel\Fortify\Contracts\VerifyEmailViewResponse::class, + \App\Http\Responses\FortifyVerifyEmailViewResponse::class + ); + } } /** diff --git a/app/Providers/Filament/AdminFilamentPanelProvider.php b/app/Providers/Filament/AdminFilamentPanelProvider.php index a6d3045..45d5aad 100644 --- a/app/Providers/Filament/AdminFilamentPanelProvider.php +++ b/app/Providers/Filament/AdminFilamentPanelProvider.php @@ -6,8 +6,9 @@ use App\Filament\Pages\Contabilita\PrimaNotaDettaglio; use App\Filament\Pages\Contabilita\PrimaNotaModifica; use App\Filament\Pages\Gescon\RubricaUniversaleArchivio; +use App\Filament\Pages\Supporto\SupportoDashboard; use App\Filament\Pages\Supporto\TicketMobile; -use App\Filament\Widgets\GoogleWorkspaceOverview; +use App\Filament\Widgets\GoogleScadenziarioOverview; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -53,42 +54,44 @@ public function panel(Panel $panel): Panel ->url(fn() => RubricaUniversaleArchivio::getUrl(panel: 'admin-filament')) ->visible(function () { $user = Auth::user(); + return $user instanceof \App\Models\User && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); }), - NavigationItem::make('Ticket Mobile') ->group('NetGescon') ->icon('heroicon-o-device-phone-mobile') ->url(fn() => TicketMobile::getUrl(panel: 'admin-filament')) ->visible(function () { $user = Auth::user(); + return $user instanceof \App\Models\User && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); }), - NavigationItem::make('Scheda Stabile') ->group('NetGescon') ->icon('heroicon-o-building-office-2') ->url(fn() => StabilePage::getUrl(panel: 'admin-filament')) ->visible(function () { $user = Auth::user(); + return $user instanceof \App\Models\User && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); }), ]) - ->discoverResources(in: app_path('Filament/Resources'), for : 'App\\Filament\\Resources') - ->discoverPages(in: app_path('Filament/Pages'), for : 'App\\Filament\\Pages') + ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') + ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') ->pages([ Dashboard::class, + SupportoDashboard::class, PrimaNotaDettaglio::class, PrimaNotaModifica::class, ContoMastrino::class, ]) - ->discoverWidgets(in: app_path('Filament/Widgets'), for : 'App\\Filament\\Widgets') + ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->widgets([ AccountWidget::class, - GoogleWorkspaceOverview::class, + GoogleScadenziarioOverview::class, ]) ->middleware([ EncryptCookies::class, diff --git a/app/Support/Security/TotpService.php b/app/Support/Security/TotpService.php new file mode 100644 index 0000000..79dbecc --- /dev/null +++ b/app/Support/Security/TotpService.php @@ -0,0 +1,87 @@ +totp($base32Secret, $timeSlice + $offset), $code)) { + return true; + } + } + + return false; + } + + public function buildOtpAuthUri(string $issuer, string $label, string $secret): string + { + $issuerEncoded = rawurlencode($issuer); + $labelEncoded = rawurlencode($label); + + return "otpauth://totp/{$issuerEncoded}:{$labelEncoded}?secret={$secret}&issuer={$issuerEncoded}&algorithm=SHA1&digits=6&period=30"; + } + + private function totp(string $base32Secret, int $timeSlice): string + { + $secret = $this->base32Decode($base32Secret); + if ($secret === '') { + return '000000'; + } + + $time = pack('N*', 0, $timeSlice); + $hash = hash_hmac('sha1', $time, $secret, true); + $offset = ord(substr($hash, -1)) & 0x0F; + $truncated = substr($hash, $offset, 4); + $value = unpack('N', $truncated)[1] & 0x7FFFFFFF; + + return str_pad((string) ($value % 1000000), 6, '0', STR_PAD_LEFT); + } + + private function base32Decode(string $input): string + { + $input = strtoupper(preg_replace('/[^A-Z2-7]/', '', $input) ?? ''); + if ($input === '') { + return ''; + } + + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $bits = ''; + + foreach (str_split($input) as $char) { + $index = strpos($alphabet, $char); + if ($index === false) { + continue; + } + $bits .= str_pad(decbin($index), 5, '0', STR_PAD_LEFT); + } + + $output = ''; + foreach (str_split($bits, 8) as $chunk) { + if (strlen($chunk) === 8) { + $output .= chr(bindec($chunk)); + } + } + + return $output; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index cad050d..e68fd08 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,7 +1,7 @@ withRouting( web: __DIR__ . '/../routes/web.php', + api: __DIR__ . '/../routes/api.php', commands: __DIR__ . '/../routes/console.php', health: '/up', ) @@ -53,6 +56,8 @@ ImportGesconF24LegacyCommand::class, NetgesconQaUnitaNominativiCommand::class, NetgesconDistributionPullCommand::class, + NetgesconPreupdateBackupCommand::class, + NetgesconRestoreRecordCommand::class, GoogleSyncRubricaContactsCommand::class, GooglePushRubricaContactsCommand::class, GoogleImportKeepNotesCommand::class, @@ -68,8 +73,27 @@ 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, + 'verified' => \App\Http\Middleware\EnsureEmailIsVerifiedForNetgescon::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { - // + $exceptions->report(function (\Throwable $e): void { + if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface && $e->getStatusCode() < 500) { + return; + } + + $record = [ + 'timestamp' => now()->toIso8601String(), + 'type' => 'runtime', + 'message' => $e->getMessage(), + 'context' => sprintf('%s:%d', $e->getFile(), $e->getLine()), + ]; + + $json = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (! is_string($json) || $json === '') { + return; + } + + @file_put_contents(storage_path('logs/runtime-errors.ndjson'), $json . PHP_EOL, FILE_APPEND); + }); })->create(); diff --git a/config/distribution.php b/config/distribution.php index 50322f8..ea02dba 100644 --- a/config/distribution.php +++ b/config/distribution.php @@ -23,4 +23,8 @@ // Timeout client per check/download. 'http_timeout_seconds' => (int) env('NETGESCON_UPDATE_HTTP_TIMEOUT', 60), + + // Override opzionale DNS per richieste update (formato CSV host:port:ip). + // Esempio: updates.netgescon.it:443:192.168.0.53 + 'http_resolve' => env('NETGESCON_UPDATE_RESOLVE', ''), ]; diff --git a/config/netgescon.php b/config/netgescon.php index 90b824b..673ebc0 100755 --- a/config/netgescon.php +++ b/config/netgescon.php @@ -1,7 +1,7 @@ env('NETGESCON_VERSION', '0.8.0'), + 'version' => env('NETGESCON_VERSION', '0.8.1'), 'ui' => [ 'force_filament' => env('NETGESCON_FORCE_FILAMENT', false), ], diff --git a/database/migrations/2026_03_12_170000_create_subscription_plans_table.php b/database/migrations/2026_03_12_170000_create_subscription_plans_table.php new file mode 100644 index 0000000..7f29eb7 --- /dev/null +++ b/database/migrations/2026_03_12_170000_create_subscription_plans_table.php @@ -0,0 +1,48 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->decimal('price_monthly', 10, 2)->default(0); + $table->boolean('is_active')->default(true); + $table->json('features')->nullable(); + $table->timestamps(); + }); + + DB::table('subscription_plans')->insert([ + [ + 'name' => 'Base', + 'slug' => 'base', + 'price_monthly' => 0, + 'is_active' => true, + 'features' => json_encode(['ticket', 'anagrafiche']), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'Pro', + 'slug' => 'pro', + 'price_monthly' => 49, + 'is_active' => true, + 'features' => json_encode(['ticket', 'anagrafiche', 'contabilita', 'google']), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + } + + public function down(): void + { + Schema::dropIfExists('subscription_plans'); + } +}; diff --git a/database/migrations/2026_03_12_170100_create_payment_methods_table.php b/database/migrations/2026_03_12_170100_create_payment_methods_table.php new file mode 100644 index 0000000..331c3b2 --- /dev/null +++ b/database/migrations/2026_03_12_170100_create_payment_methods_table.php @@ -0,0 +1,45 @@ +id(); + $table->string('name'); + $table->string('type')->default('manuale'); + $table->boolean('is_active')->default(true); + $table->json('details')->nullable(); + $table->timestamps(); + }); + + DB::table('payment_methods')->insert([ + [ + 'name' => 'Bonifico Bancario', + 'type' => 'bank_transfer', + 'is_active' => true, + 'details' => json_encode(['manual_verification' => true]), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'Carta di Credito', + 'type' => 'card', + 'is_active' => false, + 'details' => json_encode(['provider' => 'pending']), + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + } + + public function down(): void + { + Schema::dropIfExists('payment_methods'); + } +}; diff --git a/database/migrations/2026_03_12_170200_create_system_settings_table.php b/database/migrations/2026_03_12_170200_create_system_settings_table.php new file mode 100644 index 0000000..c29bff1 --- /dev/null +++ b/database/migrations/2026_03_12_170200_create_system_settings_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('key')->unique(); + $table->longText('value')->nullable(); + $table->timestamps(); + }); + + DB::table('system_settings')->insert([ + ['key' => 'registration_enabled', 'value' => 'true', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'require_email_verification', 'value' => 'true', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'recaptcha_version', 'value' => 'none', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'recaptcha_site_key', 'value' => '', 'created_at' => now(), 'updated_at' => now()], + ['key' => 'recaptcha_secret_key', 'value' => '', 'created_at' => now(), 'updated_at' => now()], + ]); + } + + public function down(): void + { + Schema::dropIfExists('system_settings'); + } +}; diff --git a/database/migrations/2026_03_12_170300_add_access_governance_fields_to_users_table.php b/database/migrations/2026_03_12_170300_add_access_governance_fields_to_users_table.php new file mode 100644 index 0000000..e0f8f75 --- /dev/null +++ b/database/migrations/2026_03_12_170300_add_access_governance_fields_to_users_table.php @@ -0,0 +1,38 @@ +boolean('is_active')->default(true)->after('remember_token'); + $table->string('registration_status', 32)->default('approved')->after('is_active'); + $table->string('requested_role', 64)->nullable()->after('registration_status'); + $table->foreignId('subscription_plan_id')->nullable()->after('requested_role')->constrained('subscription_plans')->nullOnDelete(); + $table->foreignId('approved_by_user_id')->nullable()->after('subscription_plan_id')->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable()->after('approved_by_user_id'); + $table->timestamp('rejected_at')->nullable()->after('approved_at'); + $table->string('rejection_reason', 500)->nullable()->after('rejected_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropConstrainedForeignId('approved_by_user_id'); + $table->dropConstrainedForeignId('subscription_plan_id'); + $table->dropColumn([ + 'is_active', + 'registration_status', + 'requested_role', + 'approved_at', + 'rejected_at', + 'rejection_reason', + ]); + }); + } +}; diff --git a/database/migrations/2026_03_12_180000_add_two_factor_fields_to_users_table.php b/database/migrations/2026_03_12_180000_add_two_factor_fields_to_users_table.php new file mode 100644 index 0000000..9eea41c --- /dev/null +++ b/database/migrations/2026_03_12_180000_add_two_factor_fields_to_users_table.php @@ -0,0 +1,38 @@ +text('two_factor_secret')->nullable()->after('password'); + } + if (! Schema::hasColumn('users', 'two_factor_recovery_codes')) { + $table->text('two_factor_recovery_codes')->nullable()->after('two_factor_secret'); + } + if (! Schema::hasColumn('users', 'two_factor_confirmed_at')) { + $table->timestamp('two_factor_confirmed_at')->nullable()->after('two_factor_recovery_codes'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + if (Schema::hasColumn('users', 'two_factor_confirmed_at')) { + $table->dropColumn('two_factor_confirmed_at'); + } + if (Schema::hasColumn('users', 'two_factor_recovery_codes')) { + $table->dropColumn('two_factor_recovery_codes'); + } + if (Schema::hasColumn('users', 'two_factor_secret')) { + $table->dropColumn('two_factor_secret'); + } + }); + } +}; diff --git a/database/migrations/2026_03_12_181000_create_insurance_claims_table.php b/database/migrations/2026_03_12_181000_create_insurance_claims_table.php new file mode 100644 index 0000000..fba8428 --- /dev/null +++ b/database/migrations/2026_03_12_181000_create_insurance_claims_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('stabile_id')->nullable()->constrained('stabili')->nullOnDelete(); + $table->string('policy_reference')->nullable(); + $table->string('claim_number')->nullable(); + $table->string('status', 32)->default('bozza'); + $table->timestamp('opened_at')->nullable(); + $table->text('notes')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['ticket_id', 'status']); + $table->index(['claim_number']); + }); + } + + public function down(): void + { + Schema::dropIfExists('insurance_claims'); + } +}; diff --git a/database/migrations/2026_03_12_181100_create_communication_messages_table.php b/database/migrations/2026_03_12_181100_create_communication_messages_table.php new file mode 100644 index 0000000..fcfb2d8 --- /dev/null +++ b/database/migrations/2026_03_12_181100_create_communication_messages_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('channel', 32); + $table->string('direction', 16)->default('inbound'); + $table->string('protocol_number', 40)->unique(); + $table->string('external_chat_id')->nullable(); + $table->string('external_message_id')->nullable(); + $table->string('phone_number', 32)->nullable(); + $table->string('sender_name')->nullable(); + $table->longText('message_text')->nullable(); + $table->json('attachments')->nullable(); + $table->foreignId('ticket_id')->nullable()->constrained('tickets')->nullOnDelete(); + $table->foreignId('post_it_id')->nullable()->constrained('chiamate_post_it')->nullOnDelete(); + $table->foreignId('insurance_claim_id')->nullable()->constrained('insurance_claims')->nullOnDelete(); + $table->string('status', 32)->default('received'); + $table->timestamp('received_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['channel', 'direction']); + $table->index(['ticket_id', 'created_at']); + $table->index(['post_it_id', 'created_at']); + $table->index(['phone_number']); + $table->index(['external_message_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('communication_messages'); + } +}; diff --git a/database/migrations/2026_03_12_181200_seed_messaging_system_settings.php b/database/migrations/2026_03_12_181200_seed_messaging_system_settings.php new file mode 100644 index 0000000..30cd3c4 --- /dev/null +++ b/database/migrations/2026_03_12_181200_seed_messaging_system_settings.php @@ -0,0 +1,40 @@ + 'telegram_bot_token', 'value' => ''], + ['key' => 'telegram_webhook_token', 'value' => ''], + ['key' => 'whatsapp_phone_number_id', 'value' => ''], + ['key' => 'whatsapp_business_account_id', 'value' => ''], + ['key' => 'whatsapp_access_token', 'value' => ''], + ['key' => 'whatsapp_webhook_token', 'value' => ''], + ]; + + foreach ($rows as $row) { + DB::table('system_settings')->updateOrInsert( + ['key' => $row['key']], + ['value' => $row['value'], 'updated_at' => now(), 'created_at' => now()] + ); + } + } + + public function down(): void + { + DB::table('system_settings') + ->whereIn('key', [ + 'telegram_bot_token', + 'telegram_webhook_token', + 'whatsapp_phone_number_id', + 'whatsapp_business_account_id', + 'whatsapp_access_token', + 'whatsapp_webhook_token', + ]) + ->delete(); + } +}; diff --git a/database/migrations/2026_03_12_235500_add_pbx_fields_to_users_and_communication_messages.php b/database/migrations/2026_03_12_235500_add_pbx_fields_to_users_and_communication_messages.php new file mode 100644 index 0000000..330af89 --- /dev/null +++ b/database/migrations/2026_03_12_235500_add_pbx_fields_to_users_and_communication_messages.php @@ -0,0 +1,73 @@ +string('pbx_extension', 20)->nullable()->after('two_factor_confirmed_at'); + } + + if (! Schema::hasColumn('users', 'pbx_popup_enabled')) { + $table->boolean('pbx_popup_enabled')->default(false)->after('pbx_extension'); + } + + if (! Schema::hasColumn('users', 'pbx_click_to_call_enabled')) { + $table->boolean('pbx_click_to_call_enabled')->default(false)->after('pbx_popup_enabled'); + } + }); + + Schema::table('communication_messages', function (Blueprint $table): void { + if (! Schema::hasColumn('communication_messages', 'target_extension')) { + $table->string('target_extension', 20)->nullable()->after('phone_number'); + } + + if (! Schema::hasColumn('communication_messages', 'assigned_user_id')) { + $table->foreignId('assigned_user_id')->nullable()->after('target_extension')->constrained('users')->nullOnDelete(); + $table->index(['assigned_user_id', 'created_at'], 'comm_msg_assigned_user_created_idx'); + } + + if (! Schema::hasColumn('communication_messages', 'stabile_id')) { + $table->foreignId('stabile_id')->nullable()->after('assigned_user_id')->constrained('stabili')->nullOnDelete(); + $table->index(['stabile_id', 'created_at'], 'comm_msg_stabile_created_idx'); + } + }); + } + + public function down(): void + { + Schema::table('communication_messages', function (Blueprint $table): void { + if (Schema::hasColumn('communication_messages', 'stabile_id')) { + $table->dropConstrainedForeignId('stabile_id'); + $table->dropIndex('comm_msg_stabile_created_idx'); + } + + if (Schema::hasColumn('communication_messages', 'assigned_user_id')) { + $table->dropConstrainedForeignId('assigned_user_id'); + $table->dropIndex('comm_msg_assigned_user_created_idx'); + } + + if (Schema::hasColumn('communication_messages', 'target_extension')) { + $table->dropColumn('target_extension'); + } + }); + + Schema::table('users', function (Blueprint $table): void { + $drop = []; + foreach (['pbx_extension', 'pbx_popup_enabled', 'pbx_click_to_call_enabled'] as $col) { + if (Schema::hasColumn('users', $col)) { + $drop[] = $col; + } + } + + if ($drop !== []) { + $table->dropColumn($drop); + } + }); + } +}; diff --git a/database/migrations/2026_03_12_235600_create_pbx_click_to_call_requests_table.php b/database/migrations/2026_03_12_235600_create_pbx_click_to_call_requests_table.php new file mode 100644 index 0000000..802f6ed --- /dev/null +++ b/database/migrations/2026_03_12_235600_create_pbx_click_to_call_requests_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('requested_by_user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('assigned_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('stabile_id')->nullable()->constrained('stabili')->nullOnDelete(); + $table->foreignId('communication_message_id')->nullable()->constrained('communication_messages')->nullOnDelete(); + $table->string('source_extension', 20); + $table->string('target_number', 40); + $table->string('status', 20)->default('pending'); + $table->text('note')->nullable(); + $table->timestamp('requested_at')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['status', 'requested_at']); + $table->index(['source_extension', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pbx_click_to_call_requests'); + } +}; diff --git a/docs/CTI-NS1000-CHECKLIST.md b/docs/CTI-NS1000-CHECKLIST.md new file mode 100644 index 0000000..0c39561 --- /dev/null +++ b/docs/CTI-NS1000-CHECKLIST.md @@ -0,0 +1,55 @@ +# CTI Panasonic NS1000 - Checklist Operativa + +## 1) Endpoint NetGescon da usare + +- `POST /api/v1/cti/panasonic/incoming` +- `POST /api/v1/cti/panasonic/call-ended` +- `GET /api/v1/cti/panasonic/lookup?phone=...` + +Esempio base: + +- `https://staging.netgescon.it/api/v1/cti/panasonic/incoming` + +## 2) Cosa controllare quando non arrivano chiamate + +- Rete: il centralino deve raggiungere HTTPS 443 del server NetGescon. +- DNS: host configurato nel PBX deve risolvere l'IP corretto. +- Certificato TLS: il PBX non deve rifiutare il certificato del dominio. +- Token CTI: deve combaciare con il token lato NetGescon. +- Clock/NTP: orario PBX corretto (evita problemi firma/token e log). +- Firewall: apertura uscita dal PBX verso internet/DMZ su 443. + +## 3) Porta 33333 / servizi monitoraggio + +- La porta `33333` su NS1000 viene spesso usata per stream eventi/integrazioni CTI legacy. +- Se usata, configurare IP sorgente autorizzati e ACL precise (mai aperta pubblicamente). +- Preferire webhook HTTPS verso NetGescon come canale primario e 33333/syslog come canale diagnostico. + +## 4) Syslog NS1000 (consigliato) + +- Abilitare invio syslog remoto dal PBX verso un collector interno. +- Livello consigliato: eventi chiamata + errori integrazione. +- Conservare almeno 7-14 giorni per analisi incidenti. +- Correlare timestamp syslog con log applicativi NetGescon. + +## 5) Configurazione web NS1000 (traccia operativa) + +1. Accedi alla Web Maintenance Console. +2. Vai su sezione integrazioni CTI/CSTA e abilita notifica eventi chiamata. +3. Imposta URL callback HTTPS di NetGescon per eventi incoming/call-ended. +4. Inserisci token condiviso (stesso valore lato NetGescon). +5. In sezione sicurezza/rete limita destinazioni consentite al solo host NetGescon. +6. In sezione log abilita syslog remoto (se disponibile) verso collector interno. +7. Salva e riavvia il servizio CTI (o applica configurazione runtime). + +## 6) Test rapido post-configurazione + +1. Effettua una chiamata di test entrante sul centralino. +2. Verifica che sia creato/aggiornato il Post-it chiamata in NetGescon. +3. Chiudi la chiamata e verifica aggiornamento su endpoint `call-ended`. +4. Controlla log applicativi e, se attivo, log syslog del PBX. + +## 7) Opzione interno VOIP monitor + +- Si puo configurare un interno tecnico di monitoraggio per ascolto/diagnostica, rispettando policy privacy. +- Usarlo solo per troubleshooting, con accesso limitato e audit. diff --git a/docs/SMDR-INTEGRAZIONE-OPERATIVA.md b/docs/SMDR-INTEGRAZIONE-OPERATIVA.md new file mode 100644 index 0000000..ab8091d --- /dev/null +++ b/docs/SMDR-INTEGRAZIONE-OPERATIVA.md @@ -0,0 +1,53 @@ +# SMDR Panasonic NS1000 - Integrazione Operativa + +## Stato attuale + +- Listener attivo via comando Laravel `smdr:listen`. +- Ingestione su: + - `communication_messages` (`channel = smdr`) + - `chiamate_post_it` (record operativi) + +## Comando base + +```bash +php artisan smdr:listen \ + --host=192.168.0.101 \ + --port=2300 \ + --user=SMDR \ + --pass=PCCSMDR \ + --write-postit=1 \ + --write-communications=1 \ + --reconnect=1 \ + --reconnect-delay=5 +``` + +## Opzioni utili + +- `--no-auth`: se il centralino non richiede credenziali su socket. +- `--max-lines=50`: test rapido su N righe e stop. +- `--timeout=30`: timeout lettura socket. + +## Verifica dati acquisiti + +```bash +php artisan tinker --execute="echo App\\Models\\CommunicationMessage::where('channel','smdr')->count();" +php artisan tinker --execute="echo App\\Models\\ChiamataPostIt::count();" +``` + +## URL operativi correlati + +- Fornitore: `/fornitore/tickets` +- Mobile admin: `/admin/mobile` +- Mobile tickets: `/admin/tickets-mobile` + +## Note parsing SMDR + +- Le righe vengono salvate raw per audit. +- Esempio formato rilevato: + - `03/02/26 15:22 206 03 3333395405 00:00'03 EU00000.00` + - `03/02/26 17:03 201 03 3478160508 00:00'59 EU00000.00` +- `\u003cI\u003e` indica tipicamente ingresso; numeri esterni senza `\u003cI\u003e` tipicamente uscita; `EXTxxx` chiamata interna. + +## Prossimo step consigliato + +- Mappare parser SMDR su campi strutturati (interno, trunk, numero, durata, costo, direzione) e dashboard live chiamate in ingresso/uscita. diff --git a/docs/support/ONLINE-ERROR-SYNC.md b/docs/support/ONLINE-ERROR-SYNC.md new file mode 100644 index 0000000..ba62905 --- /dev/null +++ b/docs/support/ONLINE-ERROR-SYNC.md @@ -0,0 +1,41 @@ +# Online Error Sync (Git) + +Obiettivo: vedere in `supporto/modifiche` anche errori prodotti in ambiente online/staging. + +## Flusso consigliato + +1. Sul server online/staging, nella root progetto, eseguire: + +```bash +bash scripts/ops/netgescon-export-online-errors-to-git.sh +``` + +1. Commit/push del file esportato: + +```bash +git add docs/support/online-runtime-errors.ndjson +git commit -m "chore: export online runtime errors" +git push +``` + +1. In ambiente sviluppo, fare pull e aprire: + +- `admin-filament/supporto/modifiche` +- TAB `Errori` + +Gli errori online compariranno con origine `online-runtime-errors.git`. + +## File coinvolti + +- Export script: `scripts/ops/netgescon-export-online-errors-to-git.sh` +- Feed Git online: `docs/support/online-runtime-errors.ndjson` +- Dashboard supporto: `app/Filament/Pages/Supporto/Modifiche.php` + +## Nota + +Se il feed non compare, usare in sviluppo: + +```bash +php artisan optimize:clear +php artisan view:cache +``` diff --git a/resources/views/admin/tickets/show.blade.php b/resources/views/admin/tickets/show.blade.php index dbae62d..3ad1699 100755 --- a/resources/views/admin/tickets/show.blade.php +++ b/resources/views/admin/tickets/show.blade.php @@ -351,6 +351,40 @@ class="bg-light0 hover:bg-gray-700 text-white fw-bold py-2 px-3 rounded"> @endif + +
+

Assicurazione e Sinistro

+ +
+ @csrf +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + @if($ticket->insuranceClaim) +
+

Stato: {{ $ticket->insuranceClaim->status }}

+

Aperto il: {{ optional($ticket->insuranceClaim->opened_at)->format('d/m/Y H:i') ?: '-' }}

+

Da questo momento puoi associare comunicazioni email/Telegram/WhatsApp al ticket e mantenerle nel protocollo.

+
+ @endif +
+ diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 80e1b39..ae13497 100755 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -24,6 +24,18 @@ +
+ + + +
+
- -
- - -

Obbligatorio se stai registrando un Amministratore.

- -
-
@@ -56,9 +54,38 @@ -

Condomino/Inquilino accedono direttamente all'apertura ticket; Amministratore/Collaboratore entrano in area gestionale.

+

L'account resta in attesa di approvazione super-admin prima del primo accesso.

+ @if(($recaptchaVersion ?? 'none') === 'v2' && !empty($recaptchaSiteKey)) +
+
+ +
+ + @endif + + @if(($recaptchaVersion ?? 'none') === 'v3' && !empty($recaptchaSiteKey)) + + + + + @endif + + +

+ Dopo la registrazione riceverai una email di verifica e l'accesso verra abilitato solo dopo approvazione. +

diff --git a/resources/views/filament/pages/gescon/fornitore-scheda.blade.php b/resources/views/filament/pages/gescon/fornitore-scheda.blade.php index 2cf34f8..49408f8 100644 --- a/resources/views/filament/pages/gescon/fornitore-scheda.blade.php +++ b/resources/views/filament/pages/gescon/fornitore-scheda.blade.php @@ -43,6 +43,78 @@ +
+
Dipendenti e accessi fornitore
+
La gestione accessi dei dipendenti fornitore e centralizzata qui.
+ + @if(filled($lastGeneratedPassword ?? null)) +
+ Ultima password temporanea generata: {{ $lastGeneratedPassword }} +
+ @endif + +
+ + + + +
+
+ Aggiungi dipendente +
+ +
+ + + + + + + + + + + + + @forelse($dipendentiRows as $row) + + + + + + + + + @empty + + + + @endforelse + +
DipendenteEmailTelefonoUtente collegatoStatoAzioni
{{ $row['nome'] }}{{ $row['email'] !== '' ? $row['email'] : '-' }}{{ $row['telefono'] !== '' ? $row['telefono'] : '-' }} + @if((int) ($row['user_id'] ?? 0) > 0) + {{ $row['user_label'] }} (ID {{ (int) $row['user_id'] }}) + @else + - + @endif + + @if($row['attivo']) + attivo + @else + non attivo + @endif + +
+ Abilita accesso + @if((int) ($row['user_id'] ?? 0) > 0) + Reset password + @endif + Toggle attivo +
+
Nessun dipendente registrato per questo fornitore.
+
+
+
Pagamenti
diff --git a/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php b/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php index e4afda0..7689c5e 100644 --- a/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php +++ b/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php @@ -285,46 +285,136 @@
- - Collegamenti - -
- - -
-
Stabili (ambiente attivo)
- @if(count($stabili) > 0) - - @else -
- @endif -
+
+
+ + +
- +
+ + @if($sideTab === 'collegamenti') + + Collegamenti + +
+
+
Fornitori (ambiente attivo)
+ @if(count($fornitori) > 0) + + @else +
+ @endif +
+ +
+
Stabili (ambiente attivo)
+ @if(count($stabili) > 0) + + @else +
+ @endif +
+
+
+ @endif + + @if($sideTab === 'dipendenti') + + Collega o crea dipendenti fornitore + Usa questa tab per agganciare un nominativo rubrica come dipendente o creare un nuovo dipendente manualmente. + +
+ @forelse($fornitoriCollegati as $f) +
+
{{ $f['nome'] }}
+
Fornitore #{{ $f['id'] }} · Rubrica collegata: {{ $f['rubrica_id'] ?: '—' }}
+ +
+
+ + Collega da rubrica +
+ +
+ + + + +
+
+ Crea dipendente +
+
+
+ @empty +
Nessun fornitore collegato a questo nominativo rubrica.
+ @endforelse +
+
+ @endif + + @if($sideTab === 'autorizzazioni') + + Ruoli e autorizzazioni dipendenti + Abilita accesso utente e assegna interno PBX per i dipendenti collegati ai fornitori. + +
+ + + + + + + + + + + + + @forelse($dipendentiFornitoreRows as $row) + + + + + + + + + @empty + + + + @endforelse + +
FornitoreDipendenteEmailRuoli utenteInterno PBXAzioni
{{ $row['fornitore_nome'] }}{{ $row['nome'] }}{{ $row['email'] !== '' ? $row['email'] : '-' }}{{ count($row['ruoli']) > 0 ? implode(', ', $row['ruoli']) : '-' }} + + + @if(! $row['user_id']) + Abilita accesso + @endif + Salva interno +
Nessun dipendente fornitore collegato.
+
+
+ @endif Azioni diff --git a/resources/views/filament/pages/gescon/table.blade.php b/resources/views/filament/pages/gescon/table.blade.php index 07b8d18..0a38668 100644 --- a/resources/views/filament/pages/gescon/table.blade.php +++ b/resources/views/filament/pages/gescon/table.blade.php @@ -1,21 +1,40 @@ +@php + $hasFornitoriUi = method_exists($this, 'apriElenco') + && method_exists($this, 'apriScheda') + && method_exists($this, 'getFornitoreLabel'); +@endphp + +@if(! $hasFornitoriUi) + + {{ $this->table }} + +@else -
+
+
@@ -28,20 +47,62 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $a
- @if($activeTab === 'elenco') -
- - +
+ +
+
+ +
+ + @if(trim((string) ($this->fornitoriSearch ?? '')) !== '') + + @endif +
+
+ + @if(count($this->activeSearchFilters) > 0) +
+ @foreach($this->activeSearchFilters as $token) + + {{ $token }} + + + @endforeach +
+ @endif
- @endif +
- @if($activeTab === 'elenco') + @if($this->activeTab === 'elenco')
@@ -57,7 +118,7 @@ class="mt-1 w-full rounded-lg border-gray-300 text-sm" @forelse($this->fornitoriRows as $f) - + @@ -98,16 +159,112 @@ class="mt-1 w-full rounded-lg border-gray-300 text-sm"
Mostrati max 400 record per mantenere la pagina rapida.
- @else + @elseif($this->activeTab === 'scheda') @php($fornitore = $this->selectedFornitore) -
+
@if($fornitore)
Scheda fornitore inline: {{ $this->getFornitoreLabel($fornitore) }}
ID #{{ (int) $fornitore->id }} · Dipendenti collegati: {{ (int) ($fornitore->dipendenti_count ?? 0) }}
- Apri vecchia scheda (immutata) +
+ + + Apri vecchia scheda (immutata) +
+
+ +
+
+
Anagrafica fornitore
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
Indirizzo: {{ $fornitore->indirizzo_completo ?: '-' }}
+
+ +
+ +
+
Contatti e pagamenti
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
IBAN: {{ $fornitore->iban ?: '-' }}
+
BIC: {{ $fornitore->bic ?: '-' }}
+
Intestazione conto: {{ $fornitore->intestazione_cc_esatta ?: '-' }}
+
Modalita pagamento: {{ $fornitore->modalita_pagamento_predefinita ?: '-' }}
+
+ + +
+
Contatti rubrica collegata
+ @if($fornitore->rubrica) +
+
Codice rubrica: {{ $fornitore->rubrica->codice_univoco ?: '-' }}
+
Email rubrica: {{ $fornitore->rubrica->email ?: '-' }}
+
PEC rubrica: {{ $fornitore->rubrica->pec ?: '-' }}
+
Tel. ufficio: {{ $fornitore->rubrica->telefono_ufficio ?: '-' }}
+
Tel. cellulare: {{ $fornitore->rubrica->telefono_cellulare ?: '-' }}
+
+ @else +
Nessuna rubrica collegata.
+ @endif +
+
@@ -160,12 +317,215 @@ class="mt-1 w-full rounded-lg border-gray-300 text-sm"
+ +
+
+
Aggiungi dipendente rapido (stessa scheda)
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
Dipendenti collegati
+
+
{{ $this->getFornitoreLabel($f) }}
#{{ (int) $f->id }}
@@ -83,7 +144,7 @@ class="mt-1 w-full rounded-lg border-gray-300 text-sm"
- + Apri HUB
+ + + + + + + + @forelse($this->dipendentiRows as $d) + + + + + @empty + + @endforelse + +
NominativoContatti
{{ trim(($d->nome ?? '') . ' ' . ($d->cognome ?? '')) ?: '-' }}{{ $d->email ?: '-' }}
{{ $d->telefono ?: '-' }}
Nessun dipendente collegato.
+
+
+
+ @else +
+
+
Seleziona prima un fornitore dalla Tab 1 e usa il pulsante "Apri scheda inline".
+
+ +
+
+ +
+
Nuovo fornitore rapido
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ @endif +
+ @else + @php($fornitore = $this->selectedFornitore) +
+ @if($fornitore) +
+
+
Dipendenti collegati: {{ $this->getFornitoreLabel($fornitore) }}
+
Nominativi rubrica agganciati al fornitore (modello come stabile -> condomini/inquilini).
+
+ +
+ +
+
+
Dipendenti gia collegati
+
+ + + + + + + + + + @forelse($this->dipendentiRows as $d) + + + + + + @empty + + + + @endforelse + +
NominativoContattiStato
+
{{ trim(($d->nome ?? '') . ' ' . ($d->cognome ?? '')) ?: '-' }}
+
#{{ (int) $d->id }}
+
+
{{ $d->email ?: '-' }}
+
{{ $d->telefono ?: '-' }}
+
+ @if((bool) ($d->attivo ?? false)) + Attivo + @else + Disattivo + @endif +
Nessun dipendente collegato.
+
+
+ +
+
Rubrica nominativi da agganciare
+
+ +
+ +
+ + + + + + + + + + @forelse($this->rubricaDipendentiCandidates as $r) + + + + + + @empty + + + + @endforelse + +
RubricaContattiAzione
+
{{ trim(($r->nome ?? '') . ' ' . ($r->cognome ?? '')) ?: ($r->ragione_sociale ?: '-') }}
+
Rubrica #{{ (int) $r->id }} · CF {{ $r->codice_fiscale ?: '-' }}
+
+
{{ $r->email ?: '-' }}
+
{{ $r->telefono_cellulare ?: ($r->telefono_ufficio ?: '-') }}
+
+ +
Nessun nominativo rubrica trovato con i filtri correnti.
+
+
+
@else
- Seleziona prima un fornitore dalla Tab 1 e usa il pulsante "Apri scheda inline". +
Per usare la Tab 3 seleziona prima un fornitore dalla Tab 1.
+
+ +
@endif
@endif
+@endif diff --git a/resources/views/filament/pages/impostazioni/partials/scheda-amministratore-operativita-accessi.blade.php b/resources/views/filament/pages/impostazioni/partials/scheda-amministratore-operativita-accessi.blade.php new file mode 100644 index 0000000..2a8a5e0 --- /dev/null +++ b/resources/views/filament/pages/impostazioni/partials/scheda-amministratore-operativita-accessi.blade.php @@ -0,0 +1,155 @@ +
+
+
Centralino studio e interni collaboratori
+
I numeri studio sono salvati nella scheda amministratore. Gli interni PBX dei collaboratori sono usati per instradamento SMDR/post-it.
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + @forelse($this->collaboratoriCentralinoRows as $row) + + + + + + + + + @empty + + + + @endforelse + +
CollaboratoreEmailRuoliInterno PBXFlags PBXAzioni
{{ $row['name'] }}{{ $row['email'] }}{{ count($row['ruoli']) > 0 ? implode(', ', $row['ruoli']) : '-' }} + + + popup={{ $row['pbx_popup_enabled'] ? 'on' : 'off' }} · click2call={{ $row['pbx_click_to_call_enabled'] ? 'on' : 'off' }} + + Salva interno +
Nessun collaboratore assegnato agli stabili di questo amministratore.
+
+
+ +
+
Operazioni distribuzione, backup e sincronizzazione
+
+ Aggiorna produzione + Crea pacchetto aggiornamento + Scarica ultimo pacchetto + Controlla nuovi utenti + Backup produzione + Backup incrementale dati su Drive + Verifica Google Drive + Restore dati non distruttivo + Sync aggiornamenti + archivi + Collega Google + Scollega Google +
+ + @if(filled($this->lastUpdatePackageName)) +
+ Ultimo pacchetto aggiornamento: {{ $this->lastUpdatePackageName }} +
+ @endif + + @if(filled($this->opsLastOutput)) +
+
Output operazioni
+
{{ $this->opsLastOutput }}
+
+ @endif +
+ +
+
Nuovi utenti da abilitare (risultato controllo)
+
+ + + + + + + + + + + + + @forelse($this->pendingUsersReviewRows as $row) + + + + + + + + + @empty + + + + @endforelse + +
IDEmailNomeRuoliFlagsCreato
{{ $row['id'] }}{{ $row['email'] }}{{ $row['name'] }}{{ $row['roles'] }}{{ $row['flags'] }}{{ $row['created'] }}
Nessun dato disponibile. Usa il pulsante "Controlla nuovi utenti".
+
+
+ +
+
Gestione accessi gruppo amministratore
+
Reset password temporanee per utenti del gruppo amministratore.
+ + @if(filled($this->lastGeneratedPassword)) +
+ Ultima password temporanea generata: {{ $this->lastGeneratedPassword }} +
+ @endif + +
+
Utenti gruppo amministratore
+ + + + + + + + + + + @forelse($this->accessoAmministratoreRows as $row) + + + + + + + @empty + + + + @endforelse + +
NomeEmailRuoliAzioni
{{ $row['name'] }}{{ $row['email'] }}{{ count($row['ruoli']) > 0 ? implode(', ', $row['ruoli']) : '-' }} + Reset password +
Nessun utente amministratore trovato.
+
+
+
diff --git a/resources/views/filament/pages/impostazioni/scheda-amministratore.blade.php b/resources/views/filament/pages/impostazioni/scheda-amministratore.blade.php index ff2c74d..9678b73 100644 --- a/resources/views/filament/pages/impostazioni/scheda-amministratore.blade.php +++ b/resources/views/filament/pages/impostazioni/scheda-amministratore.blade.php @@ -4,52 +4,6 @@
Salva - - Aggiorna produzione - - - Crea pacchetto aggiornamento - - - Scarica ultimo pacchetto - - - Controlla nuovi utenti - - - Backup produzione - - - Backup incrementale dati su Drive - - - Verifica Google Drive - - - Restore dati non distruttivo - - - Sync aggiornamenti + archivi - - - Collega Google - - - Scollega Google -
- - @if(filled($this->opsLastOutput)) -
-
Output operazioni
-
{{ $this->opsLastOutput }}
-
- @endif - - @if(filled($this->lastUpdatePackageName)) -
- Ultimo pacchetto aggiornamento: {{ $this->lastUpdatePackageName }} -
- @endif 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 1d9230c..2838b89 100644 --- a/resources/views/filament/pages/strumenti/post-it-gestione.blade.php +++ b/resources/views/filament/pages/strumenti/post-it-gestione.blade.php @@ -10,11 +10,23 @@
+
+
+ + +
+
+ @if(! $this->isPostItTableReady())
Tabella chiamate non pronta. Esegui le migrazioni per usare la gestione Post-it.
@else + @if($activeTab === 'storico')

Storico chiamate recenti

@@ -28,6 +40,14 @@ {{ $riga->nome_chiamante ?: 'Chiamante non identificato' }} @if($riga->telefono) - {{ $riga->telefono }} + @php($rubricaNome = $this->getRubricaNomeByPhone((string) $riga->telefono)) + @if($rubricaNome) + - {{ $rubricaNome }} + @endif + @php($rubricaUrl = $this->getRubricaUrlByPhone((string) $riga->telefono)) + @if($rubricaUrl) + - Apri scheda rubrica + @endif @endif @if($riga->stabile) - {{ $riga->stabile->denominazione ?: ('Stabile #' . $riga->stabile->id) }} @@ -73,6 +93,74 @@ @endforelse
+ @else +
+

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.

+ +
+ + + + + + + + + + + + + + + + + + + + + @forelse($this->chiamateTecniche as $m) + @php($smdr = (array) data_get($m->metadata, 'smdr', [])) + @php($cost = trim((string) (($smdr['cost_currency'] ?? '') . ' ' . ($smdr['cost_amount'] ?? '')))) + @php($duration = (string) (($smdr['duration_raw'] ?? '') ?: ($smdr['ring_duration_raw'] ?? ''))) + + + + + + + + @php($phoneValue = (string) ($m->phone_number ?: ($smdr['dial_number'] ?? ''))) + @php($rubricaNomeTech = $this->getRubricaNomeByPhone($phoneValue)) + + + + + + + + + + @empty + + + + @endforelse + +
IDData/OraDirezioneInternoLinea/CONumeroNominativoDurataCostoUtenteStabilePost-itRawAzioni
{{ $m->id }}{{ optional($m->received_at)->format('d/m/Y H:i:s') }}{{ $m->direction }}{{ (string) ($m->target_extension ?: ($smdr['extension'] ?? '-')) }}{{ (string) ($smdr['co'] ?? '-') }}{{ (string) ($m->phone_number ?: ($smdr['dial_number'] ?? '-')) }}{{ $rubricaNomeTech ?: '-' }}{{ $duration !== '' ? $duration : '-' }}{{ $cost !== '' ? $cost : '-' }}{{ $m->assignedUser?->name ?: '-' }}{{ $m->stabile?->denominazione ?: ($m->stabile_id ? ('#' . $m->stabile_id) : '-') }}{{ $m->post_it_id ? ('#' . $m->post_it_id) : '-' }}{{ $m->message_text }} + @php($rubricaUrlTech = $this->getRubricaUrlByPhone($phoneValue)) + @if($rubricaUrlTech) + Apri Rubrica + @endif + @if(!$m->post_it_id) + Crea Post-it + @else + Collegato + @endif +
Nessun evento SMDR disponibile.
+
+
+ @endif @endif diff --git a/resources/views/filament/pages/supporto/dashboard.blade.php b/resources/views/filament/pages/supporto/dashboard.blade.php new file mode 100644 index 0000000..c5aafe3 --- /dev/null +++ b/resources/views/filament/pages/supporto/dashboard.blade.php @@ -0,0 +1,10 @@ + +
+
+
Dashboard Supporto
+
Area tecnica per manutenzione, sincronizzazione Google e debug operativo.
+
+ + @livewire(\App\Filament\Widgets\GoogleWorkspaceOverview::class) +
+
diff --git a/resources/views/filament/pages/supporto/modifiche.blade.php b/resources/views/filament/pages/supporto/modifiche.blade.php index e82ddf0..510a1c9 100644 --- a/resources/views/filament/pages/supporto/modifiche.blade.php +++ b/resources/views/filament/pages/supporto/modifiche.blade.php @@ -1,4 +1,4 @@ - +
+ + + +
+ @if($this->activeTab === 'errori')
@@ -55,9 +80,46 @@ Lancia aggiornamento + + Lancia aggiornamento (riserva) + + + Check connettivita update + Aggiorna dati pagina + + Pulisci cache remoto + + + Ricompila view remoto + + + Aggiorna avanzamento + +
+ + @if($this->updateInProgress) +
+ @else +
+ @endif +
+ Avanzamento aggiornamento + {{ (int) $this->updateProgressPercent }}% +
+
+
+
+
{{ $this->updateProgressMessage }}
+
+ Job: {{ $this->updateJobId ?? '-' }} + · Stato: {{ $this->updateProgressStatus }} + @if($this->updateInProgress) + · in esecuzione + @endif +
@if(! $this->canRunUpdate()) @@ -79,21 +141,185 @@ {{ $this->lastUpdateExitCode }} ERRORE @endif
+ @if(filled($this->lastPlannedUpdateSummary)) +
+ Piano usato: {{ $this->lastPlannedUpdateSummary }} +
+ @endif
+
+
Aggiornamenti pianificati (prima del lancio)
+
    + @foreach($this->updatePlannedSteps as $step) +
  • {{ $step }}
  • + @endforeach +
+ +
+
Ripristino differenziale disponibile
+
Ripristino singola scrittura:
+
php artisan netgescon:restore-record <tabella> <pk> --apply
+
Preview senza scrivere:
+
php artisan netgescon:restore-record <tabella> <pk>
+
+
+ @if(filled($this->lastUpdateOutput))
Output tecnico
{{ $this->lastUpdateOutput }}
@endif + + @if(filled($this->lastUpdateIssue)) +
+
Diagnostica automatica ultimo errore
+
{{ $this->lastUpdateIssue }}
+
+ @endif + + @if(filled($this->lastConnectivityCheckOutput)) +
+
Output check connettivita endpoint update
+
{{ $this->lastConnectivityCheckOutput }}
+
+ @endif + +
Errori runtime, supporto e pregressi (laravel.log)
+
Raccolta unificata da: runtime-errors.ndjson, support-events.ndjson, laravel.log, docs/support/online-runtime-errors.ndjson (feed Git da online).
+ +
+ Aperti: {{ (int) $this->openBugCount }} + Risolti: {{ (int) $this->resolvedBugCount }} + Filtro: + + + +
+ +
+
+
Aggiungi BUG manuale
+ + +
+ Crea BUG manuale +
+
+ +
+
Nota risoluzione BUG
+
+ BUG selezionato: {{ $this->selectedBugFingerprint ? 'selezionato' : 'nessuno' }} +
+ +
+ Salva nota risoluzione +
+
+
+ +
+ + + + + + + + + + + + + + + + + @forelse($this->runtimeErrors as $row) + + + + + + + + + + + + + @empty + + + + @endforelse + +
BUG #StatoTimestampOrigineTipoOccorrenzeMessaggioContestoNota risoluzioneAzioni
{{ $row['bug_code'] }} + @if(($row['bug_status'] ?? 'open') === 'resolved') + risolto + @else + aperto + @endif + {{ $row['timestamp'] }}{{ $row['source'] ?? '-' }}{{ $row['type'] }}{{ (int) ($row['occurrences'] ?? 1) }}{{ $row['message'] }} +
{{ $row['context'] !== '' ? \Illuminate\Support\Str::limit($row['context'], 220) : '-' }}
+
+
{{ $row['resolution_note'] !== '' ? \Illuminate\Support\Str::limit($row['resolution_note'], 160) : '-' }}
+ @if(($row['bug_status'] ?? 'open') === 'resolved' && filled($row['resolved_at'])) +
risolto: {{ $row['resolved_at'] }}
+ @endif +
+ Nota + @if(($row['bug_status'] ?? 'open') === 'resolved') + Riapri + @else + Segna risolto + @endif +
Nessun errore registrato negli ultimi eventi.
+
+
+ @endif + + @if($this->activeTab === 'commit')
Ultimi commit (descrizioni)
+
+
Aggiungi nota operativa a un commit
+
+ + +
+
+ Salva nota commit +
+
+
@@ -102,6 +328,7 @@ + @@ -111,16 +338,23 @@ + @empty - + @endforelse
Commit Autore DescrizioneNota aggiuntiva
{{ $commit['short_hash'] }} {{ $commit['author'] }} {{ $commit['message'] }} + @php + $note = $this->getCommitNote($commit['hash']); + @endphp +
{{ $note !== '' ? $note : '-' }}
+
Nessun commit disponibile.Nessun commit disponibile.
+ @endif @php $path = base_path('docs/NETGESCON-MODIFICHE.md'); @@ -144,18 +378,63 @@ $gitRawHtml = str_replace(array_keys($replacements), array_values($replacements), $gitRawHtml); @endphp - -
Registro funzionale (manuale)
-
- {!! $rawHtml !!} -
-
+ @if($this->activeTab === 'migliorie') + +
Pagine nuove o rese piu funzionali (ultimi commit)
+
+ + + + + + + + + + + @forelse($this->recentFilamentPages as $row) + + + + + + + @empty + + + + @endforelse + +
PaginaPercorsoCommitDescrizione
{{ $row['page'] }}{{ $row['path'] }}{{ $row['commit'] }}{{ $row['message'] }}
Nessuna pagina recente rilevata dai commit.
+
+
- -
Changelog automatico da script
-
- {!! $gitRawHtml !!} -
-
+ +
Migliorie e funzionalita aggiunte (estratte dal registro)
+
+
    + @forelse($this->functionalHighlights as $item) +
  • {{ $item }}
  • + @empty +
  • Nessuna miglioria rilevata automaticamente nel file modifiche.
  • + @endforelse +
+
+
+ + +
Registro funzionale (manuale)
+
+ {!! $rawHtml !!} +
+
+ + +
Changelog automatico da script
+
+ {!! $gitRawHtml !!} +
+
+ @endif -
+ diff --git a/resources/views/filament/pages/supporto/ticket-gestione.blade.php b/resources/views/filament/pages/supporto/ticket-gestione.blade.php index c73857f..51a24ed 100644 --- a/resources/views/filament/pages/supporto/ticket-gestione.blade.php +++ b/resources/views/filament/pages/supporto/ticket-gestione.blade.php @@ -36,6 +36,33 @@ @if($activeTab === 'elenco') +
+
Categorie ticket
+
+ + + +
+
+ Aggiungi categoria + Modifica categoria selezionata +
+
+
@@ -43,6 +70,7 @@ + @@ -59,12 +87,13 @@ {{ $ticket->titolo }} @if(((int) ($ticket->attachments_count ?? 0)) > 0) - ATT {{ (int) $ticket->attachments_count }} + Allegati {{ (int) $ticket->attachments_count }} @endif + @@ -94,7 +123,7 @@ @empty - + @endforelse @@ -105,6 +134,12 @@
Fornitori attivi su ticket aperti
Mostra solo fornitori con ticket/interventi in corso, con stato operativo e ultimo aggiornamento.
+ @if(filled($lastGeneratedPassword ?? null)) +
+ Ultima password temporanea generata: {{ $lastGeneratedPassword }} +
+ @endif +
ID Titolo CategoriaTipo intervento Assegnato a Priorita Stato {{ optional($ticket->categoriaTicket)->nome ?: '-' }}{{ $this->getTipoInterventoLabel($ticket) }} {{ optional($ticket->assegnatoAUser)->name ?: '-' }} {{ $ticket->priorita }} {{ $ticket->stato }}
Nessun ticket per il filtro selezionato.Nessun ticket per il filtro selezionato.
@@ -114,6 +149,7 @@ + @@ -133,8 +169,20 @@ @endif + @empty - + @endforelse @@ -229,7 +277,7 @@
@if(str_starts_with((string) $a->mime_type, 'image/')) - {{ $a->original_file_name }} + {{ $a->original_file_name }} @else
diff --git a/resources/views/filament/pages/supporto/ticket-mobile.blade.php b/resources/views/filament/pages/supporto/ticket-mobile.blade.php index 773db58..5cb25eb 100644 --- a/resources/views/filament/pages/supporto/ticket-mobile.blade.php +++ b/resources/views/filament/pages/supporto/ticket-mobile.blade.php @@ -1,5 +1,59 @@ - +
+ @if($liveIncomingCall) + @php($rubricaUrl = $this->getLiveRubricaUrl()) + @php($estrattoUrl = $this->getLiveEstrattoContoUrl()) +
+
+
+
Chiamata in arrivo in tempo reale
+
+ Numero: {{ $liveIncomingCall['phone'] ?? '-' }} + @if(!empty($liveIncomingCall['target_extension'])) + · Interno: {{ $liveIncomingCall['target_extension'] }} + @endif + @if(!empty($liveIncomingCall['received_at'])) + · Ricevuta: {{ $liveIncomingCall['received_at'] }} + @endif +
+ @if(!empty($liveIncomingCall['rubrica_nome'])) +
Contatto riconosciuto: {{ $liveIncomingCall['rubrica_nome'] }}
+ @endif + @if(!empty($liveIncomingCall['line'])) +
{{ $this->excerpt((string) $liveIncomingCall['line'], 180) }}
+ @endif +
+
+ + Crea Post-it (prima valutazione) + + + + Precompila ticket (opzionale) + + + @if($liveCallCanClickToCall) + + Chiama da interno assegnato + + @endif + + @if($rubricaUrl) + + Apri scheda rubrica + + @endif + + @if($estrattoUrl) + + Apri estratto conto + + @endif +
+
+
+ @endif +
Dashboard Filament diff --git a/resources/views/filament/widgets/google-workspace-overview.blade.php b/resources/views/filament/widgets/google-workspace-overview.blade.php index 5b8ec63..6db694a 100644 --- a/resources/views/filament/widgets/google-workspace-overview.blade.php +++ b/resources/views/filament/widgets/google-workspace-overview.blade.php @@ -6,35 +6,45 @@
-
-
Account collegato
-
{{ $connectionLabel }}
-
- - Apri calendario interno - - - Apri rubrica interna - - - - - - - Ticket Aperti (Filament) - + @if(($showTechnicalBoxes ?? true) === true) +
+
Account collegato
+
{{ $connectionLabel }}
+
+ + Apri calendario interno + + + Apri rubrica interna + + + + + + + Ticket Aperti (Filament) + +
-
+ @else +
+
Scadenzario operativo
+
Calendario collegato: {{ $calendarId }}
+ +
+ @endif - @if(filled($errorMessage)) + @if(filled($errorMessage) && (($showTechnicalBoxes ?? true) === true))
{{ $errorMessage }}
@@ -93,29 +103,31 @@ @else @endif
-
-
Template Cartelle Drive Condominio
-
Struttura base replicabile per ogni stabile (allineata alla tua organizzazione in Drive).
- @if(count($driveTemplateFolders) === 0) -
Template non configurato.
- @else -
- @foreach($driveTemplateFolders as $folder) -
{{ $folder }}
- @endforeach -
- @endif -
+ @if(($showTechnicalBoxes ?? true) === true) +
+
Template Cartelle Drive Condominio
+
Struttura base replicabile per ogni stabile (allineata alla tua organizzazione in Drive).
+ @if(count($driveTemplateFolders) === 0) +
Template non configurato.
+ @else +
+ @foreach($driveTemplateFolders as $folder) +
{{ $folder }}
+ @endforeach +
+ @endif +
+ @endif
diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php index e349a44..343d617 100755 --- a/resources/views/profile/edit.blade.php +++ b/resources/views/profile/edit.blade.php @@ -29,6 +29,10 @@ @include('profile.partials.update-password-form')
+
+ @include('profile.partials.two-factor-authentication-form') +
+
@include('profile.partials.delete-user-form')
diff --git a/resources/views/profile/partials/two-factor-authentication-form.blade.php b/resources/views/profile/partials/two-factor-authentication-form.blade.php new file mode 100644 index 0000000..28ebb46 --- /dev/null +++ b/resources/views/profile/partials/two-factor-authentication-form.blade.php @@ -0,0 +1,58 @@ +
+
+

Autenticazione a due fattori (App)

+

Usa Google Authenticator, Microsoft Authenticator o app compatibile TOTP.

+
+ + @if (session('status') === 'two-factor-enabled') +

2FA attivata correttamente.

+ @endif + + @if (session('status') === 'two-factor-disabled') +

2FA disattivata.

+ @endif + + @if (! $user->hasTwoFactorEnabled()) + @if (!empty($twoFactorPendingSecret ?? null)) +
+

1) Inserisci questa chiave nell'app Authenticator:

+ {{ $twoFactorPendingSecret }} + + @if(!empty($twoFactorPendingUri ?? null)) +

URI OTP:

+ {{ $twoFactorPendingUri }} + @endif + +
+ @csrf +
+ + + +
+ Conferma e Attiva 2FA + +
+ @else +
+ @csrf + Abilita 2FA con App + + @endif + @else +
+

2FA attiva dal {{ optional($user->two_factor_confirmed_at)->format('d/m/Y H:i') }}.

+

In login dovrai inserire il codice OTP della tua app.

+
+ +
+ @csrf +
+ + + +
+ Disabilita 2FA + + @endif +
diff --git a/resources/views/superadmin/impostazioni/index.blade.php b/resources/views/superadmin/impostazioni/index.blade.php new file mode 100644 index 0000000..c393f2d --- /dev/null +++ b/resources/views/superadmin/impostazioni/index.blade.php @@ -0,0 +1,141 @@ +@extends('admin.layouts.netgescon') + +@section('title', 'Impostazioni Sistema') + +@section('breadcrumb') + SuperAdmin + + Impostazioni Sistema +@endsection + +@section('content') +
+

Registrazione e Sicurezza

+ +
+

Check Servizi Google

+
+
+
Configurazione OAuth
+
Client ID: {{ ($googleStatus['client_id_configured'] ?? false) ? 'ok' : 'mancante' }}
+
Client Secret: {{ ($googleStatus['client_secret_configured'] ?? false) ? 'ok' : 'mancante' }}
+
Redirect URI: {{ ($googleStatus['redirect_configured'] ?? false) ? 'ok' : 'mancante' }}
+
+
+
Connessione Account
+
Stato: {{ ($googleStatus['oauth_connected'] ?? false) ? 'collegato' : 'non collegato' }}
+
Email: {{ $googleStatus['oauth_email'] ?? '-' }}
+
+
+
Servizi
+
Calendar: {{ ($googleStatus['calendar_enabled'] ?? false) ? 'ok' : 'ko' }}
+
Rubrica: {{ ($googleStatus['contacts_enabled'] ?? false) ? 'ok' : 'ko' }}
+
Drive: {{ ($googleStatus['drive_enabled'] ?? false) ? 'ok' : 'ko' }}
+
+
+
+ +
+ @csrf + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+

Comunicazioni Omnicanale (Test)

+

Puoi instradare i webhook su ticket o post-it usando query param: ?mode=ticket&ticket_id=123 oppure ?mode=auto_post_it.

+
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
Webhook Telegram: {{ url('/api/v1/communications/telegram/webhook') }}
+
Webhook WhatsApp: {{ url('/api/v1/communications/whatsapp/webhook') }}
+
+
+ +
+

Tema Interfaccia Personale

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ +
+@endsection diff --git a/resources/views/superadmin/users/create.blade.php b/resources/views/superadmin/users/create.blade.php index 4363770..bad9b65 100644 --- a/resources/views/superadmin/users/create.blade.php +++ b/resources/views/superadmin/users/create.blade.php @@ -42,6 +42,15 @@
+
+ + +

Permessi Modulo GESCON Import

diff --git a/resources/views/superadmin/users/edit.blade.php b/resources/views/superadmin/users/edit.blade.php index 551e9cc..b76445b 100644 --- a/resources/views/superadmin/users/edit.blade.php +++ b/resources/views/superadmin/users/edit.blade.php @@ -34,6 +34,34 @@
+
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/resources/views/superadmin/users/index.blade.php b/resources/views/superadmin/users/index.blade.php index 638510e..0d1c239 100644 --- a/resources/views/superadmin/users/index.blade.php +++ b/resources/views/superadmin/users/index.blade.php @@ -23,6 +23,9 @@
+ + + @@ -32,8 +35,47 @@ + + +
Ticket attivi Interventi aperti Come procedeAccesso fornitore Azioni
+
Dipendenti: {{ (int) ($f['dipendenti_count'] ?? 0) }}
+ @if((int) ($f['linked_user_id'] ?? 0) > 0) +
Accesso attivo (utente #{{ (int) $f['linked_user_id'] }})
+ @else +
Nessun accesso attivo
+ @endif +
+ + @if((int) ($f['linked_user_id'] ?? 0) > 0) + + @endif Scheda operativa Anagrafica
@@ -142,7 +190,7 @@
Nessun fornitore attivo su ticket aperti.Nessun fornitore attivo su ticket aperti.
Nome Email RuoliStatoAttivoPiano Azioni
{{ $u->name }} {{ $u->email }} {{ $u->roles->pluck('name')->join(', ') }} + {{ $u->registration_status ?? 'approved' }} + + @if($u->is_active) + si + @else + no + @endif + +
+ @csrf + + +
+
Modifica + @if(($u->registration_status ?? 'approved') === 'pending') +
+ @csrf + +
+
+ @csrf + +
+ @endif +
+ @csrf + +
+ @if(auth()->id() !== $u->id && auth()->user()?->hasRole('super-admin') && $u->canBeImpersonated()) + Impersona + @endif
@csrf @method('DELETE') diff --git a/routes/api.php b/routes/api.php index e79a903..e138010 100755 --- a/routes/api.php +++ b/routes/api.php @@ -88,3 +88,20 @@ Route::get('/lookup', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'lookup']) ->name('api.cti.panasonic.lookup'); }); + +// --- API Comunicazioni Omnicanale (Telegram / WhatsApp test) --- +Route::prefix('v1/communications')->group(function () { + Route::post('/telegram/webhook', [\App\Http\Controllers\Api\CommunicationWebhookController::class, 'telegram']) + ->name('api.communications.telegram.webhook'); + + Route::post('/whatsapp/webhook', [\App\Http\Controllers\Api\CommunicationWebhookController::class, 'whatsapp']) + ->name('api.communications.whatsapp.webhook'); +}); + +Route::middleware('auth:sanctum')->prefix('v1/communications')->group(function () { + Route::post('/telegram/send-test', [\App\Http\Controllers\Api\CommunicationWebhookController::class, 'sendTelegramTest']) + ->name('api.communications.telegram.send_test'); + + Route::post('/whatsapp/send-test', [\App\Http\Controllers\Api\CommunicationWebhookController::class, 'sendWhatsappTest']) + ->name('api.communications.whatsapp.send_test'); +}); diff --git a/routes/web.php b/routes/web.php index a8b3d51..07fde01 100755 --- a/routes/web.php +++ b/routes/web.php @@ -84,6 +84,9 @@ Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); + Route::post('/profile/two-factor/enable', [ProfileController::class, 'twoFactorEnable'])->name('profile.two-factor.enable'); + Route::post('/profile/two-factor/confirm', [ProfileController::class, 'twoFactorConfirm'])->name('profile.two-factor.confirm'); + Route::post('/profile/two-factor/disable', [ProfileController::class, 'twoFactorDisable'])->name('profile.two-factor.disable'); // --- SUPER-ADMIN PANEL --- Route::middleware(['role:super-admin'])->prefix('superadmin')->name('superadmin.')->group(function () { @@ -95,6 +98,10 @@ Route::resource('users', SuperAdminUserController::class)->except(['show']); Route::patch('users/{user}/update-role', [SuperAdminUserController::class, 'updateRole'])->name('users.updateRole'); Route::get('users/{user}/impersonate', [SuperAdminUserController::class, 'impersonate'])->name('users.impersonate'); + Route::post('users/{user}/approve', [SuperAdminUserController::class, 'approve'])->name('users.approve'); + Route::post('users/{user}/reject', [SuperAdminUserController::class, 'reject'])->name('users.reject'); + Route::post('users/{user}/toggle-active', [SuperAdminUserController::class, 'toggleActive'])->name('users.toggle-active'); + Route::post('users/{user}/assign-plan', [SuperAdminUserController::class, 'assignPlan'])->name('users.assign-plan'); // Impostazioni Sistema Route::get('impostazioni', [\App\Http\Controllers\SuperAdmin\ImpostazioniController::class, 'index'])->name('impostazioni.index'); @@ -131,6 +138,7 @@ Route::resource('fornitori', FornitoreController::class); Route::resource('tickets', TicketController::class); Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close'); + Route::post('tickets/{ticket}/insurance/open', [TicketController::class, 'openInsuranceClaim'])->name('tickets.insurance.open'); Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store'); Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view'); Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store'); @@ -301,6 +309,7 @@ Route::resource('fornitori', FornitoreController::class); Route::resource('tickets', TicketController::class); Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close'); + Route::post('tickets/{ticket}/insurance/open', [TicketController::class, 'openInsuranceClaim'])->name('tickets.insurance.open'); Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store'); Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view'); Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store'); @@ -576,6 +585,9 @@ Route::get('affitti/ricevuta/{dovuto}', [\App\Http\Controllers\Filament\AffittiRicevuteController::class, 'show']) ->name('filament.affitti.ricevuta'); + Route::get('supporto/ticket-attachments/{attachment}/view', [\App\Http\Controllers\Admin\TicketAttachmentViewController::class, 'show']) + ->name('filament.tickets.attachments.view'); + // Pagina di test/calibrazione per etichette DYMO (11354/99014) Route::get('etichette/calibrazione', [\App\Http\Controllers\Filament\DymoCalibrationController::class, 'show']) ->name('filament.etichette.calibrazione'); diff --git a/scripts/ops/netgescon-export-online-errors-to-git.sh b/scripts/ops/netgescon-export-online-errors-to-git.sh new file mode 100755 index 0000000..841f9e4 --- /dev/null +++ b/scripts/ops/netgescon-export-online-errors-to-git.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Export recent online errors into a git-trackable NDJSON feed. +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT_FILE="${ROOT_DIR}/docs/support/online-runtime-errors.ndjson" +TMP_FILE="${OUT_FILE}.tmp" + +mkdir -p "$(dirname "$OUT_FILE")" +: > "$TMP_FILE" + +append_tail() { + local src="$1" + local lines="$2" + if [[ -f "$src" ]]; then + tail -n "$lines" "$src" >> "$TMP_FILE" || true + fi +} + +append_tail "${ROOT_DIR}/storage/logs/runtime-errors.ndjson" 300 +append_tail "${ROOT_DIR}/storage/logs/support-events.ndjson" 300 + +if [[ ! -s "$TMP_FILE" ]]; then + echo '{"timestamp":"'"$(date -Iseconds)"'","type":"info","message":"Nessun errore disponibile da esportare","context":"export script","source":"online-export"}' > "$TMP_FILE" +fi + +mv "$TMP_FILE" "$OUT_FILE" +echo "Export completato: ${OUT_FILE}"