From fe16ef481b8e1576f1e07a434574c445399e0b25 Mon Sep 17 00:00:00 2001 From: michele Date: Mon, 16 Mar 2026 16:03:36 +0000 Subject: [PATCH] Checkpoint day0: sync staging fixes and operational workflows --- .gitignore | 3 + .../GoogleSyncScadenzeCalendarCommand.php | 258 +++++++++ app/Console/Commands/ImportScadenzeMdb.php | 260 +++++++++ .../NetgesconPreupdateBackupCommand.php | 160 +++--- .../Condomini/LettureServiziArchivio.php | 147 +++++- .../Pages/Gescon/FornitoriArchivio.php | 211 +++++++- .../Gescon/RubricaUniversaleArchivio.php | 39 +- .../Pages/Gescon/RubricaUniversaleScheda.php | 499 +++++++++++++++++- app/Filament/Pages/Supporto/Modifiche.php | 119 ++++- .../Pages/Supporto/TicketGestione.php | 15 +- .../Admin/TicketAttachmentViewController.php | 6 +- app/Models/RubricaUniversale.php | 8 +- app/Models/Scadenza.php | 65 +++ app/Models/StabileServizioLettura.php | 9 + app/Models/TicketAttachment.php | 68 ++- .../Filament/AdminFilamentPanelProvider.php | 6 +- ...026_03_15_211500_create_scadenze_table.php | 44 ++ ...nagrafica_fields_to_rubrica_universale.php | 51 ++ ...elds_to_stabile_servizio_letture_table.php | 68 +++ docs/assicurazioni-roadmap.md | 123 +++++ info_per_scambio.md | 106 ++++ .../rubrica-universale-scheda.blade.php | 99 +++- .../filament/pages/gescon/table.blade.php | 55 +- .../pages/supporto/modifiche.blade.php | 51 ++ .../pages/supporto/ticket-gestione.blade.php | 17 +- .../pages/supporto/ticket-scheda.blade.php | 2 +- 26 files changed, 2329 insertions(+), 160 deletions(-) create mode 100644 app/Console/Commands/GoogleSyncScadenzeCalendarCommand.php create mode 100644 app/Console/Commands/ImportScadenzeMdb.php create mode 100644 app/Models/Scadenza.php create mode 100644 database/migrations/2026_03_15_211500_create_scadenze_table.php create mode 100644 database/migrations/2026_03_16_090000_add_anagrafica_fields_to_rubrica_universale.php create mode 100644 database/migrations/2026_03_16_150000_add_workflow_fields_to_stabile_servizio_letture_table.php create mode 100644 docs/assicurazioni-roadmap.md create mode 100644 info_per_scambio.md diff --git a/.gitignore b/.gitignore index f7c39aa..a2d5002 100755 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,9 @@ storage/framework/cache/ storage/framework/sessions/ storage/framework/views/ storage/backups/ +storage/app/distribution/ +storage/app/support/ +storage/app/update-backups/ # Local personal workspace (keep only operational note) Miki-Bug-workspace/** diff --git a/app/Console/Commands/GoogleSyncScadenzeCalendarCommand.php b/app/Console/Commands/GoogleSyncScadenzeCalendarCommand.php new file mode 100644 index 0000000..744ec84 --- /dev/null +++ b/app/Console/Commands/GoogleSyncScadenzeCalendarCommand.php @@ -0,0 +1,258 @@ +option('dry-run'); + $limit = max(1, (int) $this->option('limit')); + $days = max(1, (int) $this->option('days')); + $forcedCalendarId = trim((string) $this->option('calendar-id')); + $filterAdminIds = collect((array) $this->option('admin-id')) + ->map(fn($value) => (int) $value) + ->filter(fn(int $value) => $value > 0) + ->values() + ->all(); + + $admins = Amministratore::query() + ->when($filterAdminIds !== [], fn($query) => $query->whereIn('id', $filterAdminIds)) + ->get(); + + if ($admins->isEmpty()) { + $this->warn('Nessun amministratore trovato per i filtri richiesti.'); + return self::SUCCESS; + } + + foreach ($admins as $admin) { + $google = Arr::get($admin->impostazioni ?? [], 'google', []); + $oauth = Arr::get($google, 'oauth', []); + + if (! (bool) Arr::get($oauth, 'connected', false)) { + $this->line("Admin {$admin->id}: Google non collegato, salto."); + continue; + } + + $calendarId = $forcedCalendarId !== '' + ? $forcedCalendarId + : ((string) Arr::get($google, 'calendar_id', 'primary') ?: 'primary'); + + $token = $this->resolveAccessToken($admin, $google, $oauth, $dryRun); + if ($token === null) { + $this->warn("Admin {$admin->id}: token non disponibile, salto."); + continue; + } + + $query = Scadenza::query() + ->with('stabile:id,amministratore_id,denominazione') + ->whereNotNull('data_scadenza') + ->whereDate('data_scadenza', '<=', now()->addDays($days)->toDateString()) + ->orderBy('data_scadenza') + ->orderBy('id'); + + $query->where(function ($sub) use ($admin): void { + $sub->whereHas('stabile', function ($stabileQuery) use ($admin): void { + $stabileQuery->where('amministratore_id', (int) $admin->id); + })->orWhereNull('stabile_id'); + }); + + $scadenze = $query->limit($limit)->get(); + $created = 0; + $updated = 0; + $errors = 0; + + foreach ($scadenze as $scadenza) { + $payload = $this->buildEventPayload($scadenza); + $event = $this->findEventByScadenzaId($token, $calendarId, (int) $scadenza->id); + + if ($event === null) { + if ($dryRun) { + $created++; + continue; + } + + $response = Http::withToken($token) + ->acceptJson() + ->post('https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($calendarId) . '/events', $payload); + + if (! $response->successful()) { + $errors++; + continue; + } + + $scadenza->calendar_event_id = (string) ($response->json('id') ?? ''); + $scadenza->calendar_synced_at = now(); + $scadenza->save(); + $created++; + continue; + } + + if ($dryRun) { + $updated++; + continue; + } + + $eventId = trim((string) Arr::get($event, 'id', '')); + if ($eventId === '') { + $errors++; + continue; + } + + $response = Http::withToken($token) + ->acceptJson() + ->patch('https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($calendarId) . '/events/' . rawurlencode($eventId), $payload); + + if (! $response->successful()) { + $errors++; + continue; + } + + $scadenza->calendar_event_id = $eventId; + $scadenza->calendar_synced_at = now(); + $scadenza->save(); + $updated++; + } + + $mode = $dryRun ? 'dry-run' : 'apply'; + $this->info("Admin {$admin->id} ({$mode}): create {$created}, aggiornate {$updated}, errori {$errors}."); + } + + return self::SUCCESS; + } + + private function buildEventPayload(Scadenza $scadenza): array + { + $date = Carbon::parse($scadenza->data_scadenza); + $title = $scadenza->titolo_calendario; + $description = implode("\n", array_filter([ + 'Natura: ' . (string) ($scadenza->natura_label ?: $scadenza->natura ?: '-'), + 'Gestione: ' . (string) ($scadenza->gestione_tipo ?: '-'), + 'Stabile: ' . (string) ($scadenza->stabile?->denominazione ?: ($scadenza->codice_stabile_legacy ?: '-')), + $scadenza->anno ? ('Anno gestione: ' . $scadenza->anno) : null, + $scadenza->rata ? ('Rata: ' . $scadenza->rata) : null, + $scadenza->rif_f24 ? ('Rif. F24: ' . $scadenza->rif_f24) : null, + $scadenza->importo_f24 ? ('Importo F24: ' . number_format((float) $scadenza->importo_f24, 2, ',', '.')) : null, + ])); + + $payload = [ + 'summary' => $title, + 'description' => $description, + 'extendedProperties' => [ + 'private' => [ + 'netgescon_scadenza_id' => (string) $scadenza->id, + ], + ], + ]; + + if ($scadenza->ora_scadenza) { + $start = Carbon::parse($scadenza->data_scadenza->format('Y-m-d') . ' ' . $scadenza->ora_scadenza, 'Europe/Rome'); + $payload['start'] = ['dateTime' => $start->toIso8601String(), 'timeZone' => 'Europe/Rome']; + $payload['end'] = ['dateTime' => $start->copy()->addMinutes(30)->toIso8601String(), 'timeZone' => 'Europe/Rome']; + } else { + $payload['start'] = ['date' => $date->toDateString()]; + $payload['end'] = ['date' => $date->copy()->addDay()->toDateString()]; + } + + return $payload; + } + + private function findEventByScadenzaId(string $token, string $calendarId, int $scadenzaId): ?array + { + $response = Http::withToken($token) + ->acceptJson() + ->get('https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($calendarId) . '/events', [ + 'privateExtendedProperty' => 'netgescon_scadenza_id=' . $scadenzaId, + 'maxResults' => 1, + ]); + + if (! $response->successful()) { + return null; + } + + $items = $response->json('items'); + if (! is_array($items) || $items === []) { + return null; + } + + return is_array($items[0]) ? $items[0] : null; + } + + private function resolveAccessToken(Amministratore $amministratore, array $google, array $oauth, bool $dryRun, bool $forceRefresh = false): ?string + { + $accessToken = trim((string) Arr::get($oauth, 'access_token', '')); + $refreshToken = trim((string) Arr::get($oauth, 'refresh_token', '')); + + if (! $forceRefresh && $accessToken !== '') { + return $accessToken; + } + + 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')); + + $credentialPairs = []; + if ($settingsClientId !== '' && $settingsClientSecret !== '') { + $credentialPairs[] = [$settingsClientId, $settingsClientSecret]; + } + if ($configClientId !== '' && $configClientSecret !== '' && ($configClientId !== $settingsClientId || $configClientSecret !== $settingsClientSecret)) { + $credentialPairs[] = [$configClientId, $configClientSecret]; + } + + foreach ($credentialPairs as [$clientId, $clientSecret]) { + $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'refresh_token' => $refreshToken, + 'grant_type' => 'refresh_token', + ]); + + if (! $response->successful()) { + continue; + } + + $token = trim((string) Arr::get($response->json(), 'access_token', '')); + if ($token === '') { + continue; + } + + if (! $dryRun) { + $settings = $amministratore->impostazioni ?? []; + $settingsGoogle = Arr::get($settings, 'google', []); + $settingsOauth = Arr::get($settingsGoogle, 'oauth', []); + $settingsOauth['access_token'] = $token; + $settingsOauth['expires_in'] = (int) Arr::get($response->json(), 'expires_in', 3600); + $settingsOauth['refreshed_at'] = now()->toDateTimeString(); + $settingsGoogle['oauth'] = $settingsOauth; + $settings['google'] = $settingsGoogle; + $amministratore->impostazioni = $settings; + $amministratore->save(); + } + + return $token; + } + + return null; + } +} \ No newline at end of file diff --git a/app/Console/Commands/ImportScadenzeMdb.php b/app/Console/Commands/ImportScadenzeMdb.php new file mode 100644 index 0000000..5220247 --- /dev/null +++ b/app/Console/Commands/ImportScadenzeMdb.php @@ -0,0 +1,260 @@ +option('mdb') ?: '/mnt/gescon-archives/gescon/parti_comuni.mdb'; + $filterStabile = trim((string) $this->option('stabile')); + + if (! is_file($mdb)) { + $this->error('Specifica --mdb con path valido al file parti_comuni.mdb'); + return self::FAILURE; + } + + if ((bool) $this->option('truncate')) { + Scadenza::query()->truncate(); + } + + $rows = $this->mdbExportRows($mdb, 'Scadenze'); + if ($rows === []) { + $this->warn('Nessuna riga letta dalla tabella Scadenze.'); + return self::SUCCESS; + } + + $imported = 0; + foreach ($rows as $row) { + $legacyId = $this->toInt($row['id_scadenza'] ?? null); + if ($legacyId <= 0) { + continue; + } + + $codStabile = $this->normalizeString($row['cod_stabile'] ?? null); + if ($filterStabile !== '' && $codStabile !== $filterStabile) { + continue; + } + + $natura = strtoupper($this->normalizeString($row['Natura'] ?? null) ?? ''); + + Scadenza::query()->updateOrCreate( + ['legacy_id' => $legacyId], + [ + 'stabile_id' => $this->resolveStabileId($codStabile), + 'codice_stabile_legacy' => $codStabile, + 'descrizione_sintetica' => $this->normalizeString($row['Descriz_sintetica'] ?? null), + 'data_scadenza' => $this->toDate($row['Dt_scadenza'] ?? null), + 'ora_scadenza' => $this->toTime($row['ora_scadenza'] ?? null), + 'natura' => $natura !== '' ? $natura : null, + 'natura_label' => $this->resolveNaturaLabel($natura), + 'gestione_tipo' => $this->resolveGestioneTipo($row['Gest_ors'] ?? null), + 'num_assemblea' => $this->toInt($row['Num_assemblea'] ?? null) ?: null, + 'numero_straordinaria' => $this->toInt($row['N_stra'] ?? null) ?: null, + 'anno' => $this->normalizeString($row['Anno'] ?? null), + 'rata' => $this->toInt($row['Rata'] ?? null) ?: null, + 'fatto' => $this->normalizeString($row['Fatto'] ?? null), + 'tipo_riga' => $this->normalizeString($row['tipo_riga'] ?? null), + 'rif_f24' => $this->toInt($row['Rif_f24'] ?? null) ?: null, + 'importo_f24' => $this->toDecimal($row['Importo_f24'] ?? null), + 'richiede_pop_up' => $this->toBoolean($row['Richiede_pop_up'] ?? null), + 'giorni_anticipo' => $this->toInt($row['gg_anticipo'] ?? null) ?: null, + 'popup_dal' => $this->toDate($row['Pop_Up_dal'] ?? null), + 'legacy_payload' => [ + 'legacy_id' => $legacyId, + 'cod_stabile' => $codStabile, + 'descriz_sintetica' => $this->normalizeString($row['Descriz_sintetica'] ?? null), + 'gest_ors' => $this->normalizeString($row['Gest_ors'] ?? null), + 'natura' => $natura, + 'tipo_riga' => $this->normalizeString($row['tipo_riga'] ?? null), + 'num_assemblea' => $this->toInt($row['Num_assemblea'] ?? null) ?: null, + 'numero_straordinaria'=> $this->toInt($row['N_stra'] ?? null) ?: null, + 'anno' => $this->normalizeString($row['Anno'] ?? null), + 'rata' => $this->toInt($row['Rata'] ?? null) ?: null, + ], + ] + ); + + $imported++; + } + + $this->info('Scadenze importate/aggiornate: ' . $imported); + return self::SUCCESS; + } + + private function mdbExportRows(string $mdb, string $table): array + { + $bin = trim((string) shell_exec('command -v mdb-export')) ?: '/usr/bin/mdb-export'; + $process = new Process([$bin, $mdb, $table]); + $process->setTimeout(120); + $process->run(); + + if (! $process->isSuccessful()) { + $this->warn("mdb-export fallito per {$table}: " . $process->getErrorOutput()); + return []; + } + + $csv = $process->getOutput(); + if ($csv === '') { + return []; + } + + $fh = fopen('php://temp', 'r+'); + fwrite($fh, $csv); + rewind($fh); + + $header = fgetcsv($fh); + if (! is_array($header)) { + return []; + } + + $header = array_map(static fn($value) => trim((string) $value), $header); + $rows = []; + + while (($row = fgetcsv($fh)) !== false) { + if ($row === [null]) { + continue; + } + + $assoc = []; + foreach ($header as $index => $column) { + $assoc[$column] = $row[$index] ?? null; + } + + $rows[] = $assoc; + } + + fclose($fh); + + return $rows; + } + + private function resolveStabileId(?string $cod): ?int + { + if ($cod === null || $cod === '') { + return null; + } + + $query = Stabile::query(); + $variants = array_values(array_unique(array_filter([ + $cod, + ltrim($cod, '0'), + str_pad(ltrim($cod, '0') !== '' ? ltrim($cod, '0') : $cod, 4, '0', STR_PAD_LEFT), + ], static fn($value) => $value !== ''))); + + foreach ($variants as $variant) { + $id = $query->clone()->where('codice_stabile', $variant)->value('id'); + if ($id) { + return (int) $id; + } + + $id = $query->clone()->where('cod_stabile', $variant)->value('id'); + if ($id) { + return (int) $id; + } + } + + return null; + } + + private function normalizeString(mixed $value): ?string + { + $normalized = trim((string) $value); + return $normalized !== '' ? $normalized : null; + } + + private function toInt(mixed $value): int + { + $normalized = $this->normalizeString($value); + return $normalized !== null ? (int) preg_replace('/[^0-9-]+/', '', $normalized) : 0; + } + + private function toDecimal(mixed $value): ?float + { + $normalized = $this->normalizeString($value); + if ($normalized === null) { + return null; + } + + if (str_contains($normalized, ',') && str_contains($normalized, '.')) { + $normalized = str_replace('.', '', $normalized); + $normalized = str_replace(',', '.', $normalized); + } elseif (str_contains($normalized, ',')) { + $normalized = str_replace(',', '.', $normalized); + } + + return is_numeric($normalized) ? round((float) $normalized, 2) : null; + } + + private function toBoolean(mixed $value): bool + { + $normalized = strtolower($this->normalizeString($value) ?? ''); + return in_array($normalized, ['1', 'si', 'sì', 'true', 'yes', 'y'], true); + } + + private function toDate(mixed $value): ?string + { + $normalized = $this->normalizeString($value); + if ($normalized === null) { + return null; + } + + $formats = ['m/d/y H:i:s', 'm/d/Y H:i:s', 'm/d/y', 'm/d/Y']; + foreach ($formats as $format) { + $date = \DateTimeImmutable::createFromFormat($format, $normalized); + if ($date instanceof \DateTimeImmutable) { + return $date->format('Y-m-d'); + } + } + + return null; + } + + private function toTime(mixed $value): ?string + { + $normalized = $this->normalizeString($value); + if ($normalized === null) { + return null; + } + + $formats = ['H:i:s', 'H:i']; + foreach ($formats as $format) { + $date = \DateTimeImmutable::createFromFormat($format, $normalized); + if ($date instanceof \DateTimeImmutable) { + return $date->format('H:i:s'); + } + } + + return null; + } + + private function resolveGestioneTipo(mixed $value): ?string + { + return match (strtoupper($this->normalizeString($value) ?? '')) { + 'O' => 'ordinaria', + 'S' => 'straordinaria', + default => $this->normalizeString($value), + }; + } + + private function resolveNaturaLabel(?string $value): ?string + { + return match (strtoupper($value ?? '')) { + 'F' => 'Versamento F24', + 'R' => 'Rata / emissione rata', + default => $this->normalizeString($value), + }; + } +} \ No newline at end of file diff --git a/app/Console/Commands/NetgesconPreupdateBackupCommand.php b/app/Console/Commands/NetgesconPreupdateBackupCommand.php index 493bb4b..437b451 100644 --- a/app/Console/Commands/NetgesconPreupdateBackupCommand.php +++ b/app/Console/Commands/NetgesconPreupdateBackupCommand.php @@ -1,5 +1,4 @@ format('Ymd-His'); - $tag = trim((string) $this->option('tag')) ?: 'auto'; + $stamp = now()->format('Ymd-His'); + $tag = trim((string) $this->option('tag')) ?: 'auto'; $snapshotId = $stamp . '-' . Str::slug($tag, '-'); - $root = storage_path('app/update-backups'); + $root = storage_path('app/update-backups'); $snapshotDir = $root . '/snapshots/' . $snapshotId; - $filesDir = $snapshotDir . '/files'; - $dbDir = $snapshotDir . '/db'; - $metaDir = $snapshotDir . '/meta'; + $filesDir = $snapshotDir . '/files'; + $dbDir = $snapshotDir . '/db'; + $metaDir = $snapshotDir . '/meta'; File::ensureDirectoryExists($filesDir); File::ensureDirectoryExists($dbDir); @@ -51,22 +51,22 @@ public function handle(): int } } - $manifest = $this->buildFileManifest(); + $manifest = $this->buildFileManifest(); $differential = (bool) $this->option('differential'); - $copiedCount = $this->copySnapshotFiles($manifest, $filesDir, $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 !== '')); + $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, + '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'), + '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)); @@ -84,37 +84,49 @@ public function handle(): int $this->zipDirectory($snapshotDir, $zipPath); $latestPayload = [ - 'snapshot_id' => $snapshotId, - 'snapshot_dir' => $snapshotDir, - 'zip_path' => $zipPath, - 'created_at' => now()->toIso8601String(), + 'snapshot_id' => $snapshotId, + 'snapshot_dir' => $snapshotDir, + 'zip_path' => $zipPath, + 'created_at' => now()->toIso8601String(), + 'copied_files' => $copiedCount, + 'tables' => $dbSummary, '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; + $driveInfo = null; + $requireDrive = (bool) $this->option('require-drive'); if ((bool) $this->option('drive')) { $adminId = (int) $this->option('admin-id'); if ($adminId <= 0) { $this->warn('Drive upload saltato: --admin-id mancante.'); + if ($requireDrive) { + return self::FAILURE; + } } 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.'); + if ($requireDrive) { + return self::FAILURE; + } } } } + $latestPayload['drive'] = $driveInfo; + file_put_contents($latestPath, json_encode($latestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $result = [ - 'snapshot_id' => $snapshotId, - 'zip_path' => $zipPath, - 'drive' => $driveInfo, + 'snapshot_id' => $snapshotId, + 'zip_path' => $zipPath, + 'drive' => $driveInfo, 'copied_files' => $copiedCount, - 'tables' => $dbSummary, + 'tables' => $dbSummary, ]; $this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); @@ -125,7 +137,7 @@ public function handle(): int /** @return array */ private function buildFileManifest(): array { - $roots = ['app', 'bootstrap', 'config', 'database', 'resources', 'routes', 'public']; + $roots = ['app', 'bootstrap', 'config', 'database', 'resources', 'routes', 'public']; $manifest = []; foreach ($roots as $root) { @@ -137,7 +149,7 @@ private function buildFileManifest(): array $files = File::allFiles($abs); foreach ($files as $file) { $path = $file->getPathname(); - $rel = ltrim(str_replace(base_path(), '', $path), '/'); + $rel = ltrim(str_replace(base_path(), '', $path), '/'); $hash = hash_file('sha256', $path) ?: ''; if ($hash === '') { continue; @@ -195,22 +207,22 @@ private function exportTables(array $tables, string $dbDir, string $metaDir): ar } $columns = DB::getSchemaBuilder()->getColumnListing($table); - $pk = in_array('id', $columns, true) ? 'id' : ($columns[0] ?? 'id'); + $pk = in_array('id', $columns, true) ? 'id' : ($columns[0] ?? 'id'); - $file = $dbDir . '/' . $table . '.ndjson'; + $file = $dbDir . '/' . $table . '.ndjson'; $indexFile = $metaDir . '/index-' . $table . '.json'; if (is_file($file)) { @unlink($file); } $rowsCount = 0; - $index = []; + $index = []; DB::table($table) ->orderBy($pk) ->chunk(500, function ($rows) use (&$rowsCount, &$index, $file, $pk): void { foreach ($rows as $rowObj) { - $row = (array) $rowObj; + $row = (array) $rowObj; $payload = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (! is_string($payload)) { continue; @@ -218,8 +230,8 @@ private function exportTables(array $tables, string $dbDir, string $metaDir): ar $rowHash = sha1($payload); $pkValue = (string) ($row[$pk] ?? ''); - $record = [ - 'pk' => $pkValue, + $record = [ + 'pk' => $pkValue, 'hash' => $rowHash, 'data' => $row, ]; @@ -235,8 +247,8 @@ private function exportTables(array $tables, string $dbDir, string $metaDir): ar file_put_contents($indexFile, json_encode($index, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $summary[$table] = [ - 'rows' => $rowsCount, - 'file' => $file, + 'rows' => $rowsCount, + 'file' => $file, 'index' => $indexFile, ]; } @@ -271,8 +283,8 @@ private function uploadBackupToDrive(int $adminId, string $zipPath, string $snap } $google = Arr::get($admin->impostazioni ?? [], 'google', []); - $oauth = Arr::get($google, 'oauth', []); - $token = $this->resolveGoogleAccessToken($admin, $google, $oauth); + $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; @@ -283,16 +295,16 @@ private function uploadBackupToDrive(int $adminId, string $zipPath, string $snap $rootFolderId = 'root'; } - $node = (string) config('distribution.node_code', 'node'); - $year = now()->format('Y'); + $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; + $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; @@ -332,10 +344,10 @@ private function ensureDriveFolder(string $token, string $name, string $parentId $response = Http::withToken($token) ->acceptJson() ->get('https://www.googleapis.com/drive/v3/files', [ - 'q' => $query, - 'fields' => 'files(id,name)', - 'pageSize' => 1, - 'supportsAllDrives' => 'true', + 'q' => $query, + 'fields' => 'files(id,name)', + 'pageSize' => 1, + 'supportsAllDrives' => 'true', 'includeItemsFromAllDrives' => 'true', ]); @@ -349,9 +361,9 @@ private function ensureDriveFolder(string $token, string $name, string $parentId $create = Http::withToken($token) ->acceptJson() ->post('https://www.googleapis.com/drive/v3/files?supportsAllDrives=true', [ - 'name' => $name, + 'name' => $name, 'mimeType' => 'application/vnd.google-apps.folder', - 'parents' => [$parentId], + 'parents' => [$parentId], ]); if (! $create->successful()) { @@ -367,24 +379,22 @@ private function ensureDriveFolder(string $token, string $name, string $parentId private function uploadDriveFile(string $token, string $parentId, string $name, string $path, string $mime): ?array { $meta = [ - 'name' => $name, + '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}--"; + $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); + $response = Http::withToken($token) + ->withBody($content, 'multipart/related; boundary=' . $boundary) + ->send('POST', 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,name,webViewLink'); if (! $response->successful()) { $this->warn('Upload Drive fallito: HTTP ' . $response->status()); @@ -397,20 +407,20 @@ private function uploadDriveFile(string $token, string $parentId, string $name, private function resolveGoogleAccessToken(Amministratore $admin, array $google, array $oauth): ?string { - $accessToken = trim((string) Arr::get($oauth, 'access_token', '')); - if ($accessToken !== '') { + $accessToken = trim((string) Arr::get($oauth, 'access_token', '')); + $refreshToken = trim((string) Arr::get($oauth, 'refresh_token', '')); + if ($refreshToken === '' && $accessToken !== '') { return $accessToken; } - $refreshToken = trim((string) Arr::get($oauth, 'refresh_token', '')); if ($refreshToken === '') { return null; } - $settingsClientId = trim((string) Arr::get($google, 'client_id', '')); + $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')); + $configClientId = trim((string) config('services.google.client_id')); + $configClientSecret = trim((string) config('services.google.client_secret')); $pairs = []; if ($settingsClientId !== '' && $settingsClientSecret !== '') { @@ -422,10 +432,10 @@ private function resolveGoogleAccessToken(Amministratore $admin, array $google, foreach ($pairs as [$clientId, $clientSecret]) { $res = Http::asForm()->post('https://oauth2.googleapis.com/token', [ - 'client_id' => $clientId, + 'client_id' => $clientId, 'client_secret' => $clientSecret, 'refresh_token' => $refreshToken, - 'grant_type' => 'refresh_token', + 'grant_type' => 'refresh_token', ]); if (! $res->successful()) { @@ -437,15 +447,15 @@ private function resolveGoogleAccessToken(Amministratore $admin, array $google, continue; } - $settings = $admin->impostazioni ?? []; - $settingsGoogle = Arr::get($settings, 'google', []); - $settingsOauth = Arr::get($settingsGoogle, 'oauth', []); + $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['expires_in'] = (int) $res->json('expires_in', 3600); $settingsOauth['refreshed_at'] = now()->toDateTimeString(); - $settingsGoogle['oauth'] = $settingsOauth; - $settings['google'] = $settingsGoogle; - $admin->impostazioni = $settings; + $settingsGoogle['oauth'] = $settingsOauth; + $settings['google'] = $settingsGoogle; + $admin->impostazioni = $settings; $admin->save(); return $token; diff --git a/app/Filament/Pages/Condomini/LettureServiziArchivio.php b/app/Filament/Pages/Condomini/LettureServiziArchivio.php index 64088c1..1635dfd 100644 --- a/app/Filament/Pages/Condomini/LettureServiziArchivio.php +++ b/app/Filament/Pages/Condomini/LettureServiziArchivio.php @@ -3,6 +3,7 @@ use App\Filament\Pages\Contabilita\FatturaElettronicaScheda; use App\Models\Fornitore; +use App\Models\Stabile; use App\Models\StabileServizio; use App\Models\StabileServizioLettura; use App\Models\UnitaImmobiliare; @@ -30,6 +31,7 @@ class LettureServiziArchivio extends Page implements HasTable use InteractsWithTable; public ?int $servizioFilter = null; + public string $archivioScope = 'active'; protected static ?string $navigationLabel = 'Letture servizi'; @@ -58,6 +60,8 @@ public function mount(): void { $reqServizio = request()->integer('servizio'); $this->servizioFilter = $reqServizio > 0 ? $reqServizio : null; + $scope = strtolower(trim((string) request()->query('scope', 'active'))); + $this->archivioScope = in_array($scope, ['active', 'all'], true) ? $scope : 'active'; $this->mountInteractsWithTable(); } @@ -69,15 +73,30 @@ protected function getTableQuery(): Builder return StabileServizioLettura::query()->whereRaw('1 = 0'); } - $stabileId = StabileContext::resolveActiveStabileId($user); - if (! $stabileId) { + $stabiliIds = StabileContext::accessibleStabili($user) + ->pluck('id') + ->map(fn($id) => (int) $id) + ->filter(fn(int $id): bool => $id > 0) + ->values(); + + if ($stabiliIds->isEmpty()) { + return StabileServizioLettura::query()->whereRaw('1 = 0'); + } + + $activeStabileId = StabileContext::resolveActiveStabileId($user); + if ($this->archivioScope !== 'all' && ! $activeStabileId) { return StabileServizioLettura::query()->whereRaw('1 = 0'); } return StabileServizioLettura::query() - ->where('stabile_id', (int) $stabileId) + ->when( + $this->archivioScope === 'all', + fn(Builder $q) => $q->whereIn('stabile_id', $stabiliIds->all()), + fn(Builder $q) => $q->where('stabile_id', (int) $activeStabileId) + ) ->when($this->servizioFilter, fn(Builder $q) => $q->where('stabile_servizio_id', (int) $this->servizioFilter)) ->with([ + 'stabile:id,codice_stabile,denominazione', 'servizio:id,stabile_id,tipo,nome,contatore_matricola', 'fornitore:id,ragione_sociale', 'voceSpesa:id,descrizione,tipo_gestione', @@ -130,6 +149,19 @@ public function table(Table $table): Table return $query->whereHas('servizio', fn(Builder $q) => $q->where('tipo', $value)); }), + SelectFilter::make('stabile_id') + ->label('Stabile') + ->options(fn() => $this->getStabiliOptions()) + ->searchable() + ->query(function (Builder $query, array $data): Builder { + $value = (int) ($data['value'] ?? 0); + if ($value <= 0) { + return $query; + } + + return $query->where('stabile_id', $value); + }), + SelectFilter::make('tipo_gestione') ->label('Gestione (O/R/S)') ->options($gestioneOptions) @@ -146,6 +178,22 @@ public function table(Table $table): Table TextColumn::make('periodo_dal')->label('Dal')->date('d/m/Y')->sortable()->toggleable(), TextColumn::make('periodo_al')->label('Al')->date('d/m/Y')->sortable(), + TextColumn::make('stabile.denominazione') + ->label('Stabile') + ->formatStateUsing(function ($state, StabileServizioLettura $record): string { + $stabile = $record->stabile; + if (! $stabile instanceof Stabile) { + return '—'; + } + + $codice = trim((string) ($stabile->codice_stabile ?? '')); + $nome = trim((string) ($stabile->denominazione ?? '')); + + return trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id); + }) + ->wrap() + ->toggleable(isToggledHiddenByDefault: $this->archivioScope !== 'all'), + TextColumn::make('servizio.nome') ->label('Servizio') ->formatStateUsing(function ($state, StabileServizioLettura $record): string { @@ -217,8 +265,38 @@ public function table(Table $table): Table }) ->toggleable(), + TextColumn::make('workflow_stato') + ->label('Workflow') + ->formatStateUsing(fn($state): string => trim((string) $state) !== '' ? (string) $state : 'Acquisita') + ->badge() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('protocollo_numero') + ->label('Protocollo') + ->formatStateUsing(function ($state, StabileServizioLettura $record): string { + $numero = trim((string) ($state ?? '')); + $categoria = trim((string) ($record->protocollo_categoria ?? '')); + + if ($numero === '' && $categoria === '') { + return '—'; + } + + return trim($categoria . ($numero !== '' ? (' #' . $numero) : '')); + }) + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('prossima_lettura_scadenza_at') + ->label('Scadenza prossima lettura') + ->dateTime('d/m/Y H:i') + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('riferimento_acquisizione')->label('Riferimento')->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('archivio_documentale_path') + ->label('Archivio documentale') + ->wrap() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('fatturaElettronica.numero_fattura') ->label('Fattura FE') ->url(fn(StabileServizioLettura $record): ?string => $record->fattura_elettronica_id @@ -238,6 +316,14 @@ public function table(Table $table): Table TextColumn::make('created_at')->label('Creato')->dateTime('d/m/Y H:i')->toggleable(isToggledHiddenByDefault: true), ]) ->headerActions([ + Action::make('toggleScope') + ->label(fn(): string => $this->archivioScope === 'all' ? 'Vista: tutti gli stabili' : 'Vista: stabile attivo') + ->icon('heroicon-o-building-office-2') + ->color(fn(): string => $this->archivioScope === 'all' ? 'primary' : 'gray') + ->action(function (): void { + $this->archivioScope = $this->archivioScope === 'all' ? 'active' : 'all'; + }), + Action::make('create') ->label('Nuova') ->icon('heroicon-o-plus') @@ -274,7 +360,23 @@ public function table(Table $table): Table 'm_bus' => 'Contatore elettronico', 'pdf_ocr' => 'PDF/OCR', ]), + Select::make('workflow_stato') + ->label('Workflow') + ->options([ + 'acquisita' => 'Acquisita', + 'da_richiedere' => 'Da richiedere', + 'richiesta_inviata' => 'Richiesta inviata', + 'ricevuta' => 'Ricevuta', + 'protocollata' => 'Protocollata', + 'archiviata' => 'Archiviata', + ]) + ->default('acquisita'), + TextInput::make('protocollo_categoria')->label('Categoria protocollo')->maxLength(40), + TextInput::make('protocollo_numero')->label('Numero protocollo')->maxLength(50), TextInput::make('riferimento_acquisizione')->label('Riferimento acquisizione')->maxLength(191), + DatePicker::make('richiesta_lettura_inviata_at')->label('Richiesta inviata il')->native(false), + DatePicker::make('prossima_lettura_scadenza_at')->label('Prossima scadenza lettura')->native(false), + TextInput::make('archivio_documentale_path')->label('Path archivio documentale')->maxLength(500), TextInput::make('lettura_inizio')->label('Lettura inizio')->numeric(), TextInput::make('lettura_fine')->label('Lettura fine')->numeric(), TextInput::make('consumo_valore')->label('Consumo valore')->numeric(), @@ -302,7 +404,13 @@ public function table(Table $table): Table 'periodo_al' => $data['periodo_al'] ?? null, 'tipologia_lettura' => $data['tipologia_lettura'] ?? null, 'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: 'manuale', + 'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: 'acquisita', + 'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null, + 'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null, 'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null, + 'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null, + 'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null, + 'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null, 'lettura_inizio' => $data['lettura_inizio'] ?? null, 'lettura_fine' => $data['lettura_fine'] ?? null, 'consumo_valore' => $data['consumo_valore'] ?? null, @@ -365,7 +473,13 @@ public function table(Table $table): Table 'periodo_al' => $record->periodo_al, 'tipologia_lettura' => (string) ($record->tipologia_lettura ?? ''), 'canale_acquisizione' => (string) ($record->canale_acquisizione ?? ''), + 'workflow_stato' => (string) ($record->workflow_stato ?? ''), + 'protocollo_categoria' => (string) ($record->protocollo_categoria ?? ''), + 'protocollo_numero' => (string) ($record->protocollo_numero ?? ''), 'riferimento_acquisizione' => (string) ($record->riferimento_acquisizione ?? ''), + 'richiesta_lettura_inviata_at' => $record->richiesta_lettura_inviata_at, + 'prossima_lettura_scadenza_at' => $record->prossima_lettura_scadenza_at, + 'archivio_documentale_path' => (string) ($record->archivio_documentale_path ?? ''), 'lettura_inizio' => $record->lettura_inizio, 'lettura_fine' => $record->lettura_fine, 'consumo_valore' => $record->consumo_valore, @@ -382,7 +496,13 @@ public function table(Table $table): Table 'periodo_al' => $data['periodo_al'] ?? null, 'tipologia_lettura' => $data['tipologia_lettura'] ?? null, 'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: null, + 'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: null, + 'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null, + 'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null, 'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null, + 'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null, + 'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null, + 'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null, 'lettura_inizio' => $data['lettura_inizio'] ?? null, 'lettura_fine' => $data['lettura_fine'] ?? null, 'consumo_valore' => $data['consumo_valore'] ?? null, @@ -436,6 +556,27 @@ private function getServiziOptions(): array ->all(); } + /** + * @return array + */ + private function getStabiliOptions(): array + { + $user = Auth::user(); + if (! $user instanceof User) { + return []; + } + + return StabileContext::accessibleStabili($user) + ->mapWithKeys(function (Stabile $stabile): array { + $codice = trim((string) ($stabile->codice_stabile ?? '')); + $nome = trim((string) ($stabile->denominazione ?? '')); + $label = trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id); + + return [(int) $stabile->id => $label]; + }) + ->all(); + } + /** * @return array */ diff --git a/app/Filament/Pages/Gescon/FornitoriArchivio.php b/app/Filament/Pages/Gescon/FornitoriArchivio.php index 2483c4c..3441d5b 100644 --- a/app/Filament/Pages/Gescon/FornitoriArchivio.php +++ b/app/Filament/Pages/Gescon/FornitoriArchivio.php @@ -24,6 +24,9 @@ class FornitoriArchivio extends Page public string $activeTab = 'elenco'; public ?int $selectedFornitoreId = null; + /** @var Collection */ + public $fornitoreMatches; + /** @var array */ public array $automazioni = [ 'fornitore_acqua' => '0', @@ -64,6 +67,9 @@ class FornitoriArchivio extends Page public string $newDipendenteEmail = ''; public string $newDipendenteTelefono = ''; + /** @var array> */ + public array $dipendenteInline = []; + protected static ?string $navigationLabel = 'Fornitori'; protected static ?string $title = 'Fornitori'; @@ -93,7 +99,9 @@ public function mount(): void $this->fornitoriCount = 0; } + $this->fornitoreMatches = new Collection(); $this->searchInput = $this->fornitoriSearch; + $this->searchFornitoreMatches(); } protected function getTableQuery(): Builder @@ -131,31 +139,22 @@ public function getFornitoriRowsProperty(): Collection ->orderBy('cognome') ->orderBy('nome'); - $term = trim($this->fornitoriSearch); - if ($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); - - if ($hasTags) { - $sub->orWhere('tags', 'like', $like); - } - }); - } + $this->applyFornitoreSearch($query, trim($this->fornitoriSearch)); return $query->limit(400)->get(); } public function updatedFornitoriSearch(): void - {} + { + $this->searchInput = $this->fornitoriSearch; + $this->searchFornitoreMatches(); + } + + public function updatedSearchInput(): void + { + $this->fornitoriSearch = trim($this->searchInput); + $this->searchFornitoreMatches(); + } public function getFornitoreLabel(Fornitore $fornitore): string { @@ -277,6 +276,12 @@ public function apriElenco(): void $this->activeTab = 'elenco'; } + public function apriNuovoFornitore(): void + { + $this->selectedFornitoreId = null; + $this->activeTab = 'scheda'; + } + public function apriTabScheda(): void { if ((int) ($this->selectedFornitoreId ?? 0) <= 0) { @@ -310,6 +315,7 @@ public function apriTabDipendenti(): void public function applicaRicerca(): void { $this->fornitoriSearch = trim($this->searchInput); + $this->searchFornitoreMatches(); $this->activeTab = 'elenco'; } @@ -317,6 +323,7 @@ public function pulisciRicerca(): void { $this->fornitoriSearch = ''; $this->searchInput = ''; + $this->searchFornitoreMatches(); $this->activeTab = 'elenco'; } @@ -329,6 +336,7 @@ public function removeSearchFilter(string $token): void $this->fornitoriSearch = implode(' e ', $tokens); $this->searchInput = $this->fornitoriSearch; + $this->searchFornitoreMatches(); $this->activeTab = 'elenco'; } @@ -343,11 +351,14 @@ public function apriScheda(int $fornitoreId): void $this->selectedFornitoreId = (int) $fornitore->id; $this->activeTab = 'scheda'; $this->loadSchedaState($fornitore); + $this->loadDipendentiInlineState(); } public function updatedSelectedFornitoreId(): void { - $this->hydrateSchedaFromSelected(); + if (in_array($this->activeTab, ['scheda', 'dipendenti'], true)) { + $this->hydrateSchedaFromSelected(); + } } public function createFornitoreRapido(): void @@ -402,6 +413,9 @@ public function createFornitoreRapido(): void $this->fornitoriCount = (int) $this->getTableQuery()->count(); $this->apriScheda((int) $fornitore->id); + $this->searchInput = $this->getFornitoreLabel($fornitore); + $this->fornitoriSearch = $this->searchInput; + $this->searchFornitoreMatches(); Notification::make()->title('Fornitore creato')->success()->send(); } @@ -462,6 +476,7 @@ public function saveSchedaAnagrafica(): void $fornitore->save(); $this->loadSchedaState($fornitore->fresh()); + $this->searchFornitoreMatches(); Notification::make()->title('Anagrafica fornitore aggiornata')->success()->send(); } @@ -506,10 +521,43 @@ public function addDipendenteManuale(): void $this->newDipendenteCognome = ''; $this->newDipendenteEmail = ''; $this->newDipendenteTelefono = ''; + $this->loadDipendentiInlineState(); Notification::make()->title('Dipendente aggiunto')->success()->send(); } + public function createDipendenteRubricaAndLink(): void + { + $fornitore = $this->selectedFornitore; + if (! $fornitore instanceof Fornitore) { + Notification::make()->title('Seleziona prima un fornitore')->warning()->send(); + return; + } + + $nome = trim($this->newDipendenteNome); + if ($nome === '') { + Notification::make()->title('Nome nominativo obbligatorio')->warning()->send(); + return; + } + + $rubrica = RubricaUniversale::query()->create([ + 'nome' => $nome, + 'cognome' => $this->cleanNullable($this->newDipendenteCognome), + 'email' => $this->cleanNullable($this->newDipendenteEmail), + 'telefono_cellulare' => $this->cleanNullable($this->newDipendenteTelefono), + 'tipo_contatto' => 'persona_fisica', + 'categoria' => 'fornitore', + 'stato' => 'attivo', + 'note' => 'Creato da Fornitori > Dipendenti per il fornitore #' . (int) $fornitore->id, + 'data_inserimento' => now()->toDateString(), + 'data_ultima_modifica' => now()->toDateString(), + 'creato_da' => Auth::id(), + 'modificato_da' => Auth::id(), + ]); + + $this->addDipendenteFromRubrica((int) $rubrica->id); + } + public function addDipendenteFromRubrica(int $rubricaId): void { $fornitore = $this->selectedFornitore; @@ -581,10 +629,51 @@ public function addDipendenteFromRubrica(int $rubricaId): void $dipendente->note = Str::limit(trim($linkNote . ($existingNote !== '' ? (' | ' . $existingNote) : '')), 2000, ''); $dipendente->save(); + $this->loadDipendentiInlineState(); + Notification::make()->title('Dipendente collegato al fornitore')->success()->send(); $this->activeTab = 'dipendenti'; } + public function saveDipendenteInline(int $dipendenteId): void + { + $fornitore = $this->selectedFornitore; + if (! $fornitore instanceof Fornitore) { + Notification::make()->title('Seleziona prima un fornitore')->warning()->send(); + return; + } + + $dipendente = FornitoreDipendente::query() + ->where('fornitore_id', (int) $fornitore->id) + ->find($dipendenteId); + + if (! $dipendente instanceof FornitoreDipendente) { + Notification::make()->title('Dipendente non trovato')->danger()->send(); + return; + } + + $state = $this->dipendenteInline[$dipendenteId] ?? []; + $data = Validator::make($state, [ + 'nome' => ['required', 'string', 'max:255'], + 'cognome' => ['nullable', 'string', 'max:255'], + 'email' => ['nullable', 'email', 'max:255'], + 'telefono' => ['nullable', 'string', 'max:64'], + 'attivo' => ['nullable', 'boolean'], + ])->validate(); + + $dipendente->nome = trim((string) ($data['nome'] ?? '')); + $dipendente->cognome = $this->cleanNullable($data['cognome'] ?? null); + $dipendente->email = $this->cleanNullable($data['email'] ?? null); + $dipendente->telefono = $this->cleanNullable($data['telefono'] ?? null); + $dipendente->attivo = (bool) ($data['attivo'] ?? false); + $dipendente->updated_by_user_id = Auth::id(); + $dipendente->save(); + + $this->loadDipendentiInlineState(); + + Notification::make()->title('Dipendente aggiornato')->success()->send(); + } + public function saveSchedaAutomazioni(): void { $fornitore = $this->selectedFornitore; @@ -654,6 +743,23 @@ private function loadSchedaState(Fornitore $fornitore): void $this->editNote = (string) ($fornitore->note ?? ''); } + private function loadDipendentiInlineState(): void + { + $rows = $this->getDipendentiRowsProperty(); + + $this->dipendenteInline = $rows->mapWithKeys(function (FornitoreDipendente $dipendente): array { + return [ + (int) $dipendente->id => [ + 'nome' => (string) ($dipendente->nome ?? ''), + 'cognome' => (string) ($dipendente->cognome ?? ''), + 'email' => (string) ($dipendente->email ?? ''), + 'telefono' => (string) ($dipendente->telefono ?? ''), + 'attivo' => (bool) ($dipendente->attivo ?? false), + ], + ]; + })->all(); + } + private function resolveAmministratoreId(): int { $user = Auth::user(); @@ -670,7 +776,7 @@ private function resolveAmministratoreId(): int return (int) ($stabile?->amministratore_id ?? 0); } - private function hydrateSchedaFromSelected(): void + public function hydrateSchedaFromSelected(): void { if ((int) ($this->selectedFornitoreId ?? 0) <= 0) { return; @@ -682,6 +788,67 @@ private function hydrateSchedaFromSelected(): void } $this->loadSchedaState($fornitore); + $this->loadDipendentiInlineState(); + } + + private function applyFornitoreSearch(Builder $query, string $raw): Builder + { + $tokens = $this->extractSearchTokens($raw); + if ($tokens === []) { + return $query; + } + $hasTags = Schema::hasColumn('fornitori', 'tags'); + + foreach ($tokens as $token) { + $term = trim($token); + if ($term === '') { + continue; + } + + $digits = preg_replace('/\D+/', '', $term) ?: ''; + $needleText = '%' . mb_strtolower($term) . '%'; + $needle = '%' . $digits . '%'; + + $query->where(function (Builder $sub) use ($needleText, $needle, $digits, $hasTags): void { + $sub->whereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$needleText]) + ->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$needleText]) + ->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$needleText]) + ->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$needleText]) + ->orWhereRaw("LOWER(COALESCE(partita_iva, '')) LIKE ?", [$needleText]) + ->orWhereRaw("LOWER(COALESCE(codice_fiscale, '')) LIKE ?", [$needleText]); + + if ($hasTags) { + $sub->orWhereRaw("LOWER(COALESCE(tags, '')) LIKE ?", [$needleText]); + } + + if ($digits !== '') { + $sub->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]) + ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]); + } + }); + } + + return $query; + } + + private function searchFornitoreMatches(): void + { + $raw = trim($this->searchInput !== '' ? $this->searchInput : $this->fornitoriSearch); + if ($raw === '') { + $this->fornitoreMatches = new Collection(); + return; + } + + $query = $this->getTableQuery() + ->withCount('dipendenti') + ->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedFornitoreId ?? 0)]) + ->orderBy('ragione_sociale') + ->orderBy('cognome') + ->orderBy('nome'); + + $this->fornitoreMatches = $this->applyFornitoreSearch($query, $raw) + ->limit(12) + ->get(); } private function cleanNullable(?string $value): ?string diff --git a/app/Filament/Pages/Gescon/RubricaUniversaleArchivio.php b/app/Filament/Pages/Gescon/RubricaUniversaleArchivio.php index 68dadd8..38be7b8 100644 --- a/app/Filament/Pages/Gescon/RubricaUniversaleArchivio.php +++ b/app/Filament/Pages/Gescon/RubricaUniversaleArchivio.php @@ -133,14 +133,39 @@ public function table(Table $table): Table return $query; } - $like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search) . '%'; + $tokens = preg_split('/(?:[,;|+]| + \s(?:e|ed|and)\s| + \s+ + )+/ux', $search) ?: []; + $tokens = array_values(array_filter(array_map(static fn(string $value): string => trim($value), $tokens), static fn(string $value): bool => $value !== '')); - return $query->where(function (Builder $q) use ($like) { - $q->where('ragione_sociale', 'like', $like) - ->orWhere('nome', 'like', $like) - ->orWhere('cognome', 'like', $like) - ->orWhereRaw("TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, ''))) LIKE ?", [$like]); - }); + if ($tokens === []) { + $tokens = [$search]; + } + + foreach ($tokens as $token) { + $like = '%' . mb_strtolower($token) . '%'; + $digits = preg_replace('/\D+/', '', $token) ?: ''; + $query->where(function (Builder $q) use ($like, $digits) { + $q->whereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$like]) + ->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$like]) + ->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$like]) + ->orWhereRaw("LOWER(TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, '')))) LIKE ?", [$like]) + ->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$like]) + ->orWhereRaw("LOWER(COALESCE(pec, '')) LIKE ?", [$like]) + ->orWhereRaw("LOWER(COALESCE(codice_fiscale, '')) LIKE ?", [$like]) + ->orWhereRaw("LOWER(COALESCE(partita_iva, '')) LIKE ?", [$like]); + + if ($digits !== '') { + $digitLike = '%' . $digits . '%'; + $q->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$digitLike]) + ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$digitLike]) + ->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$digitLike]); + } + }); + } + + return $query; }), TextColumn::make('categoria')->label('Categoria')->sortable()->toggleable(), TextColumn::make('partita_iva')->label('P.IVA')->searchable()->toggleable(isToggledHiddenByDefault: true), diff --git a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php index 82f5581..79e4817 100644 --- a/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php +++ b/app/Filament/Pages/Gescon/RubricaUniversaleScheda.php @@ -3,6 +3,8 @@ use App\Models\Fornitore; use App\Models\FornitoreDipendente; +use App\Models\Persona; +use App\Models\PersonaEmailMultipla; use App\Models\RubricaContattoCanale; use App\Models\RubricaRuolo; use App\Models\RubricaUniversale; @@ -20,6 +22,7 @@ use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; use Filament\Pages\Page; +use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilderContract; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -91,6 +94,9 @@ class RubricaUniversaleScheda extends Page /** @var array */ public array $inlineForm = []; + /** @var array> */ + public array $emailMultiple = []; + public function mount(int | string $record): void { $user = Auth::user(); @@ -259,6 +265,7 @@ public function mount(int | string $record): void ]) ->all(); + $this->hydrateEmailMultiple(); $this->fillInlineForm(); $this->hydrateFornitoriWorkspace(); } @@ -277,6 +284,9 @@ public function cancelInlineEdit(): void public function saveInlineEdit(): void { + $legacyIdentity = $this->extractLegacyIdentityFromNote($this->inlineForm['note'] ?? null); + $resolvedAddress = $this->parseAddressComponents($this->inlineForm['indirizzo_completo'] ?? null); + $data = validator($this->inlineForm, [ 'nome' => ['nullable', 'string', 'max:255'], 'cognome' => ['nullable', 'string', 'max:255'], @@ -285,11 +295,21 @@ public function saveInlineEdit(): void 'categoria' => ['nullable', 'string', 'max:120'], 'codice_fiscale' => ['nullable', 'string', 'max:32'], 'partita_iva' => ['nullable', 'string', 'max:32'], + 'sesso' => ['nullable', 'string', 'max:1'], + 'data_nascita' => ['nullable', 'date'], + 'luogo_nascita' => ['nullable', 'string', 'max:100'], + 'provincia_nascita' => ['nullable', 'string', 'max:2'], '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' => ['nullable', 'string', 'max:255'], + 'civico' => ['nullable', 'string', 'max:32'], + 'cap' => ['nullable', 'string', 'max:5'], + 'citta' => ['nullable', 'string', 'max:100'], + 'provincia' => ['nullable', 'string', 'max:2'], + 'nazione' => ['nullable', 'string', 'max:50'], 'indirizzo_completo' => ['nullable', 'string', 'max:255'], 'tipo_utenza_call' => ['nullable', 'string', 'max:120'], 'riferimento_stabile' => ['nullable', 'string', 'max:255'], @@ -298,6 +318,17 @@ public function saveInlineEdit(): void 'note_segreteria' => ['nullable', 'string', 'max:5000'], ])->validate(); + $normalizedBirthDate = $this->normalizeLegacyDateValue($data['data_nascita'] ?? $legacyIdentity['data_nascita'] ?? null); + $cleanNote = $this->cleanNullable($legacyIdentity['note_clean'] ?? ($data['note'] ?? null)); + $sesso = $this->normalizeSessoValue($data['sesso'] ?? $legacyIdentity['sesso'] ?? null); + $provinciaNascita = $this->normalizeProvince($data['provincia_nascita'] ?? $legacyIdentity['provincia_nascita'] ?? null); + + $indirizzo = $this->cleanNullable($data['indirizzo'] ?? null) ?: ($resolvedAddress['indirizzo'] ?? null); + $civico = $this->cleanNullable($data['civico'] ?? null) ?: ($resolvedAddress['civico'] ?? null); + $cap = $this->cleanNullable($data['cap'] ?? null) ?: ($resolvedAddress['cap'] ?? null); + $citta = $this->cleanNullable($data['citta'] ?? null) ?: ($resolvedAddress['citta'] ?? null); + $provincia = $this->normalizeProvince($data['provincia'] ?? null) ?: ($resolvedAddress['provincia'] ?? null); + $this->rubrica->fill([ 'nome' => $this->cleanNullable($data['nome'] ?? null), 'cognome' => $this->cleanNullable($data['cognome'] ?? null), @@ -306,21 +337,34 @@ public function saveInlineEdit(): void 'categoria' => $this->cleanNullable($data['categoria'] ?? null), 'codice_fiscale' => $this->cleanNullable($data['codice_fiscale'] ?? null), 'partita_iva' => $this->cleanNullable($data['partita_iva'] ?? null), + 'sesso' => $sesso, + 'data_nascita' => $normalizedBirthDate, + 'luogo_nascita' => $this->cleanNullable($data['luogo_nascita'] ?? $legacyIdentity['luogo_nascita'] ?? null), + 'provincia_nascita' => $provinciaNascita, '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), + 'indirizzo' => $indirizzo, + 'civico' => $civico, + 'cap' => $cap, + 'citta' => $citta, + 'provincia' => $provincia, + 'nazione' => $this->cleanNullable($data['nazione'] ?? null) ?: $this->rubrica->nazione, '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' => $cleanNote, 'note_segreteria' => $this->cleanNullable($data['note_segreteria'] ?? null), 'data_ultima_modifica' => now()->toDateString(), 'modificato_da' => Auth::id(), ]); + if (! $this->rubrica->titolo_id && ! empty($legacyIdentity['titolo_id'])) { + $this->rubrica->titolo_id = (int) $legacyIdentity['titolo_id']; + } + $this->rubrica->save(); $this->mount((int) $this->rubrica->id); @@ -547,6 +591,53 @@ protected function getHeaderActions(): array $this->startInlineEdit(); }), + Action::make('email_multiple') + ->label('Email aggiuntive') + ->icon('heroicon-o-envelope') + ->modalWidth('4xl') + ->form([ + Repeater::make('emails') + ->label('Email aggiuntive') + ->defaultItems(0) + ->reorderable(true) + ->schema([ + Hidden::make('id'), + TextInput::make('email') + ->label('Email') + ->email() + ->required() + ->maxLength(255) + ->columnSpan(6), + Select::make('tipo_email') + ->label('Tipo') + ->native(false) + ->options([ + 'secondaria' => 'Secondaria', + 'lavoro' => 'Lavoro', + 'personale' => 'Personale', + 'pec' => 'PEC', + 'altro' => 'Altro', + ]) + ->default('secondaria') + ->columnSpan(4), + Toggle::make('attiva') + ->label('Attiva') + ->default(true) + ->columnSpan(2), + ]) + ->columns(12), + ]) + ->fillForm(fn(): array=> ['emails' => $this->getAdditionalEmailsFormRows()]) + ->action(function (array $data): void { + $this->saveAdditionalEmails($data['emails'] ?? []); + $this->mount((int) $this->rubrica->id); + + Notification::make() + ->title('Email aggiuntive aggiornate') + ->success() + ->send(); + }), + Action::make('contatti_avanzati') ->label('Contatti avanzati') ->icon('heroicon-o-rectangle-stack') @@ -906,6 +997,9 @@ protected function getHeaderActions(): array private function fillInlineForm(): void { + $legacyIdentity = $this->extractLegacyIdentityFromNote($this->rubrica->note ?? null); + $resolvedAddress = $this->parseAddressComponents($this->rubrica->indirizzo_completo ?? null); + $this->inlineForm = [ 'nome' => (string) ($this->rubrica->nome ?? ''), 'cognome' => (string) ($this->rubrica->cognome ?? ''), @@ -914,16 +1008,26 @@ private function fillInlineForm(): void 'categoria' => (string) ($this->rubrica->categoria ?? ''), 'codice_fiscale' => (string) ($this->rubrica->codice_fiscale ?? ''), 'partita_iva' => (string) ($this->rubrica->partita_iva ?? ''), + 'sesso' => (string) ($this->rubrica->sesso ?? $legacyIdentity['sesso'] ?? ''), + 'data_nascita' => (string) (($this->rubrica->data_nascita?->format('Y-m-d')) ?? $legacyIdentity['data_nascita'] ?? ''), + 'luogo_nascita' => (string) ($this->rubrica->luogo_nascita ?? $legacyIdentity['luogo_nascita'] ?? ''), + 'provincia_nascita' => (string) ($this->rubrica->provincia_nascita ?? $legacyIdentity['provincia_nascita'] ?? ''), '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' => (string) ($this->rubrica->indirizzo ?? $resolvedAddress['indirizzo'] ?? ''), + 'civico' => (string) ($this->rubrica->civico ?? $resolvedAddress['civico'] ?? ''), + 'cap' => (string) ($this->rubrica->cap ?? $resolvedAddress['cap'] ?? ''), + 'citta' => (string) ($this->rubrica->citta ?? $resolvedAddress['citta'] ?? ''), + 'provincia' => (string) ($this->rubrica->provincia ?? $resolvedAddress['provincia'] ?? ''), + 'nazione' => (string) ($this->rubrica->nazione ?? 'Italia'), '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' => (string) ($legacyIdentity['note_clean'] ?? $this->rubrica->note ?? ''), 'note_segreteria' => (string) ($this->rubrica->note_segreteria ?? ''), ]; } @@ -939,6 +1043,395 @@ private function cleanNullable(mixed $value): mixed return $trim === '' ? null : $trim; } + /** @return array */ + private function extractLegacyIdentityFromNote(mixed $note): array + { + $raw = trim((string) ($note ?? '')); + if ($raw === '') { + return [ + 'note_clean' => null, + 'titolo_id' => null, + 'sesso' => null, + 'data_nascita' => null, + 'luogo_nascita' => null, + 'provincia_nascita' => null, + ]; + } + + $parsed = [ + 'titolo_id' => null, + 'sesso' => null, + 'data_nascita' => null, + 'luogo_nascita' => null, + 'provincia_nascita' => null, + ]; + + $cleanLines = []; + foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) { + $trimmed = trim($line); + if (! str_starts_with($trimmed, 'Dati anagrafici legacy:')) { + $cleanLines[] = $line; + continue; + } + + $payload = trim(substr($trimmed, strlen('Dati anagrafici legacy:'))); + $bits = preg_split('/\s*\|\s*/', $payload) ?: []; + foreach ($bits as $bit) { + [$key, $value] = array_pad(explode(':', $bit, 2), 2, null); + $key = mb_strtolower(trim((string) $key)); + $value = trim((string) $value); + if ($value === '') { + continue; + } + + if ($key === 'titolo') { + $parsed['titolo_id'] = $this->resolveTitoloId($value); + continue; + } + if ($key === 'sesso') { + $parsed['sesso'] = $this->normalizeSessoValue($value); + continue; + } + if ($key === 'data nascita') { + $parsed['data_nascita'] = $this->normalizeLegacyDateValue($value); + continue; + } + if ($key === 'luogo nascita') { + $parsed['luogo_nascita'] = $value; + continue; + } + if ($key === 'provincia nascita') { + $parsed['provincia_nascita'] = $this->normalizeProvince($value); + } + } + } + + $cleanLines = array_values(array_filter($cleanLines, fn($line) => trim((string) $line) !== '')); + + return $parsed + [ + 'note_clean' => $cleanLines === [] ? null : implode("\n", $cleanLines), + ]; + } + + /** @return array */ + private function parseAddressComponents(mixed $value): array + { + $full = trim((string) ($value ?? '')); + if ($full === '') { + return [ + 'indirizzo' => null, + 'civico' => null, + 'cap' => null, + 'citta' => null, + 'provincia' => null, + ]; + } + + $parts = [ + 'indirizzo' => $full, + 'civico' => null, + 'cap' => null, + 'citta' => null, + 'provincia' => null, + ]; + + if (preg_match('/\((?[A-Za-z]{2})\)\s*$/', $full, $provinceMatch) === 1) { + $parts['provincia'] = strtoupper($provinceMatch['provincia']); + $full = trim(substr($full, 0, -strlen($provinceMatch[0]))); + } + + if (preg_match('/\b(?\d{5})\b/', $full, $capMatch, PREG_OFFSET_CAPTURE) === 1) { + $parts['cap'] = $capMatch['cap'][0]; + $capPos = $capMatch['cap'][1]; + $beforeCap = trim(substr($full, 0, $capPos)); + $afterCap = trim(substr($full, $capPos + 5)); + if ($afterCap !== '') { + $parts['citta'] = $afterCap; + } + $full = $beforeCap; + } + + if (preg_match('/^(?.*?)(?:,\s*|\s+)(?\d+[A-Za-z\/-]*)$/', $full, $streetMatch) === 1) { + $parts['indirizzo'] = trim($streetMatch['indirizzo']) ?: null; + $parts['civico'] = trim($streetMatch['civico']) ?: null; + } else { + $parts['indirizzo'] = $full; + } + + return $parts; + } + + private function normalizeLegacyDateValue(mixed $value): ?string + { + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d'); + } + + $raw = trim((string) ($value ?? '')); + if ($raw === '') { + return null; + } + + foreach (['Y-m-d', 'd/m/Y', 'd-m-Y', 'd.m.Y', 'd/m/y', 'd-m-y', 'd.m.y', 'm/d/Y', 'm/d/y'] as $format) { + $dt = \DateTime::createFromFormat($format, $raw); + if ($dt instanceof \DateTime) { + return $this->normalizeCenturyDrift($dt)->format('Y-m-d'); + } + } + + $ts = strtotime($raw); + if ($ts === false) { + return null; + } + + return $this->normalizeCenturyDrift((new \DateTime())->setTimestamp($ts))->format('Y-m-d'); + } + + private function normalizeCenturyDrift(\DateTime $date): \DateTime + { + $thresholdYear = (int) date('Y') + 1; + if ((int) $date->format('Y') > $thresholdYear) { + $date->modify('-100 years'); + } + + return $date; + } + + private function normalizeSessoValue(mixed $value): ?string + { + $raw = strtoupper(trim((string) ($value ?? ''))); + if ($raw === '') { + return null; + } + if ($raw === 'M' || str_starts_with($raw, 'MAS')) { + return 'M'; + } + if ($raw === 'F' || str_starts_with($raw, 'FEM')) { + return 'F'; + } + + return null; + } + + private function normalizeProvince(mixed $value): ?string + { + $raw = strtoupper(trim((string) ($value ?? ''))); + if ($raw === '') { + return null; + } + + return substr($raw, 0, 2); + } + + private function resolveTitoloId(?string $raw): ?int + { + $raw = trim((string) ($raw ?? '')); + if ($raw === '' || ! Schema::hasTable('titoli')) { + return null; + } + + $key = mb_strtolower($raw); + $title = Titolo::query() + ->whereRaw('LOWER(sigla) = ?', [$key]) + ->orWhereRaw('LOWER(nome) = ?', [$key]) + ->first(); + + return $title?->id ? (int) $title->id : null; + } + + private function hydrateEmailMultiple(): void + { + $persona = $this->resolvePersonaForRubrica(false); + if (! $persona instanceof Persona) { + $this->emailMultiple = []; + return; + } + + $this->emailMultiple = $persona->emailMultiple() + ->orderByDesc('attiva') + ->orderBy('tipo_email') + ->orderBy('email') + ->get() + ->map(fn(PersonaEmailMultipla $row): array=> [ + 'id' => (int) $row->id, + 'email' => (string) $row->email, + 'tipo_email' => (string) ($row->tipo_email ?? 'secondaria'), + 'attiva' => (bool) $row->attiva, + ]) + ->all(); + } + + /** @return array> */ + private function getAdditionalEmailsFormRows(): array + { + return $this->emailMultiple; + } + + /** @param array> $rows */ + private function saveAdditionalEmails(array $rows): void + { + $persona = $this->resolvePersonaForRubrica(true); + if (! $persona instanceof Persona) { + throw new \RuntimeException('Impossibile associare la rubrica a una persona.'); + } + + $this->syncPersonaFromRubrica($persona); + + DB::transaction(function () use ($rows, $persona): void { + $keepIds = []; + + foreach (array_values($rows) as $row) { + $email = mb_strtolower(trim((string) ($row['email'] ?? ''))); + if ($email === '') { + continue; + } + + $attrs = [ + 'email' => $email, + 'tipo_email' => trim((string) ($row['tipo_email'] ?? 'secondaria')) ?: 'secondaria', + 'attiva' => (bool) ($row['attiva'] ?? true), + ]; + + $id = (int) ($row['id'] ?? 0); + if ($id > 0) { + $existing = PersonaEmailMultipla::query() + ->where('persona_id', (int) $persona->id) + ->where('id', $id) + ->first(); + + if ($existing instanceof PersonaEmailMultipla) { + $existing->fill($attrs); + $existing->save(); + $keepIds[] = (int) $existing->id; + continue; + } + } + + $created = PersonaEmailMultipla::query()->create([ + 'persona_id' => (int) $persona->id, + ...$attrs, + ]); + $keepIds[] = (int) $created->id; + } + + $deleteQuery = PersonaEmailMultipla::query()->where('persona_id', (int) $persona->id); + if ($keepIds !== []) { + $deleteQuery->whereNotIn('id', $keepIds); + } + $deleteQuery->delete(); + }); + + $this->hydrateEmailMultiple(); + } + + private function resolvePersonaForRubrica(bool $createIfMissing): ?Persona + { + if (! Schema::hasTable('persone')) { + return null; + } + + $cf = trim((string) ($this->rubrica->codice_fiscale ?? '')); + if ($cf !== '') { + $found = Persona::query()->where('codice_fiscale', $cf)->first(); + if ($found instanceof Persona) { + return $found; + } + } + + $email = mb_strtolower(trim((string) ($this->rubrica->email ?? ''))); + if ($email !== '') { + $found = Persona::query()->whereRaw('LOWER(email_principale) = ?', [$email])->first(); + if ($found instanceof Persona) { + return $found; + } + + if (Schema::hasTable('persone_email_multiple')) { + $match = Persona::query() + ->whereHas('emailMultiple', fn(EloquentBuilderContract $query) => $query->where('email', $email)) + ->first(); + if ($match instanceof Persona) { + return $match; + } + } + } + + $phone = trim((string) ($this->rubrica->telefono_cellulare ?: $this->rubrica->telefono_ufficio ?: '')); + if ($phone !== '') { + $found = Persona::query()->where('telefono_principale', $phone)->first(); + if ($found instanceof Persona) { + return $found; + } + } + + $nome = trim((string) ($this->rubrica->nome ?? '')); + $cognome = trim((string) ($this->rubrica->cognome ?? '')); + if ($nome !== '' && $cognome !== '') { + $found = Persona::query()->where('nome', $nome)->where('cognome', $cognome)->first(); + if ($found instanceof Persona) { + return $found; + } + } + + if (! $createIfMissing) { + return null; + } + + if ($nome === '' && $cognome === '' && trim((string) ($this->rubrica->ragione_sociale ?? '')) === '') { + return null; + } + + $persona = Persona::query()->create([ + 'tipologia' => $this->rubrica->tipo_contatto === 'persona_giuridica' ? 'giuridica' : 'fisica', + 'nome' => $nome !== '' ? $nome : (trim((string) ($this->rubrica->ragione_sociale ?? '')) !== '' ? 'Impresa' : 'N/D'), + 'cognome' => $cognome !== '' ? $cognome : (trim((string) ($this->rubrica->ragione_sociale ?? '')) !== '' ? trim((string) $this->rubrica->ragione_sociale) : 'N/D'), + 'ragione_sociale' => $this->rubrica->ragione_sociale, + 'codice_fiscale' => $cf !== '' ? $cf : null, + 'partita_iva' => $this->rubrica->partita_iva, + 'data_nascita' => $this->rubrica->data_nascita, + 'residenza_via' => $this->rubrica->indirizzo_completo, + 'telefono_principale' => $phone !== '' ? $phone : null, + 'telefono_secondario' => $this->rubrica->telefono_casa, + 'email_principale' => $email !== '' ? $email : null, + 'email_pec' => $this->rubrica->pec, + 'note' => $this->rubrica->note, + 'attivo' => $this->rubrica->stato !== 'inattivo', + ]); + + return $persona; + } + + private function syncPersonaFromRubrica(Persona $persona): void + { + $updates = []; + + if (! $persona->email_principale && $this->rubrica->email) { + $updates['email_principale'] = mb_strtolower(trim((string) $this->rubrica->email)); + } + if (! $persona->email_pec && $this->rubrica->pec) { + $updates['email_pec'] = $this->rubrica->pec; + } + if (! $persona->telefono_principale && ($this->rubrica->telefono_cellulare || $this->rubrica->telefono_ufficio)) { + $updates['telefono_principale'] = $this->rubrica->telefono_cellulare ?: $this->rubrica->telefono_ufficio; + } + if (! $persona->residenza_via && $this->rubrica->indirizzo_completo) { + $updates['residenza_via'] = $this->rubrica->indirizzo_completo; + } + if (! $persona->data_nascita && $this->rubrica->data_nascita) { + $updates['data_nascita'] = $this->rubrica->data_nascita; + } + if (! $persona->codice_fiscale && $this->rubrica->codice_fiscale) { + $updates['codice_fiscale'] = $this->rubrica->codice_fiscale; + } + if (! $persona->partita_iva && $this->rubrica->partita_iva) { + $updates['partita_iva'] = $this->rubrica->partita_iva; + } + + if ($updates !== []) { + $persona->fill($updates); + $persona->save(); + } + } + public function getUrlUnita(?int $unitaId): ?string { if (! $unitaId) { diff --git a/app/Filament/Pages/Supporto/Modifiche.php b/app/Filament/Pages/Supporto/Modifiche.php index afe7b3e..fbdb7e8 100644 --- a/app/Filament/Pages/Supporto/Modifiche.php +++ b/app/Filament/Pages/Supporto/Modifiche.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; use Throwable; @@ -51,6 +52,12 @@ class Modifiche extends Page public ?string $lastConnectivityCheckOutput = null; + /** @var array|null */ + public ?array $lastManifestPreview = null; + + /** @var array|null */ + public ?array $latestBackupSummary = null; + public ?string $lastPlannedUpdateSummary = null; public bool $updateInProgress = false; @@ -135,6 +142,7 @@ public function reloadDashboardData(): void $this->loadRuntimeErrors(); $this->loadRecentFilamentPages(); $this->loadFunctionalHighlights(); + $this->loadLatestBackupSummary(); if ($this->selectedCommitHash === null && isset($this->latestCommits[0]['hash'])) { $this->selectedCommitHash = (string) $this->latestCommits[0]['hash']; @@ -279,7 +287,7 @@ 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', + 'Upload backup su Google Drive obbligatorio prima dell update', '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', @@ -320,6 +328,7 @@ public function runUpdateConnectivityCheck(): void $out[] = trim($headResult->output() !== '' ? $headResult->output() : $headResult->errorOutput()); $this->lastConnectivityCheckOutput = trim(implode(PHP_EOL, $out)); + $this->lastManifestPreview = null; $combined = Str::lower($this->lastConnectivityCheckOutput); if (str_contains($combined, 'timed out') || str_contains($combined, 'curl: (28)')) { @@ -332,6 +341,29 @@ public function runUpdateConnectivityCheck(): void return; } + try { + $manifestUrl = rtrim((string) config('distribution.update_url', 'https://updates.netgescon.it'), '/') . '/api/v1/distribution/updates/manifest'; + $response = Http::timeout((int) config('distribution.http_timeout_seconds', 60)) + ->withOptions($this->buildDistributionHttpOptions()) + ->acceptJson() + ->get($manifestUrl, ['channel' => $this->updateChannel]); + + if ($response->successful()) { + $manifest = $response->json(); + if (is_array($manifest)) { + $this->lastManifestPreview = [ + 'version' => (string) ($manifest['version'] ?? '-'), + 'package_url' => (string) ($manifest['package_url'] ?? '-'), + 'sha256' => (string) ($manifest['sha256'] ?? '-'), + 'migrate_required' => (bool) ($manifest['migrate_required'] ?? false), + 'release_notes' => (string) ($manifest['release_notes'] ?? $manifest['notes'] ?? ''), + 'security' => $manifest['security'] ?? $manifest['security_updates'] ?? null, + ]; + } + } + } catch (Throwable) { + } + Notification::make() ->title('Check connettivita completato') ->success() @@ -411,17 +443,33 @@ private function startUpdateJob(bool $fallback): void '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; + $adminId = $this->resolveAmministratoreId(); + if ($adminId <= 0) { + $this->updateInProgress = false; + $this->updateProgressStatus = 'failed'; + $this->updateProgressPercent = 100; + $this->updateProgressMessage = 'Amministratore Google non risolto'; + $this->lastUpdateExitCode = 1; + $this->lastUpdateOutput = 'Backup Google Drive obbligatorio: impossibile risolvere amministratore attivo per il sito corrente.'; + $this->lastUpdateIssue = 'Aggiornamento bloccato: serve un amministratore attivo con integrazione Google Drive per creare la seconda copia ripristinabile.'; + @file_put_contents($progressPath, json_encode([ + 'timestamp' => now()->toIso8601String(), + 'percent' => 100, + 'message' => 'Amministratore Google non risolto', + 'status' => 'failed', + 'exit_code' => 1, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return; } + $backupParams = [ + '--differential' => true, + '--drive' => true, + '--require-drive' => true, + '--admin-id' => (string) $adminId, + '--tag' => 'update-' . $jobId, + ]; + try { $backupExit = Artisan::call('netgescon:preupdate-backup', $backupParams); $backupOut = trim((string) Artisan::output()); @@ -440,8 +488,10 @@ private function startUpdateJob(bool $fallback): void 'status' => 'failed', 'exit_code' => 1, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $this->loadLatestBackupSummary(); return; } + $this->loadLatestBackupSummary(); } catch (Throwable $e) { $this->updateInProgress = false; $this->updateProgressStatus = 'failed'; @@ -531,6 +581,55 @@ private function registerPlannedUpdateSummary(): void $this->appendSupportEvent('update_plan', 'Piano aggiornamento registrato', $summary); } + private function loadLatestBackupSummary(): void + { + $this->latestBackupSummary = null; + + $path = storage_path('app/update-backups/latest-manifest.json'); + if (! is_file($path)) { + return; + } + + $decoded = json_decode((string) @file_get_contents($path), true); + if (! is_array($decoded)) { + return; + } + + $zipPath = (string) ($decoded['zip_path'] ?? ''); + $drive = is_array($decoded['drive'] ?? null) ? $decoded['drive'] : null; + + $this->latestBackupSummary = [ + 'snapshot_id' => (string) ($decoded['snapshot_id'] ?? '-'), + 'created_at' => (string) ($decoded['created_at'] ?? '-'), + 'zip_path' => $zipPath, + 'zip_exists' => $zipPath !== '' && is_file($zipPath), + 'zip_size_mb' => $zipPath !== '' && is_file($zipPath) ? round(((float) filesize($zipPath)) / 1048576, 2) : null, + 'drive' => $drive, + 'copied_files' => (int) ($decoded['copied_files'] ?? 0), + 'tables_count' => is_array($decoded['tables'] ?? null) ? count((array) $decoded['tables']) : 0, + ]; + } + + /** @return array */ + private function buildDistributionHttpOptions(): 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 !== '')); + if ($entries === []) { + return []; + } + + return [ + 'curl' => [ + CURLOPT_RESOLVE => $entries, + ], + ]; + } + private function loadRecentFilamentPages(): void { $this->recentFilamentPages = []; diff --git a/app/Filament/Pages/Supporto/TicketGestione.php b/app/Filament/Pages/Supporto/TicketGestione.php index bff387e..5c7c2f5 100644 --- a/app/Filament/Pages/Supporto/TicketGestione.php +++ b/app/Filament/Pages/Supporto/TicketGestione.php @@ -251,11 +251,10 @@ public function openAttachmentPreview(int $attachmentId): void } $name = (string) ($attachment->original_file_name ?? 'allegato'); - $mime = strtolower((string) ($attachment->mime_type ?? '')); - $ext = strtolower((string) pathinfo($name, PATHINFO_EXTENSION)); + $mime = $attachment->resolvedMimeType(); - $isImage = str_starts_with($mime, 'image/') || in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'], true); - $isPdf = $mime === 'application/pdf' || $ext === 'pdf'; + $isImage = $attachment->isImage(); + $isPdf = $attachment->isPdf(); $this->attachmentPreview = [ 'id' => (int) $attachment->id, @@ -264,6 +263,7 @@ public function openAttachmentPreview(int $attachmentId): void 'url' => $this->getAttachmentPublicUrl($attachment), 'is_image' => $isImage, 'is_pdf' => $isPdf, + 'mime' => $mime, ]; } @@ -411,6 +411,11 @@ public function caricaAllegati(): void } $path = $file->store('ticket-gestione/' . $ticket->id, 'public'); + $mime = TicketAttachment::normalizeMimeType( + method_exists($file, 'getClientMimeType') ? (string) $file->getClientMimeType() : '', + method_exists($file, 'getClientOriginalName') ? (string) $file->getClientOriginalName() : '', + $path + ); TicketAttachment::query()->create([ 'ticket_id' => $ticket->id, @@ -418,7 +423,7 @@ public function caricaAllegati(): void 'user_id' => Auth::id(), 'file_path' => $path, 'original_file_name' => (string) $file->getClientOriginalName(), - 'mime_type' => (string) $file->getClientMimeType(), + 'mime_type' => $mime, 'size' => (int) $file->getSize(), 'description' => $this->descrizioneAllegati ?: 'Allegato da gestione ticket', ]); diff --git a/app/Http/Controllers/Admin/TicketAttachmentViewController.php b/app/Http/Controllers/Admin/TicketAttachmentViewController.php index b443c3d..586d704 100644 --- a/app/Http/Controllers/Admin/TicketAttachmentViewController.php +++ b/app/Http/Controllers/Admin/TicketAttachmentViewController.php @@ -5,13 +5,13 @@ use App\Models\TicketAttachment; use App\Models\User; use App\Support\StabileContext; -use Illuminate\Http\Response; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class TicketAttachmentViewController extends Controller { - public function show(TicketAttachment $attachment): Response + public function show(TicketAttachment $attachment): BinaryFileResponse { $user = Auth::user(); if (! $user instanceof User) { @@ -39,7 +39,7 @@ public function show(TicketAttachment $attachment): Response } $absolutePath = $disk->path($path); - $mime = (string) ($attachment->mime_type ?: $disk->mimeType($path) ?: 'application/octet-stream'); + $mime = $attachment->resolvedMimeType(); return response()->file($absolutePath, [ 'Content-Type' => $mime, diff --git a/app/Models/RubricaUniversale.php b/app/Models/RubricaUniversale.php index 0f25582..822aa3a 100755 --- a/app/Models/RubricaUniversale.php +++ b/app/Models/RubricaUniversale.php @@ -2,6 +2,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; class RubricaUniversale extends Model @@ -18,6 +19,10 @@ class RubricaUniversale extends Model 'tipo_contatto', // 'persona_fisica', 'persona_giuridica' 'codice_fiscale', 'partita_iva', + 'sesso', + 'data_nascita', + 'luogo_nascita', + 'provincia_nascita', 'indirizzo', 'civico', 'cap', @@ -50,6 +55,7 @@ class RubricaUniversale extends Model protected $casts = [ 'data_inserimento' => 'date', 'data_ultima_modifica' => 'date', + 'data_nascita' => 'date', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', @@ -79,7 +85,7 @@ public function datiBancari() return $this->hasMany(DatiBancari::class, 'contatto_id'); } - public function titolo() + public function titolo(): BelongsTo { return $this->belongsTo(Titolo::class, 'titolo_id'); } diff --git a/app/Models/Scadenza.php b/app/Models/Scadenza.php new file mode 100644 index 0000000..5866e2a --- /dev/null +++ b/app/Models/Scadenza.php @@ -0,0 +1,65 @@ + 'date', + 'popup_dal' => 'date', + 'richiede_pop_up' => 'boolean', + 'importo_f24' => 'decimal:2', + 'calendar_synced_at' => 'datetime', + 'legacy_payload' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function stabile() + { + return $this->belongsTo(Stabile::class, 'stabile_id'); + } + + public function getTitoloCalendarioAttribute(): string + { + $parts = array_filter([ + $this->natura_label ?: $this->natura, + trim((string) ($this->descrizione_sintetica ?? '')), + ]); + + return implode(' - ', $parts) ?: ('Scadenza #' . (int) $this->id); + } +} \ No newline at end of file diff --git a/app/Models/StabileServizioLettura.php b/app/Models/StabileServizioLettura.php index 82c6db2..2ef3d61 100644 --- a/app/Models/StabileServizioLettura.php +++ b/app/Models/StabileServizioLettura.php @@ -20,6 +20,13 @@ class StabileServizioLettura extends Model 'tipologia_lettura', 'canale_acquisizione', 'riferimento_acquisizione', + 'workflow_stato', + 'protocollo_categoria', + 'protocollo_numero', + 'richiesta_lettura_inviata_at', + 'prossima_lettura_scadenza_at', + 'archivio_documentale_path', + 'archivio_documentale_sha256', 'lettura_inizio', 'lettura_fine', 'consumo_valore', @@ -32,6 +39,8 @@ class StabileServizioLettura extends Model protected $casts = [ 'periodo_dal' => 'date', 'periodo_al' => 'date', + 'richiesta_lettura_inviata_at' => 'datetime', + 'prossima_lettura_scadenza_at' => 'datetime', 'lettura_inizio' => 'decimal:3', 'lettura_fine' => 'decimal:3', 'consumo_valore' => 'decimal:3', diff --git a/app/Models/TicketAttachment.php b/app/Models/TicketAttachment.php index ea418d7..1442433 100755 --- a/app/Models/TicketAttachment.php +++ b/app/Models/TicketAttachment.php @@ -1,18 +1,16 @@ belongsTo(User::class, 'user_id', 'id'); } + + public function resolvedMimeType(): string + { + return self::normalizeMimeType( + (string) ($this->mime_type ?? ''), + (string) ($this->original_file_name ?? ''), + (string) ($this->file_path ?? '') + ); + } + + public function isImage(): bool + { + return str_starts_with($this->resolvedMimeType(), 'image/'); + } + + public function isPdf(): bool + { + return $this->resolvedMimeType() === 'application/pdf'; + } + + public static function normalizeMimeType(?string $mimeType, ?string $originalName = null, ?string $path = null): string + { + $mime = strtolower(trim((string) $mimeType)); + + if ($mime !== '' && $mime !== 'application/octet-stream') { + return $mime; + } + + $extension = strtolower((string) pathinfo((string) ($originalName ?: $path), PATHINFO_EXTENSION)); + + $fromExtension = match ($extension) { + 'jpg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'gif' => 'image/gif', + 'bmp' => 'image/bmp', + 'pdf' => 'application/pdf', + 'txt' => 'text/plain', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + default => '', + }; + + if ($fromExtension !== '') { + return $fromExtension; + } + + $diskPath = trim((string) $path); + if ($diskPath !== '') { + try { + $detected = Storage::disk('public')->mimeType($diskPath); + if (is_string($detected) && trim($detected) !== '') { + return strtolower(trim($detected)); + } + } catch (\Throwable) { + } + } + + return 'application/octet-stream'; + } } diff --git a/app/Providers/Filament/AdminFilamentPanelProvider.php b/app/Providers/Filament/AdminFilamentPanelProvider.php index 45d5aad..cade183 100644 --- a/app/Providers/Filament/AdminFilamentPanelProvider.php +++ b/app/Providers/Filament/AdminFilamentPanelProvider.php @@ -79,8 +79,8 @@ public function panel(Panel $panel): Panel && $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, @@ -88,7 +88,7 @@ public function panel(Panel $panel): Panel 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, GoogleScadenziarioOverview::class, diff --git a/database/migrations/2026_03_15_211500_create_scadenze_table.php b/database/migrations/2026_03_15_211500_create_scadenze_table.php new file mode 100644 index 0000000..7fe0a2b --- /dev/null +++ b/database/migrations/2026_03_15_211500_create_scadenze_table.php @@ -0,0 +1,44 @@ +id(); + $table->unsignedInteger('legacy_id')->nullable()->unique(); + $table->foreignId('stabile_id')->nullable()->constrained('stabili')->nullOnDelete(); + $table->string('codice_stabile_legacy', 20)->nullable()->index(); + $table->string('descrizione_sintetica')->nullable(); + $table->date('data_scadenza')->nullable()->index(); + $table->time('ora_scadenza')->nullable(); + $table->string('natura', 20)->nullable()->index(); + $table->string('natura_label', 120)->nullable(); + $table->string('gestione_tipo', 20)->nullable(); + $table->unsignedInteger('num_assemblea')->nullable(); + $table->unsignedInteger('numero_straordinaria')->nullable(); + $table->string('anno', 20)->nullable(); + $table->unsignedInteger('rata')->nullable(); + $table->string('fatto', 20)->nullable(); + $table->string('tipo_riga', 60)->nullable(); + $table->unsignedBigInteger('rif_f24')->nullable(); + $table->decimal('importo_f24', 12, 2)->nullable(); + $table->boolean('richiede_pop_up')->default(false); + $table->unsignedInteger('giorni_anticipo')->nullable(); + $table->date('popup_dal')->nullable(); + $table->string('calendar_event_id')->nullable()->index(); + $table->timestamp('calendar_synced_at')->nullable(); + $table->json('legacy_payload')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('scadenze'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_16_090000_add_anagrafica_fields_to_rubrica_universale.php b/database/migrations/2026_03_16_090000_add_anagrafica_fields_to_rubrica_universale.php new file mode 100644 index 0000000..587f024 --- /dev/null +++ b/database/migrations/2026_03_16_090000_add_anagrafica_fields_to_rubrica_universale.php @@ -0,0 +1,51 @@ +string('sesso', 1)->nullable()->after('partita_iva'); + } + if (! Schema::hasColumn('rubrica_universale', 'data_nascita')) { + $table->date('data_nascita')->nullable()->after('sesso'); + } + if (! Schema::hasColumn('rubrica_universale', 'luogo_nascita')) { + $table->string('luogo_nascita', 100)->nullable()->after('data_nascita'); + } + if (! Schema::hasColumn('rubrica_universale', 'provincia_nascita')) { + $table->string('provincia_nascita', 2)->nullable()->after('luogo_nascita'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('rubrica_universale')) { + return; + } + + Schema::table('rubrica_universale', function (Blueprint $table): void { + $drops = []; + + foreach (['provincia_nascita', 'luogo_nascita', 'data_nascita', 'sesso'] as $column) { + if (Schema::hasColumn('rubrica_universale', $column)) { + $drops[] = $column; + } + } + + if ($drops !== []) { + $table->dropColumn($drops); + } + }); + } +}; diff --git a/database/migrations/2026_03_16_150000_add_workflow_fields_to_stabile_servizio_letture_table.php b/database/migrations/2026_03_16_150000_add_workflow_fields_to_stabile_servizio_letture_table.php new file mode 100644 index 0000000..f10b402 --- /dev/null +++ b/database/migrations/2026_03_16_150000_add_workflow_fields_to_stabile_servizio_letture_table.php @@ -0,0 +1,68 @@ +string('workflow_stato', 40)->nullable()->after('riferimento_acquisizione'); + } + + if (! Schema::hasColumn('stabile_servizio_letture', 'protocollo_categoria')) { + $table->string('protocollo_categoria', 40)->nullable()->after('workflow_stato'); + } + + if (! Schema::hasColumn('stabile_servizio_letture', 'protocollo_numero')) { + $table->string('protocollo_numero', 50)->nullable()->after('protocollo_categoria'); + } + + if (! Schema::hasColumn('stabile_servizio_letture', 'richiesta_lettura_inviata_at')) { + $table->dateTime('richiesta_lettura_inviata_at')->nullable()->after('protocollo_numero'); + } + + if (! Schema::hasColumn('stabile_servizio_letture', 'prossima_lettura_scadenza_at')) { + $table->dateTime('prossima_lettura_scadenza_at')->nullable()->after('richiesta_lettura_inviata_at'); + } + + if (! Schema::hasColumn('stabile_servizio_letture', 'archivio_documentale_path')) { + $table->string('archivio_documentale_path', 500)->nullable()->after('prossima_lettura_scadenza_at'); + } + + if (! Schema::hasColumn('stabile_servizio_letture', 'archivio_documentale_sha256')) { + $table->string('archivio_documentale_sha256', 64)->nullable()->after('archivio_documentale_path'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('stabile_servizio_letture')) { + return; + } + + Schema::table('stabile_servizio_letture', function (Blueprint $table): void { + foreach ([ + 'archivio_documentale_sha256', + 'archivio_documentale_path', + 'prossima_lettura_scadenza_at', + 'richiesta_lettura_inviata_at', + 'protocollo_numero', + 'protocollo_categoria', + 'workflow_stato', + ] as $column) { + if (Schema::hasColumn('stabile_servizio_letture', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/docs/assicurazioni-roadmap.md b/docs/assicurazioni-roadmap.md new file mode 100644 index 0000000..8349bab --- /dev/null +++ b/docs/assicurazioni-roadmap.md @@ -0,0 +1,123 @@ +# Assicurazioni Roadmap + +Base gia presente nel codice: + +- `Ticket -> insuranceClaim()` gia esiste. +- `InsuranceClaim` gestisce oggi il collegamento minimo ticket/sinistro. +- `DocumentoCollegato` supporta gia il tipo documento `assicurazione` e il collegamento allo stabile. +- `Scadenza` e ora disponibile per rinnovi polizza, pagamenti premio, richieste integrazione, riserve e liquidazioni. + +Obiettivo prodotto + +- Gestire la polizza dello stabile come dossier unico. +- Caricare documenti assicurativi da email e archiviarli nei documenti collegati dello stabile. +- Gestire quietanze di pagamento con ricevuta allegata e collegamento al movimento bancario. +- Aprire il sinistro da ticket e seguirlo fino a liquidazione, diniego o passaggio al legale. +- Generare scadenze operative e amministrative lungo tutto il ciclo. + +Modello dati minimo consigliato + +1. `insurance_policies` + +- `id` +- `stabile_id` +- `compagnia_rubrica_id` +- `broker_rubrica_id` +- `documento_collegato_id` +- `numero_polizza` +- `ramo` +- `descrizione_copertura` +- `massimale` +- `franchigia` +- `premio_annuo` +- `decorrenza` +- `scadenza` +- `rinnovo_tacito` +- `giorni_preavviso_disdetta` +- `stato` +- `metadata` + +1. `insurance_policy_payments` + +- `id` +- `insurance_policy_id` +- `movimento_bancario_id` +- `documento_collegato_id` +- `numero_quietanza` +- `data_pagamento` +- `competenza_dal` +- `competenza_al` +- `importo` +- `stato` +- `metadata` + +1. Estendere `insurance_claims` + +- mantenere `ticket_id` +- aggiungere `insurance_policy_id` +- aggiungere `documento_collegato_id` +- aggiungere `numero_pratica_compagnia` +- aggiungere `stato_workflow` +- aggiungere `data_evento` +- aggiungere `data_denuncia` +- aggiungere `data_apertura_compagnia` +- aggiungere `data_liquidazione` +- aggiungere `importo_riservato` +- aggiungere `importo_liquidato` +- aggiungere `passata_a_legale_at` +- aggiungere `legale_rubrica_id` + +1. `insurance_claim_events` + +- timeline strutturata della pratica +- tipi evento: apertura, invio_documenti, integrazione, sopralluogo, riserva, liquidazione, diniego, passaggio_legale, mediazione, chiusura + +Agganci applicativi + +1. Email -> Documenti + +- intercettare mail della compagnia/broker +- salvare EML e allegati +- creare o aggiornare `DocumentoCollegato` tipo `assicurazione` +- se il soggetto mittente e riconosciuto, collegare `compagnia_rubrica_id` o `broker_rubrica_id` + +1. Documenti stabile + +- nella tab `documenti-collegati` dello stabile filtrare ed evidenziare le polizze assicurative +- mostrare polizza attiva, prossima scadenza, ultima quietanza, ultimo sinistro aperto + +1. Ticket -> Sinistro + +- aggiungere in `TicketGestione` una action `Apri pratica assicurativa` +- precompilare riferimento polizza, data evento, descrizione danno, allegati ticket +- creare automaticamente almeno una `Scadenza` di follow-up + +1. Banca -> Quietanze + +- permettere collegamento manuale e poi semi-automatico tra quietanza e `movimento_bancario` +- usare causali bancarie che contengono `polizza`, `assicurazione`, `quietanza`, numero pratica o numero polizza + +1. Scadenze + +- rinnovo polizza +- termine disdetta +- richiesta integrazione documenti +- scadenza per denuncia +- follow-up con compagnia +- data prevista liquidazione +- termine per invio al legale/mediazione + +Primo slice implementativo consigliato + +1. Migrazione + model `insurance_policies` +2. Estensione `insurance_claims` +3. Action in ticket per aprire la pratica assicurativa con collegamento a polizza e allegati ticket +4. Vista riepilogo assicurazione nello stabile +5. Quietanza + collegamento movimento bancario +6. Automazione email in ingresso + +Note operative + +- Riutilizzare `RubricaUniversale` per compagnia, broker e legale. +- Riutilizzare `DocumentoCollegato` per il fascicolo documentale, senza creare un archivio parallelo. +- Riutilizzare `Scadenza` per tutte le attivita operative invece di introdurre una seconda agenda dedicata. diff --git a/info_per_scambio.md b/info_per_scambio.md new file mode 100644 index 0000000..568f603 --- /dev/null +++ b/info_per_scambio.md @@ -0,0 +1,106 @@ +Ecco il prompt unico da dare all Agent DNS/Web per chiudere configurazione host, DNS e virtual host dei siti NetGescon. + +Obiettivo operativo + +- Rendere coerenti e raggiungibili in HTTPS i siti NetGescon usati oggi: + - + - +- Fare in modo che il nodo update pubblichi davvero gli endpoint distribution usati dal client update Laravel. + +Prompt unico per Agent DNS/Web + +Verifica e correggi l infrastruttura web/DNS di NetGescon con questi vincoli e test finali. + +Contesto verificato + +- La macchina interna di release/update risponde su 192.168.0.53. +- staging.netgescon.it oggi risponde correttamente in HTTPS. +- updates.netgescon.it oggi non ha un virtual host dedicato attivo su nginx, oppure punta a un istanza Laravel non allineata. +- Test eseguiti: + - => HTTP 200 + - => HTTP 200, manifest versione 0.8.1 + - forzato verso 192.168.0.53 => HTTP 200 ma con risposta incoerente/vecchia + - forzato verso 192.168.0.53 => HTTP 404 + - forzato verso 192.168.0.53 => HTTP 404 + +Lavori richiesti + +1. DNS + +- Verifica il record A/AAAA di staging.netgescon.it. +- Verifica il record A/AAAA di updates.netgescon.it. +- Se il nodo update deve stare sulla macchina interna 192.168.0.53, allinea DNS interno o split-horizon DNS in modo che updates.netgescon.it risolva correttamente verso quel nodo. +- Se esiste proxy esterno o reverse proxy, verifica che inoltri Host header e HTTPS correttamente. + +1. Nginx / vhost + +- Verifica i virtual host attivi in /etc/nginx/sites-available e /etc/nginx/sites-enabled. +- staging.netgescon.it deve avere il suo vhost dedicato e funzionante. +- Crea o correggi un vhost dedicato per updates.netgescon.it. +- Non lasciare updates.netgescon.it servito dal vhost di default o da un server_name diverso. +- Il vhost updates deve puntare all applicazione Laravel corretta che espone: + - /api/v1/distribution/health + - /api/v1/distribution/updates/manifest + - /api/v1/distribution/updates/package + +1. Laravel / env del nodo update + +- Verifica che il document root punti a public/ della app corretta. +- Verifica che APP_URL del nodo update sia . +- Verifica che routes/api.php sia registrato nel bootstrap dell app attiva. +- Verifica che storage/app/distribution/free/manifest.json esista sul nodo update corretto. +- Verifica che il package pubblicato esista e sia leggibile dal web server. + +1. TLS + +- Verifica certificato valido per staging.netgescon.it. +- Verifica certificato valido per updates.netgescon.it. +- Se necessario, rigenera o riallinea certificati e catena completa. + +1. Output richiesto dall Agent + +- Elenco file nginx modificati. +- Record DNS verificati/modificati. +- APP_URL e document root finali di staging e updates. +- Risultato finale dei test sotto. + +Test finali obbligatori + +- curl -k +- curl -k "" +- curl -k +- curl -k "" +- curl -k "" -I + +Esito atteso + +- staging.health = 200 +- staging.manifest = 200 +- updates.health = 200 +- updates.manifest = 200 +- updates.package = 200 oppure 302/304 se gestito da proxy/CDN, ma mai 404 + +Nota applicativa utile + +- Su staging i valori target distribution sono: + - NETGESCON_UPDATE_URL= + - NETGESCON_UPDATE_CHANNEL=free + - NETGESCON_NODE_CODE=staging-01 + +Parte scadenze legacy avviata qui + +- La tabella sorgente MDB e letta da parti_comuni.mdb -> Scadenze. +- Campi operativi gia verificati: + - id_scadenza + - Dt_scadenza + - ora_scadenza + - cod_stabile + - Descriz_sintetica + - Gest_ors + - Natura + - Rif_f24 + - Importo_f24 + - Richiede_pop_up + - gg_anticipo + - Pop_Up_dal +- Non usare Descriz_lunga. 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 7689c5e..c2aa41a 100644 --- a/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php +++ b/resources/views/filament/pages/gescon/rubrica-universale-scheda.blade.php @@ -35,6 +35,27 @@ Partita IVA + + + + + + + + + + @@ -203,11 +244,50 @@
{{ $rubrica->partita_iva ?? '—' }}
+
+
Sesso
+
{{ $rubrica->sesso ?? '—' }}
+
+ +
+
Data nascita
+
{{ $rubrica->data_nascita?->format('d/m/Y') ?? '—' }}
+
+ +
+
Luogo nascita
+
{{ $rubrica->luogo_nascita ?? '—' }}
+
+ +
+
Provincia nascita
+
{{ $rubrica->provincia_nascita ?? '—' }}
+
+
Email
{{ $rubrica->email ?? '—' }}
+
+
Email aggiuntive
+ @if(empty($emailMultiple)) +
+ @else +
+ @foreach($emailMultiple as $emailRow) + + {{ $emailRow['email'] }} + · {{ $emailRow['tipo_email'] ?? 'secondaria' }} + @if(empty($emailRow['attiva'])) + · inattiva + @endif + + @endforeach +
+ @endif +
+
PEC
{{ $rubrica->pec ?? '—' }}
@@ -246,6 +326,23 @@
Indirizzo
{{ $rubrica->indirizzo_completo ?? '—' }}
+ @if($rubrica->indirizzo || $rubrica->cap || $rubrica->citta || $rubrica->provincia) +
+ {{ $rubrica->indirizzo ?? '—' }} + @if($rubrica->civico) + , {{ $rubrica->civico }} + @endif + @if($rubrica->cap) + · {{ $rubrica->cap }} + @endif + @if($rubrica->citta) + · {{ $rubrica->citta }} + @endif + @if($rubrica->provincia) + ({{ $rubrica->provincia }}) + @endif +
+ @endif
diff --git a/resources/views/filament/pages/gescon/table.blade.php b/resources/views/filament/pages/gescon/table.blade.php index 0a38668..b2295b8 100644 --- a/resources/views/filament/pages/gescon/table.blade.php +++ b/resources/views/filament/pages/gescon/table.blade.php @@ -10,7 +10,7 @@ @else -
+
+ Apri HUB
@@ -171,11 +170,12 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5
+ Apri vecchia scheda (immutata)
-
+
Anagrafica fornitore
@@ -407,7 +407,10 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5
- +
+ + +
@endif @@ -434,30 +437,38 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5 Nominativo Contatti Stato + Azione @forelse($this->dipendentiRows as $d) -
{{ trim(($d->nome ?? '') . ' ' . ($d->cognome ?? '')) ?: '-' }}
#{{ (int) $d->id }}
+
+ + +
-
{{ $d->email ?: '-' }}
-
{{ $d->telefono ?: '-' }}
+
+ + +
- @if((bool) ($d->attivo ?? false)) - Attivo - @else - Disattivo - @endif + + + + @empty - Nessun dipendente collegato. + Nessun dipendente collegato. @endforelse @@ -466,6 +477,20 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5
+
+
Nuovo nominativo da collegare al fornitore
+
+ + + + +
+
+ + +
+
+
Rubrica nominativi da agganciare
+
+
Ultimo backup pronto per update
+ @if($this->latestBackupSummary) +
+
Snapshot: {{ $this->latestBackupSummary['snapshot_id'] }}
+
Creato: {{ $this->latestBackupSummary['created_at'] }}
+
Copia locale: {{ $this->latestBackupSummary['zip_exists'] ? 'OK' : 'mancante' }}
+
ZIP: {{ $this->latestBackupSummary['zip_path'] ?: '-' }}
+
Dimensione: {{ $this->latestBackupSummary['zip_size_mb'] !== null ? number_format((float) $this->latestBackupSummary['zip_size_mb'], 2, ',', '.') . ' MB' : '-' }}
+
File copiati: {{ (int) $this->latestBackupSummary['copied_files'] }} · Tabelle indicizzate: {{ (int) $this->latestBackupSummary['tables_count'] }}
+
+ Copia Google Drive: + @if(is_array($this->latestBackupSummary['drive'] ?? null)) + OK + @if(!empty($this->latestBackupSummary['drive']['webViewLink'])) + Apri su Drive + @endif + @else + non disponibile + @endif +
+
+ @else +
Nessun backup pre-update registrato.
+ @endif +
+ +
+
Anteprima pacchetto in arrivo
+ @if($this->lastManifestPreview) +
+
Versione remota: {{ $this->lastManifestPreview['version'] }}
+
Migration richieste: {{ $this->lastManifestPreview['migrate_required'] ? 'si' : 'no' }}
+
Pacchetto: {{ $this->lastManifestPreview['package_url'] }}
+
SHA256: {{ $this->lastManifestPreview['sha256'] }}
+ @if(filled($this->lastManifestPreview['release_notes'] ?? null)) +
{{ $this->lastManifestPreview['release_notes'] }}
+ @endif + @if(!empty($this->lastManifestPreview['security'])) +
+ Aggiornamenti sicurezza: {{ is_array($this->lastManifestPreview['security']) ? json_encode($this->lastManifestPreview['security'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $this->lastManifestPreview['security'] }} +
+ @endif +
+ @else +
Esegui “Check connettivita update” per leggere manifest e contenuto dell aggiornamento prima del lancio.
+ @endif +
+
+
Aggiornamenti pianificati (prima del lancio)
    diff --git a/resources/views/filament/pages/supporto/ticket-gestione.blade.php b/resources/views/filament/pages/supporto/ticket-gestione.blade.php index 51a24ed..fe7113f 100644 --- a/resources/views/filament/pages/supporto/ticket-gestione.blade.php +++ b/resources/views/filament/pages/supporto/ticket-gestione.blade.php @@ -276,12 +276,12 @@ @forelse($ticket->attachments as $a)
    - @if(str_starts_with((string) $a->mime_type, 'image/')) + @if($a->isImage()) {{ $a->original_file_name }} @else
    -
    {{ strtoupper(pathinfo((string) $a->original_file_name, PATHINFO_EXTENSION) ?: 'FILE') }}
    +
    {{ strtoupper(pathinfo((string) $a->original_file_name, PATHINFO_EXTENSION) ?: ($a->isPdf() ? 'PDF' : 'FILE')) }}
    Anteprima disponibile in modal
    @@ -342,7 +342,7 @@ @if($attachmentPreview)
    -
    +
    {{ $attachmentPreview['name'] ?? 'Allegato' }}
    @@ -350,14 +350,17 @@
    {{ $attachmentPreview['description'] }}
    @endif
    - +
    + Apri in nuova scheda + +
    -
    +
    @if($attachmentPreview['is_image'] ?? false) - Anteprima allegato + Anteprima allegato @elseif($attachmentPreview['is_pdf'] ?? false) - + @else
    Anteprima non disponibile per questo formato. Scarica o apri il file: diff --git a/resources/views/filament/pages/supporto/ticket-scheda.blade.php b/resources/views/filament/pages/supporto/ticket-scheda.blade.php index 0635d3d..ab43320 100644 --- a/resources/views/filament/pages/supporto/ticket-scheda.blade.php +++ b/resources/views/filament/pages/supporto/ticket-scheda.blade.php @@ -94,7 +94,7 @@
    {{ $a->original_file_name }}
    {{ $a->description }}
    - Apri allegato + Apri allegato
    @empty
    Nessun allegato.