feat(release): prepare staging 0.8.1 update rollout

This commit is contained in:
michele 2026-03-15 19:01:19 +00:00
parent e0e3c7d16d
commit 07c440fad4
86 changed files with 8724 additions and 1233 deletions

View File

@ -1 +1 @@
0.1.0 0.8.1

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Fornitore; use App\Models\Fornitore;
@ -14,7 +13,9 @@
class GesconRubricaDedupCommand extends Command class GesconRubricaDedupCommand extends Command
{ {
protected $signature = 'gescon:rubrica-dedup protected $signature = 'gescon:rubrica-dedup
{amministratore_id : ID amministratore} {amministratore_id? : ID amministratore (opzionale se --all-admin)}
{--all-admin : Elabora tutte le anagrafiche attive}
{--include-null-admin : Include anche rubriche con amministratore_id NULL}
{--dry-run : Non scrive, mostra solo conteggi} {--dry-run : Non scrive, mostra solo conteggi}
{--delete-empties : Soft-delete dei contatti vuoti e orfani}'; {--delete-empties : Soft-delete dei contatti vuoti e orfani}';
@ -22,16 +23,18 @@ class GesconRubricaDedupCommand extends Command
public function handle(): int public function handle(): int
{ {
$adminId = (int) $this->argument('amministratore_id'); $allAdmin = (bool) $this->option('all-admin');
if ($adminId <= 0) { $adminId = (int) ($this->argument('amministratore_id') ?: 0);
$this->error('amministratore_id non valido'); if (! $allAdmin && $adminId <= 0) {
$this->error('amministratore_id non valido (oppure usa --all-admin)');
return self::FAILURE; return self::FAILURE;
} }
$dry = (bool) $this->option('dry-run'); $dry = (bool) $this->option('dry-run');
$deleteEmpties = (bool) $this->option('delete-empties'); $deleteEmpties = (bool) $this->option('delete-empties');
$includeNullAdmin = (bool) $this->option('include-null-admin');
if (!Schema::hasTable('rubrica_universale')) { if (! Schema::hasTable('rubrica_universale')) {
$this->error('Tabella rubrica_universale non disponibile.'); $this->error('Tabella rubrica_universale non disponibile.');
return self::FAILURE; return self::FAILURE;
} }
@ -46,16 +49,26 @@ public function handle(): int
'moved_canali' => 0, 'moved_canali' => 0,
'moved_fornitori' => 0, 'moved_fornitori' => 0,
'moved_stabili' => 0, 'moved_stabili' => 0,
'moved_links_generic' => 0,
]; ];
$runner = function () use ($adminId, $dry, $deleteEmpties, &$stats): void { $runner = function () use ($adminId, $allAdmin, $includeNullAdmin, $dry, $deleteEmpties, &$stats): void {
// Carica i contatti dell'admin (escludi già soft-deleted per evitare merge strani). // Carica i contatti target (escludi già soft-deleted per evitare merge strani).
$rubriche = RubricaUniversale::query() $rubricheQuery = RubricaUniversale::query()->whereNull('deleted_at');
->where('amministratore_id', $adminId) if (! $allAdmin) {
->whereNull('deleted_at') $rubricheQuery->where(function ($q) use ($adminId, $includeNullAdmin): void {
$q->where('amministratore_id', $adminId);
if ($includeNullAdmin) {
$q->orWhereNull('amministratore_id');
}
});
}
$rubriche = $rubricheQuery
->get([ ->get([
'id', 'id',
'codice_univoco', 'codice_univoco',
'amministratore_id',
'tipo_contatto', 'tipo_contatto',
'ragione_sociale', 'ragione_sociale',
'nome', 'nome',
@ -108,10 +121,9 @@ public function handle(): int
continue; continue;
} }
DB::transaction(function () use ($adminId, $master, $dup, &$stats): void {
// 1) Merge campi (non sovrascrivere i valori del master, riempi solo i null/vuoti) // 1) Merge campi (non sovrascrivere i valori del master, riempi solo i null/vuoti)
$payload = $this->mergeRubricaPayload($master, $dup); $payload = $this->mergeRubricaPayload($master, $dup);
if (!empty($payload)) { if (! empty($payload)) {
$master->fill($payload); $master->fill($payload);
if ($master->isDirty()) { if ($master->isDirty()) {
$master->save(); $master->save();
@ -132,25 +144,40 @@ public function handle(): int
} }
if (Schema::hasTable('fornitori')) { if (Schema::hasTable('fornitori')) {
$stats['moved_fornitori'] += Fornitore::query() $fornitoriMove = Fornitore::query()->where('rubrica_id', (int) $dup->id);
->where('amministratore_id', $adminId) if (! $allAdmin && $adminId > 0) {
->where('rubrica_id', (int) $dup->id) $fornitoriMove->where(function ($q) use ($adminId, $includeNullAdmin): void {
->update(['rubrica_id' => (int) $master->id]); $q->where('amministratore_id', $adminId);
if ($includeNullAdmin) {
$q->orWhereNull('amministratore_id');
}
});
}
$stats['moved_fornitori'] += $fornitoriMove->update(['rubrica_id' => (int) $master->id]);
} }
if (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id')) { if (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id')) {
$stats['moved_stabili'] += Stabile::query() $stabiliMove = Stabile::query()->where('rubrica_id', (int) $dup->id);
->where('amministratore_id', $adminId) if (! $allAdmin && $adminId > 0) {
->where('rubrica_id', (int) $dup->id) $stabiliMove->where('amministratore_id', $adminId);
->update(['rubrica_id' => (int) $master->id]);
} }
$stats['moved_stabili'] += $stabiliMove->update(['rubrica_id' => (int) $master->id]);
}
$stats['moved_links_generic'] += $this->moveGenericRubricaLinks(
(int) $dup->id,
(int) $master->id,
$adminId,
$allAdmin,
$includeNullAdmin,
);
// 3) Soft-delete duplicato // 3) Soft-delete duplicato
$note = trim((string) ($dup->note ?? '')); $note = trim((string) ($dup->note ?? ''));
$suffix = 'Merged into rubrica_id=' . (int) $master->id; $suffix = 'Merged into rubrica_id=' . (int) $master->id;
if ($note === '') { if ($note === '') {
$dup->note = $suffix; $dup->note = $suffix;
} elseif (!str_contains($note, $suffix)) { } elseif (! str_contains($note, $suffix)) {
$dup->note = mb_substr($note . "\n" . $suffix, 0, 65535); $dup->note = mb_substr($note . "\n" . $suffix, 0, 65535);
} }
@ -158,22 +185,29 @@ public function handle(): int
$dup->delete(); $dup->delete();
$stats['rubrica_soft_deleted']++; $stats['rubrica_soft_deleted']++;
$stats['merged']++; $stats['merged']++;
});
} }
} }
if (!$deleteEmpties) { if (! $deleteEmpties) {
return; return;
} }
// Soft-delete contatti completamente vuoti e non collegati a nulla. // Soft-delete contatti completamente vuoti e non collegati a nulla.
$empties = RubricaUniversale::query() $emptiesQ = RubricaUniversale::query()->whereNull('deleted_at');
->where('amministratore_id', $adminId) if (! $allAdmin) {
->whereNull('deleted_at') $emptiesQ->where(function ($q) use ($adminId, $includeNullAdmin): void {
$q->where('amministratore_id', $adminId);
if ($includeNullAdmin) {
$q->orWhereNull('amministratore_id');
}
});
}
$empties = $emptiesQ
->get(['id', 'ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email', 'pec', 'telefono_ufficio', 'telefono_cellulare', 'telefono_casa']); ->get(['id', 'ragione_sociale', 'nome', 'cognome', 'codice_fiscale', 'partita_iva', 'email', 'pec', 'telefono_ufficio', 'telefono_cellulare', 'telefono_casa']);
foreach ($empties as $r) { foreach ($empties as $r) {
if (!$this->isRubricaEmptyIdentity($r)) { if (! $this->isRubricaEmptyIdentity($r)) {
continue; continue;
} }
@ -184,10 +218,10 @@ public function handle(): int
? RubricaContattoCanale::query()->where('rubrica_id', (int) $r->id)->exists() ? RubricaContattoCanale::query()->where('rubrica_id', (int) $r->id)->exists()
: false; : false;
$hasFornitori = Schema::hasTable('fornitori') $hasFornitori = Schema::hasTable('fornitori')
? Fornitore::query()->where('amministratore_id', $adminId)->where('rubrica_id', (int) $r->id)->exists() ? Fornitore::query()->where('rubrica_id', (int) $r->id)->exists()
: false; : false;
$hasStabili = (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id')) $hasStabili = (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id'))
? Stabile::query()->where('amministratore_id', $adminId)->where('rubrica_id', (int) $r->id)->exists() ? Stabile::query()->where('rubrica_id', (int) $r->id)->exists()
: false; : false;
if ($hasRuoli || $hasCanali || $hasFornitori || $hasStabili) { if ($hasRuoli || $hasCanali || $hasFornitori || $hasStabili) {
@ -195,7 +229,7 @@ public function handle(): int
} }
$stats['rubrica_empties_soft_deleted']++; $stats['rubrica_empties_soft_deleted']++;
if (!$dry) { if (! $dry) {
$r->note = trim(((string) ($r->note ?? '')) . "\n" . 'Auto-clean: empty identity'); $r->note = trim(((string) ($r->note ?? '')) . "\n" . 'Auto-clean: empty identity');
$r->save(); $r->save();
$r->delete(); $r->delete();
@ -215,6 +249,51 @@ public function handle(): int
return self::SUCCESS; return self::SUCCESS;
} }
private function moveGenericRubricaLinks(
int $fromRubricaId,
int $toRubricaId,
int $adminId,
bool $allAdmin,
bool $includeNullAdmin,
): int {
if ($fromRubricaId <= 0 || $toRubricaId <= 0 || $fromRubricaId === $toRubricaId) {
return 0;
}
$db = DB::getDatabaseName();
$rows = DB::select(
"SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = ?
AND column_name IN ('rubrica_id', 'rubrica_universale_id')
AND table_name <> 'rubrica_universale'",
[$db]
);
$moved = 0;
foreach ($rows as $row) {
$table = (string) ($row->TABLE_NAME ?? '');
$column = (string) ($row->COLUMN_NAME ?? '');
if ($table === '' || $column === '' || ! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
continue;
}
$query = DB::table($table)->where($column, $fromRubricaId);
if (! $allAdmin && $adminId > 0 && Schema::hasColumn($table, 'amministratore_id')) {
$query->where(function ($q) use ($adminId, $includeNullAdmin): void {
$q->where('amministratore_id', $adminId);
if ($includeNullAdmin) {
$q->orWhereNull('amministratore_id');
}
});
}
$moved += (int) $query->update([$column => $toRubricaId]);
}
return $moved;
}
/** @return list<string> */ /** @return list<string> */
private function buildIdentityKeys(RubricaUniversale $r): array private function buildIdentityKeys(RubricaUniversale $r): array
{ {
@ -393,7 +472,15 @@ private function normalizeFiscalIds(?string $raw): array
} }
$norm = $this->normalizeCf($raw); $norm = $this->normalizeCf($raw);
if (!$norm) { if (! $norm) {
return ['cf' => null, 'piva' => null];
}
// Valori placeholder/import legacy non devono guidare dedup automatico.
if (in_array($norm, ['0', '00', '000', '0000', 'ND', 'N/D', 'NULL'], true)) {
return ['cf' => null, 'piva' => null];
}
if (preg_match('/^0+$/', $norm) === 1) {
return ['cf' => null, 'piva' => null]; return ['cf' => null, 'piva' => null];
} }

View File

@ -14,15 +14,19 @@ class NetgesconDistributionPullCommand extends Command
{--auth= : Token autorizzazione licensed} {--auth= : Token autorizzazione licensed}
{--apply : Applica update se disponibile} {--apply : Applica update se disponibile}
{--dry-run : Simula senza scrivere file} {--dry-run : Simula senza scrivere file}
{--force : Applica anche se versione uguale o inferiore}'; {--force : Applica anche se versione uguale o inferiore}
{--progress-file= : File JSON dove scrivere lo stato avanzamento}';
protected $description = 'Controlla e applica aggiornamenti NetGescon dal server distribuzione'; protected $description = 'Controlla e applica aggiornamenti NetGescon dal server distribuzione';
public function handle(): int public function handle(): int
{ {
$this->writeProgress(2, 'Avvio controllo aggiornamenti', 'running');
$baseUrl = rtrim((string) config('distribution.update_url', ''), '/'); $baseUrl = rtrim((string) config('distribution.update_url', ''), '/');
if ($baseUrl === '') { if ($baseUrl === '') {
$this->error('NETGESCON_UPDATE_URL non configurato.'); $this->error('NETGESCON_UPDATE_URL non configurato.');
$this->writeProgress(100, 'NETGESCON_UPDATE_URL non configurato', 'failed', 1);
return self::FAILURE; return self::FAILURE;
} }
@ -37,6 +41,7 @@ public function handle(): int
if ($channel === 'licensed') { if ($channel === 'licensed') {
if ($code === '' || $auth === '') { if ($code === '' || $auth === '') {
$this->error('Canale licensed richiede --code e --auth (o variabili NETGESCON_LICENSED_*).'); $this->error('Canale licensed richiede --code e --auth (o variabili NETGESCON_LICENSED_*).');
$this->writeProgress(100, 'Credenziali licensed mancanti', 'failed', 1);
return self::FAILURE; return self::FAILURE;
} }
$query['code'] = $code; $query['code'] = $code;
@ -44,9 +49,15 @@ public function handle(): int
} }
$timeout = (int) config('distribution.http_timeout_seconds', 60); $timeout = (int) config('distribution.http_timeout_seconds', 60);
$resolveEntries = $this->resolveEntries();
$this->info("Controllo aggiornamenti su {$manifestUrl} (channel={$channel})..."); $this->info("Controllo aggiornamenti su {$manifestUrl} (channel={$channel})...");
if ($resolveEntries !== []) {
$this->line('Override resolve attivo: ' . implode(', ', $resolveEntries));
}
$this->writeProgress(12, 'Contatto server distribution', 'running');
$response = Http::timeout($timeout) $response = Http::timeout($timeout)
->withOptions($this->httpOptions(false, $resolveEntries))
->acceptJson() ->acceptJson()
->withHeaders(array_filter([ ->withHeaders(array_filter([
'X-Netgescon-Node' => $nodeCode !== '' ? $nodeCode : null, 'X-Netgescon-Node' => $nodeCode !== '' ? $nodeCode : null,
@ -54,7 +65,23 @@ public function handle(): int
->get($manifestUrl, $query); ->get($manifestUrl, $query);
if (! $response->successful()) { if (! $response->successful()) {
if ($response->status() === 404) {
$healthUrl = $baseUrl . '/api/v1/distribution/health';
$health = Http::timeout($timeout)
->withOptions($this->httpOptions(false, $resolveEntries))
->acceptJson()
->get($healthUrl);
if ($health->successful()) {
$this->error('Manifest endpoint non trovato (HTTP 404), ma health distribution risponde OK.');
$this->error('Verifica che il server update esponga /api/v1/distribution/updates/manifest.');
$this->writeProgress(100, 'Manifest endpoint non disponibile sul server update', 'failed', 1);
return self::FAILURE;
}
}
$this->error('Manifest non raggiungibile: HTTP ' . $response->status()); $this->error('Manifest non raggiungibile: HTTP ' . $response->status());
$this->writeProgress(100, 'Manifest non raggiungibile: HTTP ' . $response->status(), 'failed', 1);
return self::FAILURE; return self::FAILURE;
} }
@ -67,6 +94,7 @@ public function handle(): int
if ($remoteVersion === '' || $packageUrl === '' || $hash === '') { if ($remoteVersion === '' || $packageUrl === '' || $hash === '') {
$this->error('Manifest incompleto: richiesti version/package_url/sha256.'); $this->error('Manifest incompleto: richiesti version/package_url/sha256.');
$this->writeProgress(100, 'Manifest incompleto', 'failed', 1);
return self::FAILURE; return self::FAILURE;
} }
@ -78,16 +106,19 @@ public function handle(): int
if (! $hasNewVersion && ! $this->option('force')) { if (! $hasNewVersion && ! $this->option('force')) {
$this->info('Nessun aggiornamento necessario.'); $this->info('Nessun aggiornamento necessario.');
$this->writeProgress(100, 'Nessun aggiornamento necessario', 'completed', 0);
return self::SUCCESS; return self::SUCCESS;
} }
if (! $this->option('apply')) { if (! $this->option('apply')) {
$this->info('Update disponibile. Esegui con --apply per installarlo.'); $this->info('Update disponibile. Esegui con --apply per installarlo.');
$this->writeProgress(100, 'Update disponibile (apply non richiesto)', 'completed', 0);
return self::SUCCESS; return self::SUCCESS;
} }
if ($this->option('dry-run')) { if ($this->option('dry-run')) {
$this->warn('Dry-run: update rilevato, nessuna modifica applicata.'); $this->warn('Dry-run: update rilevato, nessuna modifica applicata.');
$this->writeProgress(100, 'Dry-run completato', 'completed', 0);
return self::SUCCESS; return self::SUCCESS;
} }
@ -95,6 +126,7 @@ public function handle(): int
$backupRoot = storage_path('app/update-backups'); $backupRoot = storage_path('app/update-backups');
File::ensureDirectoryExists($tmpRoot); File::ensureDirectoryExists($tmpRoot);
File::ensureDirectoryExists($backupRoot); File::ensureDirectoryExists($backupRoot);
$this->writeProgress(25, 'Preparazione cartelle temporanee', 'running');
$stamp = date('Ymd-His'); $stamp = date('Ymd-His');
$zipPath = $tmpRoot . '/netgescon-update-' . $stamp . '.zip'; $zipPath = $tmpRoot . '/netgescon-update-' . $stamp . '.zip';
@ -102,9 +134,13 @@ public function handle(): int
$envBackup = $backupRoot . '/.env-' . $stamp; $envBackup = $backupRoot . '/.env-' . $stamp;
$this->info('Download pacchetto...'); $this->info('Download pacchetto...');
$download = Http::timeout($timeout)->withOptions(['stream' => true])->get($packageUrl); $this->writeProgress(35, 'Download pacchetto update', 'running');
$download = Http::timeout($timeout)
->withOptions($this->httpOptions(true, $resolveEntries))
->get($packageUrl);
if (! $download->successful()) { if (! $download->successful()) {
$this->error('Download fallito: HTTP ' . $download->status()); $this->error('Download fallito: HTTP ' . $download->status());
$this->writeProgress(100, 'Download fallito: HTTP ' . $download->status(), 'failed', 1);
return self::FAILURE; return self::FAILURE;
} }
@ -113,25 +149,32 @@ public function handle(): int
$calcHash = strtolower(hash_file('sha256', $zipPath) ?: ''); $calcHash = strtolower(hash_file('sha256', $zipPath) ?: '');
if ($calcHash !== $hash) { if ($calcHash !== $hash) {
$this->error('Hash SHA256 non valido. Atteso=' . $hash . ' calcolato=' . $calcHash); $this->error('Hash SHA256 non valido. Atteso=' . $hash . ' calcolato=' . $calcHash);
$this->writeProgress(100, 'Verifica hash fallita', 'failed', 1);
return self::FAILURE; return self::FAILURE;
} }
$this->writeProgress(50, 'Pacchetto verificato', 'running');
$this->info('Backup .env...'); $this->info('Backup .env...');
$this->writeProgress(58, 'Backup ambiente corrente', 'running');
if (File::exists(base_path('.env'))) { if (File::exists(base_path('.env'))) {
File::copy(base_path('.env'), $envBackup); File::copy(base_path('.env'), $envBackup);
} }
$this->info('Estrazione pacchetto...'); $this->info('Estrazione pacchetto...');
$this->writeProgress(66, 'Estrazione pacchetto', 'running');
File::ensureDirectoryExists($extractDir); File::ensureDirectoryExists($extractDir);
$zip = new ZipArchive(); $zip = new ZipArchive();
if ($zip->open($zipPath) !== true) { if ($zip->open($zipPath) !== true) {
$this->error('Impossibile aprire ZIP update.'); $this->error('Impossibile aprire ZIP update.');
$this->writeProgress(100, 'Impossibile aprire ZIP update', 'failed', 1);
return self::FAILURE; return self::FAILURE;
} }
$zip->extractTo($extractDir); $zip->extractTo($extractDir);
$zip->close(); $zip->close();
$this->info('Applicazione file (esclusi .env e storage)...'); $this->info('Applicazione file (esclusi .env e storage)...');
$this->writeProgress(78, 'Applicazione file', 'running');
$this->syncDir($extractDir, base_path()); $this->syncDir($extractDir, base_path());
if (File::exists($envBackup)) { if (File::exists($envBackup)) {
@ -139,16 +182,42 @@ public function handle(): int
} }
$this->call('optimize:clear'); $this->call('optimize:clear');
$this->writeProgress(90, 'Pulizia cache applicazione', 'running');
if ($migrateRequired) { if ($migrateRequired) {
$this->warn('Manifest richiede migrate: esecuzione php artisan migrate --force'); $this->warn('Manifest richiede migrate: esecuzione php artisan migrate --force');
$this->writeProgress(95, 'Esecuzione migration', 'running');
$this->call('migrate', ['--force' => true]); $this->call('migrate', ['--force' => true]);
} }
$this->info('Aggiornamento applicato con successo.'); $this->info('Aggiornamento applicato con successo.');
$this->writeProgress(100, 'Aggiornamento completato', 'completed', 0);
return self::SUCCESS; return self::SUCCESS;
} }
private function writeProgress(int $percent, string $message, string $status, ?int $exitCode = null): void
{
$path = trim((string) $this->option('progress-file'));
if ($path === '') {
return;
}
$dir = dirname($path);
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$payload = [
'timestamp' => now()->toIso8601String(),
'percent' => max(0, min(100, $percent)),
'message' => $message,
'status' => $status,
'exit_code' => $exitCode,
];
@file_put_contents($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private function syncDir(string $from, string $to): void private function syncDir(string $from, string $to): void
{ {
$items = File::allFiles($from); $items = File::allFiles($from);
@ -166,4 +235,46 @@ private function syncDir(string $from, string $to): void
File::copy($path, $target); File::copy($path, $target);
} }
} }
/**
* @return array<int, string>
*/
private function resolveEntries(): array
{
$raw = trim((string) config('distribution.http_resolve', ''));
if ($raw === '') {
return [];
}
$entries = array_values(array_filter(array_map('trim', explode(',', $raw)), static fn(string $v): bool => $v !== ''));
$valid = [];
foreach ($entries as $entry) {
if (preg_match('/^[^:\s]+:\d{1,5}:\d{1,3}(?:\.\d{1,3}){3}$/', $entry) === 1) {
$valid[] = $entry;
continue;
}
$this->warn('Entry NETGESCON_UPDATE_RESOLVE ignorata (formato non valido): ' . $entry);
}
return $valid;
}
/**
* @param array<int, string> $resolveEntries
* @return array<string, mixed>
*/
private function httpOptions(bool $stream, array $resolveEntries): array
{
$options = ['stream' => $stream];
if ($resolveEntries !== []) {
$options['curl'] = [
CURLOPT_RESOLVE => $resolveEntries,
];
}
return $options;
}
} }

View File

@ -0,0 +1,456 @@
<?php
namespace App\Console\Commands;
use App\Models\Amministratore;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use ZipArchive;
class NetgesconPreupdateBackupCommand extends Command
{
protected $signature = 'netgescon:preupdate-backup
{--admin-id= : ID amministratore per backup su Google Drive}
{--differential : Copia solo file cambiati rispetto all ultimo snapshot}
{--drive : Carica backup anche su Google Drive}
{--tag=auto : Etichetta operazione (es. auto/manual)}
{--tables=users,amministratori,stabili,fornitori,tickets,ticket_messages,ticket_attachments,rubrica_universale,fornitore_dipendenti : Tabelle da esportare}';
protected $description = 'Crea backup pre-update con snapshot differenziale, indice record e upload opzionale su Google Drive.';
/** @var array<string,mixed> */
private array $latestManifest = [];
public function handle(): int
{
$stamp = now()->format('Ymd-His');
$tag = trim((string) $this->option('tag')) ?: 'auto';
$snapshotId = $stamp . '-' . Str::slug($tag, '-');
$root = storage_path('app/update-backups');
$snapshotDir = $root . '/snapshots/' . $snapshotId;
$filesDir = $snapshotDir . '/files';
$dbDir = $snapshotDir . '/db';
$metaDir = $snapshotDir . '/meta';
File::ensureDirectoryExists($filesDir);
File::ensureDirectoryExists($dbDir);
File::ensureDirectoryExists($metaDir);
$this->line('Backup snapshot: ' . $snapshotId);
$latestPath = $root . '/latest-manifest.json';
if (is_file($latestPath)) {
$decoded = json_decode((string) file_get_contents($latestPath), true);
if (is_array($decoded)) {
$this->latestManifest = $decoded;
}
}
$manifest = $this->buildFileManifest();
$differential = (bool) $this->option('differential');
$copiedCount = $this->copySnapshotFiles($manifest, $filesDir, $differential);
$tables = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('tables'))), fn(string $v): bool => $v !== ''));
$dbSummary = $this->exportTables($tables, $dbDir, $metaDir);
$meta = [
'snapshot_id' => $snapshotId,
'created_at' => now()->toIso8601String(),
'tag' => $tag,
'differential' => $differential,
'copied_files' => $copiedCount,
'tables' => $dbSummary,
'node_code' => (string) config('distribution.node_code', ''),
'app_version' => (string) config('netgescon.version', '0.0.0'),
];
file_put_contents($metaDir . '/snapshot-meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
file_put_contents($metaDir . '/files-manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
if (is_file(base_path('.env'))) {
File::copy(base_path('.env'), $metaDir . '/.env.backup');
}
if (is_file(base_path('VERSION'))) {
File::copy(base_path('VERSION'), $metaDir . '/VERSION.backup');
}
$zipPath = $root . '/archives/' . $snapshotId . '.zip';
File::ensureDirectoryExists(dirname($zipPath));
$this->zipDirectory($snapshotDir, $zipPath);
$latestPayload = [
'snapshot_id' => $snapshotId,
'snapshot_dir' => $snapshotDir,
'zip_path' => $zipPath,
'created_at' => now()->toIso8601String(),
'files_manifest' => $manifest,
];
file_put_contents($latestPath, json_encode($latestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$this->info('Backup locale creato: ' . $zipPath);
$driveInfo = null;
if ((bool) $this->option('drive')) {
$adminId = (int) $this->option('admin-id');
if ($adminId <= 0) {
$this->warn('Drive upload saltato: --admin-id mancante.');
} else {
$driveInfo = $this->uploadBackupToDrive($adminId, $zipPath, $snapshotId);
if (is_array($driveInfo)) {
$this->info('Backup caricato su Drive: ' . (string) ($driveInfo['webViewLink'] ?? $driveInfo['id'] ?? 'ok'));
} else {
$this->warn('Upload Drive non riuscito.');
}
}
}
$result = [
'snapshot_id' => $snapshotId,
'zip_path' => $zipPath,
'drive' => $driveInfo,
'copied_files' => $copiedCount,
'tables' => $dbSummary,
];
$this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
/** @return array<string,string> */
private function buildFileManifest(): array
{
$roots = ['app', 'bootstrap', 'config', 'database', 'resources', 'routes', 'public'];
$manifest = [];
foreach ($roots as $root) {
$abs = base_path($root);
if (! is_dir($abs)) {
continue;
}
$files = File::allFiles($abs);
foreach ($files as $file) {
$path = $file->getPathname();
$rel = ltrim(str_replace(base_path(), '', $path), '/');
$hash = hash_file('sha256', $path) ?: '';
if ($hash === '') {
continue;
}
$manifest[$rel] = $hash;
}
}
ksort($manifest);
return $manifest;
}
/**
* @param array<string,string> $manifest
*/
private function copySnapshotFiles(array $manifest, string $targetRoot, bool $differential): int
{
$old = [];
if ($differential && is_array($this->latestManifest['files_manifest'] ?? null)) {
$old = (array) $this->latestManifest['files_manifest'];
}
$count = 0;
foreach ($manifest as $rel => $hash) {
$unchanged = $differential && isset($old[$rel]) && (string) $old[$rel] === (string) $hash;
if ($unchanged) {
continue;
}
$src = base_path($rel);
if (! is_file($src)) {
continue;
}
$dst = $targetRoot . '/' . $rel;
File::ensureDirectoryExists(dirname($dst));
File::copy($src, $dst);
$count++;
}
return $count;
}
/**
* @param array<int,string> $tables
* @return array<string,array{rows:int,file:string,index:string}>
*/
private function exportTables(array $tables, string $dbDir, string $metaDir): array
{
$summary = [];
foreach ($tables as $table) {
if (! DB::getSchemaBuilder()->hasTable($table)) {
continue;
}
$columns = DB::getSchemaBuilder()->getColumnListing($table);
$pk = in_array('id', $columns, true) ? 'id' : ($columns[0] ?? 'id');
$file = $dbDir . '/' . $table . '.ndjson';
$indexFile = $metaDir . '/index-' . $table . '.json';
if (is_file($file)) {
@unlink($file);
}
$rowsCount = 0;
$index = [];
DB::table($table)
->orderBy($pk)
->chunk(500, function ($rows) use (&$rowsCount, &$index, $file, $pk): void {
foreach ($rows as $rowObj) {
$row = (array) $rowObj;
$payload = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (! is_string($payload)) {
continue;
}
$rowHash = sha1($payload);
$pkValue = (string) ($row[$pk] ?? '');
$record = [
'pk' => $pkValue,
'hash' => $rowHash,
'data' => $row,
];
file_put_contents($file, json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND);
if ($pkValue !== '') {
$index[$pkValue] = $rowHash;
}
$rowsCount++;
}
});
file_put_contents($indexFile, json_encode($index, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$summary[$table] = [
'rows' => $rowsCount,
'file' => $file,
'index' => $indexFile,
];
}
return $summary;
}
private function zipDirectory(string $sourceDir, string $zipPath): void
{
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Impossibile creare archivio zip backup.');
}
$files = File::allFiles($sourceDir);
foreach ($files as $file) {
$absolute = $file->getPathname();
$relative = ltrim(str_replace($sourceDir, '', $absolute), '/');
$zip->addFile($absolute, $relative);
}
$zip->close();
}
/** @return array<string,mixed>|null */
private function uploadBackupToDrive(int $adminId, string $zipPath, string $snapshotId): ?array
{
$admin = Amministratore::query()->find($adminId);
if (! $admin instanceof Amministratore) {
$this->warn('Amministratore non trovato per upload Drive.');
return null;
}
$google = Arr::get($admin->impostazioni ?? [], 'google', []);
$oauth = Arr::get($google, 'oauth', []);
$token = $this->resolveGoogleAccessToken($admin, $google, $oauth);
if ($token === null) {
$this->warn('Token Google non disponibile per upload backup.');
return null;
}
$rootFolderId = trim((string) Arr::get($google, 'drive_backup_root_id', ''));
if ($rootFolderId === '') {
$rootFolderId = 'root';
}
$node = (string) config('distribution.node_code', 'node');
$year = now()->format('Y');
$month = now()->format('m');
$backupsFolder = $this->ensureDriveFolder($token, 'NetGescon-Backups', $rootFolderId);
if ($backupsFolder === null) {
return null;
}
$nodeFolder = $this->ensureDriveFolder($token, $node !== '' ? $node : 'node', $backupsFolder);
$yearFolder = $nodeFolder ? $this->ensureDriveFolder($token, $year, $nodeFolder) : null;
$monthFolder = $yearFolder ? $this->ensureDriveFolder($token, $month, $yearFolder) : null;
if ($monthFolder === null) {
return null;
}
$this->ensureDriveTemplateStructure($token, $rootFolderId);
$fileName = basename($zipPath);
return $this->uploadDriveFile($token, $monthFolder, $fileName, $zipPath, 'application/zip');
}
private function ensureDriveTemplateStructure(string $token, string $rootFolderId): void
{
$templateRoot = $this->ensureDriveFolder($token, 'NetGescon-Template-Cartelle-Condominio', $rootFolderId);
if ($templateRoot === null) {
return;
}
$folders = config('netgescon.google.drive_template_folders', []);
if (! is_array($folders)) {
return;
}
foreach ($folders as $folder) {
$name = trim((string) $folder);
if ($name === '') {
continue;
}
$this->ensureDriveFolder($token, $name, $templateRoot);
}
}
private function ensureDriveFolder(string $token, string $name, string $parentId): ?string
{
$query = sprintf("mimeType='application/vnd.google-apps.folder' and trashed=false and name='%s' and '%s' in parents", str_replace("'", "\\'", $name), $parentId);
$response = Http::withToken($token)
->acceptJson()
->get('https://www.googleapis.com/drive/v3/files', [
'q' => $query,
'fields' => 'files(id,name)',
'pageSize' => 1,
'supportsAllDrives' => 'true',
'includeItemsFromAllDrives' => 'true',
]);
if ($response->successful()) {
$files = $response->json('files');
if (is_array($files) && isset($files[0]['id']) && is_string($files[0]['id'])) {
return $files[0]['id'];
}
}
$create = Http::withToken($token)
->acceptJson()
->post('https://www.googleapis.com/drive/v3/files?supportsAllDrives=true', [
'name' => $name,
'mimeType' => 'application/vnd.google-apps.folder',
'parents' => [$parentId],
]);
if (! $create->successful()) {
$this->warn('Impossibile creare cartella Drive: ' . $name);
return null;
}
$id = $create->json('id');
return is_string($id) ? $id : null;
}
/** @return array<string,mixed>|null */
private function uploadDriveFile(string $token, string $parentId, string $name, string $path, string $mime): ?array
{
$meta = [
'name' => $name,
'parents' => [$parentId],
];
$boundary = 'netgescon_' . Str::random(20);
$content = "--{$boundary}\r\n";
$content .= "Content-Type: application/json; charset=UTF-8\r\n\r\n";
$content .= json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\r\n";
$content .= "--{$boundary}\r\n";
$content .= "Content-Type: {$mime}\r\n\r\n";
$content .= (string) file_get_contents($path) . "\r\n";
$content .= "--{$boundary}--";
$response = Http::withToken($token)
->withHeaders([
'Content-Type' => 'multipart/related; boundary=' . $boundary,
])
->post('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,name,webViewLink', $content);
if (! $response->successful()) {
$this->warn('Upload Drive fallito: HTTP ' . $response->status());
return null;
}
$json = $response->json();
return is_array($json) ? $json : null;
}
private function resolveGoogleAccessToken(Amministratore $admin, array $google, array $oauth): ?string
{
$accessToken = trim((string) Arr::get($oauth, 'access_token', ''));
if ($accessToken !== '') {
return $accessToken;
}
$refreshToken = trim((string) Arr::get($oauth, 'refresh_token', ''));
if ($refreshToken === '') {
return null;
}
$settingsClientId = trim((string) Arr::get($google, 'client_id', ''));
$settingsClientSecret = trim((string) Arr::get($google, 'client_secret', ''));
$configClientId = trim((string) config('services.google.client_id'));
$configClientSecret = trim((string) config('services.google.client_secret'));
$pairs = [];
if ($settingsClientId !== '' && $settingsClientSecret !== '') {
$pairs[] = [$settingsClientId, $settingsClientSecret];
}
if ($configClientId !== '' && $configClientSecret !== '') {
$pairs[] = [$configClientId, $configClientSecret];
}
foreach ($pairs as [$clientId, $clientSecret]) {
$res = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'refresh_token' => $refreshToken,
'grant_type' => 'refresh_token',
]);
if (! $res->successful()) {
continue;
}
$token = trim((string) $res->json('access_token', ''));
if ($token === '') {
continue;
}
$settings = $admin->impostazioni ?? [];
$settingsGoogle = Arr::get($settings, 'google', []);
$settingsOauth = Arr::get($settingsGoogle, 'oauth', []);
$settingsOauth['access_token'] = $token;
$settingsOauth['expires_in'] = (int) $res->json('expires_in', 3600);
$settingsOauth['refreshed_at'] = now()->toDateTimeString();
$settingsGoogle['oauth'] = $settingsOauth;
$settings['google'] = $settingsGoogle;
$admin->impostazioni = $settings;
$admin->save();
return $token;
}
return null;
}
}

View File

@ -0,0 +1,138 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class NetgesconRestoreRecordCommand extends Command
{
protected $signature = 'netgescon:restore-record
{table : Tabella da ripristinare}
{pk : Valore chiave primaria (default id)}
{--snapshot-id= : Snapshot specifico (default ultimo)}
{--pk-column=id : Nome colonna PK}
{--apply : Esegue il ripristino (altrimenti solo preview)}';
protected $description = 'Ripristina una singola scrittura da snapshot differenziale pre-update.';
public function handle(): int
{
$table = trim((string) $this->argument('table'));
$pk = trim((string) $this->argument('pk'));
$pkColumn = trim((string) $this->option('pk-column')) ?: 'id';
if ($table === '' || $pk === '') {
$this->error('Parametri table/pk obbligatori.');
return self::FAILURE;
}
$snapshotDir = $this->resolveSnapshotDir();
if ($snapshotDir === null) {
$this->error('Snapshot non trovato.');
return self::FAILURE;
}
$file = $snapshotDir . '/db/' . $table . '.ndjson';
if (! is_file($file)) {
$this->error('Export tabella non trovato nello snapshot: ' . $file);
return self::FAILURE;
}
$record = $this->findRecordByPk($file, $pk);
if (! is_array($record)) {
$this->error('Record non trovato nello snapshot per PK=' . $pk);
return self::FAILURE;
}
$this->line('Snapshot: ' . $snapshotDir);
$this->line('Tabella: ' . $table . ' · PK: ' . $pkColumn . '=' . $pk);
$this->line('Hash: ' . (string) ($record['hash'] ?? '-'));
$data = is_array($record['data'] ?? null) ? (array) $record['data'] : [];
if (! array_key_exists($pkColumn, $data)) {
$data[$pkColumn] = $pk;
}
if (! (bool) $this->option('apply')) {
$this->warn('Preview mode: nessuna scrittura eseguita. Usa --apply per ripristinare.');
$this->line(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
if (! DB::getSchemaBuilder()->hasTable($table)) {
$this->error('Tabella non esistente su DB corrente: ' . $table);
return self::FAILURE;
}
DB::table($table)->updateOrInsert(
[$pkColumn => $data[$pkColumn]],
$data
);
$this->info('Record ripristinato con successo.');
return self::SUCCESS;
}
private function resolveSnapshotDir(): ?string
{
$snapshotId = trim((string) $this->option('snapshot-id'));
$root = storage_path('app/update-backups/snapshots');
if ($snapshotId !== '') {
$dir = $root . '/' . $snapshotId;
return is_dir($dir) ? $dir : null;
}
$latest = storage_path('app/update-backups/latest-manifest.json');
if (is_file($latest)) {
$decoded = json_decode((string) @file_get_contents($latest), true);
if (is_array($decoded) && isset($decoded['snapshot_dir']) && is_string($decoded['snapshot_dir']) && is_dir($decoded['snapshot_dir'])) {
return $decoded['snapshot_dir'];
}
}
if (! is_dir($root)) {
return null;
}
$dirs = array_values(array_filter(scandir($root) ?: [], function (string $name) use ($root): bool {
return $name !== '.' && $name !== '..' && is_dir($root . '/' . $name);
}));
rsort($dirs, SORT_NATURAL);
if ($dirs === []) {
return null;
}
return $root . '/' . $dirs[0];
}
/** @return array<string,mixed>|null */
private function findRecordByPk(string $file, string $pk): ?array
{
$handle = fopen($file, 'rb');
if (! is_resource($handle)) {
return null;
}
try {
while (($line = fgets($handle)) !== false) {
$decoded = json_decode((string) $line, true);
if (! is_array($decoded)) {
continue;
}
$candidate = (string) ($decoded['pk'] ?? '');
if ($candidate === $pk) {
return $decoded;
}
}
} finally {
fclose($handle);
}
return null;
}
}

View File

@ -0,0 +1,495 @@
<?php
namespace App\Console\Commands;
use App\Models\ChiamataPostIt;
use App\Models\CommunicationMessage;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class SmdrListenCommand extends Command
{
/** @var array<string, string> */
private const CO_PUBLIC_LINES = [
'1' => '0639731100',
'01' => '0639731100',
'0001' => '0639731100',
'3' => '0688812703',
'03' => '0688812703',
'0003' => '0688812703',
];
/** @var array<string, string> */
private const EXTENSION_CONTEXT = [
'601' => 'giorno',
'603' => 'notte',
];
protected $signature = 'smdr:listen
{--host=192.168.0.101 : SMDR host/IP}
{--port=2300 : SMDR TCP port}
{--user=SMDR : Login username}
{--pass=PCCSMDR : Login password}
{--timeout=0 : Read timeout in seconds (0 = no timeout)}
{--reconnect=1 : Auto-reconnect when socket closes}
{--reconnect-delay=3 : Seconds before reconnect attempt}
{--write-postit=1 : Create Post-it records from incoming SMDR lines}
{--write-communications=1 : Store raw lines in communication_messages}
{--no-auth : Skip sending user/password}
{--max-lines=0 : Stop after N lines (0 = infinite)}';
protected $description = 'Listen to Panasonic NS1000 SMDR stream over TCP and store incoming call records.';
public function handle(): int
{
$host = (string) $this->option('host');
$port = (int) $this->option('port');
$user = (string) $this->option('user');
$pass = (string) $this->option('pass');
$timeout = (int) $this->option('timeout');
$reconnect = (bool) ((int) $this->option('reconnect'));
$reconnectDelay = max(1, (int) $this->option('reconnect-delay'));
$maxLines = (int) $this->option('max-lines');
$writePostIt = (bool) ((int) $this->option('write-postit'));
$writeCommunications = (bool) ((int) $this->option('write-communications'));
$auth = ! $this->option('no-auth');
$lineCount = 0;
$attempt = 0;
$seenFingerprints = [];
$seenOrder = [];
$seenLimit = 5000;
do {
$attempt++;
$this->info("[Attempt {$attempt}] Connecting to SMDR {$host}:{$port}...");
$socket = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errstr, 10);
if (! is_resource($socket)) {
$this->error("Connection failed ({$errno}): {$errstr}");
if (! $reconnect) {
return self::FAILURE;
}
sleep($reconnectDelay);
continue;
}
if ($timeout > 0) {
stream_set_timeout($socket, $timeout);
}
stream_set_blocking($socket, true);
if ($auth) {
$okUser = @fwrite($socket, $user . "\r\n");
usleep(200000);
$okPass = @fwrite($socket, $pass . "\r\n");
if ($okUser === false || $okPass === false) {
$this->warn('Socket closed during auth write (broken pipe).');
fclose($socket);
if (! $reconnect) {
$this->info('SMDR listener stopped.');
return self::SUCCESS;
}
$this->line("Reconnect in {$reconnectDelay}s...");
sleep($reconnectDelay);
continue;
}
$this->line('Credentials sent (CR+LF).');
} else {
$this->line('Authentication skipped (--no-auth).');
}
$this->info('Listening for SMDR lines...');
while (! feof($socket)) {
$line = fgets($socket);
if ($line === false) {
$meta = stream_get_meta_data($socket);
if (($meta['timed_out'] ?? false) === true) {
$this->warn('Read timeout reached, waiting for next data...');
continue;
}
usleep(200000);
continue;
}
$line = trim($line);
if ($line === '') {
continue;
}
$parsed = $this->parseSmdrLine($line);
if ($parsed === null) {
continue;
}
$fingerprint = $parsed['fingerprint'];
if (isset($seenFingerprints[$fingerprint])) {
continue;
}
$seenFingerprints[$fingerprint] = true;
$seenOrder[] = $fingerprint;
if (count($seenOrder) > $seenLimit) {
$old = array_shift($seenOrder);
if ($old !== null) {
unset($seenFingerprints[$old]);
}
}
$lineCount++;
$this->line("[{$lineCount}] {$line}");
$phone = $parsed['phone_number'];
$direction = $parsed['direction'];
$receivedAt = $parsed['occurred_at'] ?? now();
$durationSeconds = $parsed['duration_seconds'];
$targetExtension = (string) ($parsed['extension'] ?? '');
[$assignedUserId, $stabileId] = $this->resolveRouting($targetExtension);
Log::info('SMDR line received', [
'line' => $line,
'phone' => $phone,
'direction' => $direction,
'source' => 'smdr',
]);
if ($writeCommunications && Schema::hasTable('communication_messages')) {
$communicationPayload = [
'channel' => 'smdr',
'direction' => $direction,
'external_chat_id' => null,
'external_message_id' => null,
'phone_number' => $phone,
'sender_name' => null,
'message_text' => $line,
'attachments' => null,
'status' => 'received',
'received_at' => $receivedAt,
'metadata' => [
'raw_line' => $line,
'source_host' => $host,
'source_port' => $port,
'assigned_user_id' => $assignedUserId,
'stabile_id' => $stabileId,
'smdr' => [
'date' => $parsed['date'],
'time' => $parsed['time'],
'extension' => $parsed['extension'],
'call_phase' => $parsed['call_phase'],
'co' => $parsed['co'],
'co_line_number' => $parsed['co_line_number'],
'dial_number_raw' => $parsed['dial_number_raw'],
'dial_number' => $parsed['dial_number'],
'ring_duration_raw' => $parsed['ring_duration_raw'],
'duration_raw' => $parsed['duration_raw'],
'duration_seconds' => $durationSeconds,
'cost_raw' => $parsed['cost_raw'],
'cost_amount' => $parsed['cost_amount'],
'cost_currency' => $parsed['cost_currency'],
'account_code' => $parsed['account_code'],
'direction' => $direction,
'fingerprint' => $fingerprint,
],
],
];
if (Schema::hasColumn('communication_messages', 'target_extension')) {
$communicationPayload['target_extension'] = $targetExtension !== '' ? $targetExtension : null;
}
if (Schema::hasColumn('communication_messages', 'assigned_user_id')) {
$communicationPayload['assigned_user_id'] = $assignedUserId;
}
if (Schema::hasColumn('communication_messages', 'stabile_id')) {
$communicationPayload['stabile_id'] = $stabileId;
}
CommunicationMessage::query()->create($communicationPayload);
}
if ($writePostIt && Schema::hasTable('chiamate_post_it')) {
try {
ChiamataPostIt::query()->create([
'telefono' => $phone,
'stabile_id' => $stabileId,
'creato_da_user_id' => $assignedUserId,
'nome_chiamante' => 'SMDR ' . strtoupper($direction),
'oggetto' => 'Chiamata ' . strtoupper($direction) . ' da SMDR',
'nota' => $line,
'direzione' => $this->mapPostItDirection($direction, $durationSeconds),
'origine' => 'smdr',
'origine_id' => $fingerprint,
'durata_secondi' => $durationSeconds,
'priorita' => 'Media',
'stato' => 'post_it',
'chiamata_il' => $receivedAt,
]);
} catch (\Throwable $e) {
Log::warning('SMDR post-it write skipped', [
'line' => $line,
'error' => $e->getMessage(),
]);
}
}
if ($maxLines > 0 && $lineCount >= $maxLines) {
$this->warn("Max lines ({$maxLines}) reached, stopping.");
fclose($socket);
$this->info('SMDR listener stopped.');
return self::SUCCESS;
}
}
fclose($socket);
$this->warn('Socket closed by remote host.');
if (! $reconnect) {
$this->info('SMDR listener stopped.');
return self::SUCCESS;
}
$this->line("Reconnect in {$reconnectDelay}s...");
sleep($reconnectDelay);
} while (true);
}
private function parseSmdrLine(string $line): ?array
{
$normalized = trim((string) preg_replace('/^\[\d+\]\s*/', '', $line));
if ($normalized === '') {
return null;
}
$upper = strtoupper($normalized);
if (
str_starts_with($upper, 'DATE ') ||
str_starts_with($upper, 'DATE\t') ||
str_starts_with($upper, 'CD') ||
str_starts_with($upper, 'TR') ||
preg_match('/^-{5,}$/', $normalized) === 1
) {
return null;
}
$parts = preg_split('/\s+/', $normalized);
if (! is_array($parts) || count($parts) < 4) {
return null;
}
$date = $parts[0] ?? '';
$time = $parts[1] ?? '';
$extension = $parts[2] ?? '';
if (
preg_match('/^\d{2}\/\d{2}\/\d{2}$/', $date) !== 1 ||
preg_match('/^\d{2}:\d{2}$/', $time) !== 1 ||
preg_match('/^\d{2,4}$/', $extension) !== 1
) {
return null;
}
$idx = 3;
$co = null;
$dialNumberRaw = null;
if (isset($parts[$idx]) && preg_match('/^\d{1,4}$/', $parts[$idx]) === 1) {
$co = $parts[$idx];
$idx++;
}
if (! isset($parts[$idx])) {
return null;
}
$dialNumberRaw = $parts[$idx];
$idx++;
$ringDurationRaw = null;
$durationRaw = null;
$costRaw = null;
$accountCode = null;
if (isset($parts[$idx]) && preg_match('/^\d{1,2}\'\d{2}$/', $parts[$idx]) === 1) {
$ringDurationRaw = $parts[$idx];
$idx++;
}
if (isset($parts[$idx]) && preg_match('/^\d{2}:\d{2}\'\d{2}$/', $parts[$idx]) === 1) {
$durationRaw = $parts[$idx];
$idx++;
} elseif ($ringDurationRaw !== null) {
$durationRaw = $ringDurationRaw;
}
if (isset($parts[$idx]) && preg_match('/^[A-Z]{2,3}[0-9]+(?:\.[0-9]+)?$/', $parts[$idx]) === 1) {
$costRaw = $parts[$idx];
$idx++;
}
if (isset($parts[$idx])) {
$accountCode = implode(' ', array_slice($parts, $idx));
}
$dialUpper = strtoupper($dialNumberRaw);
$direction = 'outbound';
$isInbound = str_contains($dialUpper, '<I>');
$isInternal = str_starts_with($dialUpper, 'EXT');
if ($isInternal) {
$direction = 'internal';
} elseif ($isInbound) {
$direction = 'inbound';
}
$dialNumber = str_ireplace('<I>', '', $dialNumberRaw);
$phoneNumber = $this->sanitizeNumber($dialNumber);
if ($direction === 'internal') {
$internalTarget = $this->sanitizeNumber(substr($dialNumberRaw, 3));
$phoneNumber = $internalTarget ?: $phoneNumber;
}
$coNormalized = $co !== null ? ltrim((string) $co, '0') : '';
$coLookup = $coNormalized !== '' ? $coNormalized : (string) $co;
$publicLine = self::CO_PUBLIC_LINES[(string) $co] ?? self::CO_PUBLIC_LINES[$coLookup] ?? null;
$callPhase = self::EXTENSION_CONTEXT[$extension] ?? null;
if ($direction !== 'internal' && $phoneNumber === null && $publicLine !== null) {
// Fallback: for records without external number we keep the known public line.
$phoneNumber = $publicLine;
}
try {
$occurredAt = Carbon::createFromFormat('d/m/y H:i', "{$date} {$time}");
} catch (\Throwable) {
$occurredAt = now();
}
$durationSeconds = $this->parseDurationToSeconds($durationRaw);
[$costCurrency, $costAmount] = $this->parseCost($costRaw);
$fingerprintBase = implode('|', [
$date,
$time,
$extension,
(string) $co,
$dialNumberRaw,
(string) $durationRaw,
(string) $costRaw,
(string) $accountCode,
]);
return [
'date' => $date,
'time' => $time,
'extension' => $extension,
'co' => $co,
'co_line_number' => $publicLine,
'dial_number_raw' => $dialNumberRaw,
'dial_number' => $dialNumber,
'call_phase' => $callPhase,
'duration_raw' => $durationRaw,
'ring_duration_raw' => $ringDurationRaw,
'duration_seconds' => $durationSeconds,
'cost_raw' => $costRaw,
'cost_currency' => $costCurrency,
'cost_amount' => $costAmount,
'account_code' => $accountCode,
'direction' => $direction,
'phone_number' => $phoneNumber,
'occurred_at' => $occurredAt,
'fingerprint' => sha1($fingerprintBase),
];
}
private function sanitizeNumber(?string $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', $value);
if (! is_string($digits) || $digits === '') {
return null;
}
return $digits;
}
private function parseDurationToSeconds(?string $duration): ?int
{
if ($duration === null) {
return null;
}
if (preg_match('/^(\d{1,2})\'(\d{2})$/', $duration, $m) === 1) {
return ((int) $m[1] * 60) + (int) $m[2];
}
if (preg_match('/^(\d{2}):(\d{2})\'(\d{2})$/', $duration, $m) !== 1) {
return null;
}
return ((int) $m[1] * 3600) + ((int) $m[2] * 60) + (int) $m[3];
}
private function parseCost(?string $costRaw): array
{
if ($costRaw === null) {
return [null, null];
}
if (preg_match('/^([A-Z]{2,3})([0-9]+(?:\.[0-9]+)?)$/', $costRaw, $m) !== 1) {
return [null, null];
}
return [$m[1], (float) $m[2]];
}
private function mapPostItDirection(string $direction, ?int $durationSeconds): string
{
if ($direction === 'inbound') {
if ($durationSeconds === 0) {
return 'persa';
}
return 'in_arrivo';
}
return 'in_uscita';
}
private function resolveRouting(string $extension): array
{
$ext = trim($extension);
if ($ext === '' || ! Schema::hasColumn('users', 'pbx_extension')) {
return [null, null];
}
$user = User::query()->where('pbx_extension', $ext)->first();
if (! $user) {
return [null, null];
}
$stabileId = null;
if (method_exists($user, 'stabiliAssegnati')) {
$stabileId = $user->stabiliAssegnati()->value('stabili.id');
}
return [
(int) $user->id,
$stabileId ? (int) $stabileId : null,
];
}
}

View File

@ -4,6 +4,7 @@
use App\Models\Documento; use App\Models\Documento;
use App\Models\FatturaElettronica; use App\Models\FatturaElettronica;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\FornitoreStabileImpostazione; use App\Models\FornitoreStabileImpostazione;
use App\Models\RegistroRitenuteAcconto; use App\Models\RegistroRitenuteAcconto;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
@ -30,7 +31,9 @@
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class FornitoreScheda extends Page class FornitoreScheda extends Page
{ {
@ -67,6 +70,19 @@ class FornitoreScheda extends Page
/** @var array<int, string> */ /** @var array<int, string> */
public array $tagSuggestions = []; public array $tagSuggestions = [];
/** @var array<int, array<string, mixed>> */
public array $dipendentiRows = [];
public string $nuovoDipendenteNome = '';
public string $nuovoDipendenteCognome = '';
public string $nuovoDipendenteEmail = '';
public string $nuovoDipendenteTelefono = '';
public ?string $lastGeneratedPassword = null;
public function cleanDisplayValue(?string $value, string $fallback = '-'): string public function cleanDisplayValue(?string $value, string $fallback = '-'): string
{ {
$v = trim((string) $value); $v = trim((string) $value);
@ -129,16 +145,169 @@ public function mount(int | string $record): void
} }
$allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0))); $allowedAdminIds = array_values(array_unique(array_filter($allowedAdminIds, fn($v) => (int) $v > 0)));
$fornitoreAdminId = (int) ($this->fornitore->amministratore_id ?? 0); $fornitoreAdminIds = array_values(array_unique(array_filter([
(int) ($this->fornitore->amministratore_id ?? 0),
(int) ($this->fornitore->rubrica?->amministratore_id ?? 0),
], fn($v) => (int) $v > 0)));
if ($fornitoreAdminId <= 0 || ! in_array($fornitoreAdminId, $allowedAdminIds, true)) { if ($fornitoreAdminIds === []) {
if (! $user->hasAnyRole(['admin', 'amministratore'])) {
abort(404); abort(404);
} }
} else {
$hasIntersection = count(array_intersect($fornitoreAdminIds, $allowedAdminIds)) > 0;
if (! $hasIntersection) {
abort(404);
}
}
} }
$this->hydrateBoxData($user); $this->hydrateBoxData($user);
$this->tagsInput = (string) ($this->fornitore->tags ?? ''); $this->tagsInput = (string) ($this->fornitore->tags ?? '');
$this->loadTagSuggestions(); $this->loadTagSuggestions();
$this->refreshDipendentiRows();
}
public function creaDipendenteFornitore(): void
{
$nome = trim($this->nuovoDipendenteNome);
$email = mb_strtolower(trim($this->nuovoDipendenteEmail));
if ($nome === '' || $email === '') {
Notification::make()->title('Nome ed email sono obbligatori')->warning()->send();
return;
}
$exists = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->whereRaw('LOWER(email) = ?', [$email])
->exists();
if ($exists) {
Notification::make()->title('Dipendente gia presente per questo fornitore')->warning()->send();
return;
}
FornitoreDipendente::query()->create([
'fornitore_id' => (int) $this->fornitore->id,
'nome' => $nome,
'cognome' => trim($this->nuovoDipendenteCognome) ?: null,
'email' => $email,
'telefono' => trim($this->nuovoDipendenteTelefono) ?: null,
'attivo' => true,
'created_by_user_id' => Auth::id(),
'updated_by_user_id' => Auth::id(),
]);
$this->nuovoDipendenteNome = '';
$this->nuovoDipendenteCognome = '';
$this->nuovoDipendenteEmail = '';
$this->nuovoDipendenteTelefono = '';
$this->refreshDipendentiRows();
Notification::make()->title('Dipendente aggiunto')->success()->send();
}
public function abilitaAccessoDipendente(int $dipendenteId): void
{
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->find($dipendenteId);
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Dipendente non trovato')->danger()->send();
return;
}
$email = trim((string) ($dipendente->email ?? ''));
if ($email === '') {
Notification::make()->title('Email dipendente mancante')->warning()->send();
return;
}
$targetUser = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first();
$created = false;
if (! $targetUser instanceof User) {
$generatedPassword = Str::random(12);
$targetUser = User::query()->create([
'name' => trim((string) ($dipendente->nome_completo ?: 'Utente fornitore')),
'email' => $email,
'password' => Hash::make($generatedPassword),
'email_verified_at' => now(),
'is_active' => true,
]);
$this->lastGeneratedPassword = $generatedPassword;
$created = true;
}
$targetUser->assignRole('fornitore');
$dipendente->user_id = (int) $targetUser->id;
$dipendente->attivo = true;
$dipendente->updated_by_user_id = Auth::id();
$dipendente->save();
$this->refreshDipendentiRows();
Notification::make()
->title('Accesso dipendente abilitato')
->body($created
? 'Accesso creato. Password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)')
: 'Accesso collegato ad utente esistente.')
->success()
->send();
}
public function resetPasswordDipendenteUser(int $userId): void
{
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->where('user_id', $userId)
->first();
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Utente non collegato a questo fornitore')->danger()->send();
return;
}
$target = User::query()->find($userId);
if (! $target instanceof User) {
Notification::make()->title('Utente non trovato')->danger()->send();
return;
}
$newPassword = Str::random(12);
$target->password = Hash::make($newPassword);
$target->save();
$this->lastGeneratedPassword = $newPassword;
Notification::make()
->title('Password dipendente resettata')
->body('Nuova password temporanea: ' . $newPassword)
->success()
->send();
}
public function toggleDipendenteAttivo(int $dipendenteId): void
{
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->find($dipendenteId);
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Dipendente non trovato')->danger()->send();
return;
}
$dipendente->attivo = ! (bool) $dipendente->attivo;
$dipendente->updated_by_user_id = Auth::id();
$dipendente->save();
$this->refreshDipendentiRows();
Notification::make()->title('Stato dipendente aggiornato')->success()->send();
} }
public function saveTags(): void public function saveTags(): void
@ -255,6 +424,30 @@ private function loadTagSuggestions(): void
$this->tagSuggestions = array_slice($pool, 0, 300); $this->tagSuggestions = array_slice($pool, 0, 300);
} }
private function refreshDipendentiRows(): void
{
$this->dipendentiRows = FornitoreDipendente::query()
->with('user:id,name,email')
->where('fornitore_id', (int) $this->fornitore->id)
->orderByDesc('attivo')
->orderBy('nome')
->orderBy('cognome')
->get()
->map(function (FornitoreDipendente $d): array {
$userId = (int) ($d->user_id ?? 0);
return [
'id' => (int) $d->id,
'nome' => (string) ($d->nome_completo ?: 'Dipendente #' . $d->id),
'email' => (string) ($d->email ?? ''),
'telefono' => (string) ($d->telefono ?? ''),
'attivo' => (bool) $d->attivo,
'user_id' => $userId,
'user_label' => $userId > 0 ? ((string) ($d->user?->name ?: ('Utente #' . $userId))): '-',
];
})
->all();
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
@ -285,7 +478,7 @@ private function splitTags(string $value): array
$clean = trim($part); $clean = trim($part);
$clean = preg_replace('/\s+/', ' ', $clean) ?? ''; $clean = preg_replace('/\s+/', ' ', $clean) ?? '';
return $clean; return $clean;
}, $parts), fn (string $p): bool => $p !== '')); }, $parts), fn(string $p): bool => $p !== ''));
} }
private function canonicalizeTag(string $raw): ?string private function canonicalizeTag(string $raw): ?string

View File

@ -2,6 +2,8 @@
namespace App\Filament\Pages\Gescon; namespace App\Filament\Pages\Gescon;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\RubricaUniversale;
use App\Models\User; use App\Models\User;
use App\Support\StabileContext; use App\Support\StabileContext;
use BackedEnum; use BackedEnum;
@ -10,12 +12,15 @@
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
class FornitoriArchivio extends Page class FornitoriArchivio extends Page
{ {
public int $fornitoriCount = 0; public int $fornitoriCount = 0;
public string $search = ''; public string $fornitoriSearch = '';
public string $activeTab = 'elenco'; public string $activeTab = 'elenco';
public ?int $selectedFornitoreId = null; public ?int $selectedFornitoreId = null;
@ -28,6 +33,36 @@ class FornitoriArchivio extends Page
public ?string $contoIban = null; public ?string $contoIban = null;
public ?string $contoIntestazioneEsatta = null; public ?string $contoIntestazioneEsatta = null;
public string $dipendentiRubricaSearch = '';
public string $searchInput = '';
public string $editRagioneSociale = '';
public string $editTitolo = '';
public string $editNome = '';
public string $editCognome = '';
public string $editEmail = '';
public string $editPec = '';
public string $editTelefono = '';
public string $editCellulare = '';
public string $editPartitaIva = '';
public string $editCodiceFiscale = '';
public string $editSitoWeb = '';
public string $editTags = '';
public string $editNote = '';
public string $newFornitoreRagioneSociale = '';
public string $newFornitoreNome = '';
public string $newFornitoreCognome = '';
public string $newFornitoreEmail = '';
public string $newFornitoreTelefono = '';
public string $newFornitorePartitaIva = '';
public string $newFornitoreCodiceFiscale = '';
public string $newDipendenteNome = '';
public string $newDipendenteCognome = '';
public string $newDipendenteEmail = '';
public string $newDipendenteTelefono = '';
protected static ?string $navigationLabel = 'Fornitori'; protected static ?string $navigationLabel = 'Fornitori';
@ -57,6 +92,8 @@ public function mount(): void
} catch (\Throwable) { } catch (\Throwable) {
$this->fornitoriCount = 0; $this->fornitoriCount = 0;
} }
$this->searchInput = $this->fornitoriSearch;
} }
protected function getTableQuery(): Builder protected function getTableQuery(): Builder
@ -94,24 +131,32 @@ public function getFornitoriRowsProperty(): Collection
->orderBy('cognome') ->orderBy('cognome')
->orderBy('nome'); ->orderBy('nome');
$term = trim($this->search); $term = trim($this->fornitoriSearch);
if ($term !== '') { if ($term !== '') {
$query->where(function (Builder $sub) use ($term): void { $hasTags = Schema::hasColumn('fornitori', 'tags');
$like = '%' . $term . '%'; $like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%';
$query->where(function (Builder $sub) use ($like, $hasTags): void {
$sub->where('ragione_sociale', 'like', $like) $sub->where('ragione_sociale', 'like', $like)
->orWhere('nome', 'like', $like) ->orWhere('nome', 'like', $like)
->orWhere('cognome', 'like', $like) ->orWhere('cognome', 'like', $like)
->orWhere('partita_iva', 'like', $like) ->orWhere('partita_iva', 'like', $like)
->orWhere('codice_fiscale', 'like', $like) ->orWhere('codice_fiscale', 'like', $like)
->orWhere('email', 'like', $like) ->orWhere('email', 'like', $like)
->orWhere('telefono', 'like', $like) ->orWhere('telefono', 'like', $like);
->orWhere('tags', 'like', $like);
if ($hasTags) {
$sub->orWhere('tags', 'like', $like);
}
}); });
} }
return $query->limit(400)->get(); return $query->limit(400)->get();
} }
public function updatedFornitoriSearch(): void
{}
public function getFornitoreLabel(Fornitore $fornitore): string public function getFornitoreLabel(Fornitore $fornitore): string
{ {
$label = trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? '')))); $label = trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? ''))));
@ -136,15 +181,157 @@ public function getSelectedFornitoreProperty(): ?Fornitore
} }
return $this->getTableQuery() return $this->getTableQuery()
->with(['rubrica', 'dipendenti.user'])
->withCount('dipendenti') ->withCount('dipendenti')
->find((int) $this->selectedFornitoreId); ->find((int) $this->selectedFornitoreId);
} }
/**
* @return array<int, string>
*/
public function getActiveSearchFiltersProperty(): array
{
return $this->extractSearchTokens((string) $this->fornitoriSearch);
}
/**
* @return Collection<int, FornitoreDipendente>
*/
public function getDipendentiRowsProperty(): Collection
{
$fornitore = $this->selectedFornitore;
if (! $fornitore instanceof Fornitore) {
return new Collection();
}
return FornitoreDipendente::query()
->with('user')
->where('fornitore_id', (int) $fornitore->id)
->orderByDesc('attivo')
->orderBy('cognome')
->orderBy('nome')
->get();
}
/**
* @return Collection<int, RubricaUniversale>
*/
public function getRubricaDipendentiCandidatesProperty(): Collection
{
$fornitore = $this->selectedFornitore;
if (! $fornitore instanceof Fornitore) {
return new Collection();
}
$query = RubricaUniversale::query()
->whereNull('deleted_at')
->where(function (Builder $q): void {
$q->where('tipo_contatto', 'persona_fisica')
->orWhereNull('tipo_contatto');
})
->where(function (Builder $q): void {
$q->whereNotNull('nome')
->where('nome', '!=', '')
->orWhereNotNull('cognome')
->where('cognome', '!=', '')
->orWhereNotNull('email')
->where('email', '!=', '');
});
if (Schema::hasColumn('rubrica_universale', 'amministratore_id')) {
$adminId = (int) ($fornitore->amministratore_id ?? 0);
if ($adminId > 0) {
$query->where(function (Builder $q) use ($adminId): void {
$q->where('amministratore_id', $adminId)
->orWhereNull('amministratore_id');
});
}
}
$term = trim($this->dipendentiRubricaSearch);
if ($term !== '') {
$tokens = $this->extractSearchTokens($term);
foreach ($tokens as $token) {
$like = '%' . $token . '%';
$query->where(function (Builder $sub) use ($like): void {
$sub->where('nome', 'like', $like)
->orWhere('cognome', 'like', $like)
->orWhere('ragione_sociale', 'like', $like)
->orWhere('email', 'like', $like)
->orWhere('telefono_cellulare', 'like', $like)
->orWhere('telefono_ufficio', 'like', $like)
->orWhere('codice_fiscale', 'like', $like);
});
}
}
return $query
->orderBy('cognome')
->orderBy('nome')
->limit(80)
->get(['id', 'nome', 'cognome', 'ragione_sociale', 'email', 'telefono_cellulare', 'telefono_ufficio', 'codice_fiscale']);
}
public function apriElenco(): void public function apriElenco(): void
{ {
$this->activeTab = 'elenco'; $this->activeTab = 'elenco';
} }
public function apriTabScheda(): void
{
if ((int) ($this->selectedFornitoreId ?? 0) <= 0) {
Notification::make()
->title('Seleziona prima un fornitore dalla Tab 1')
->warning()
->send();
$this->activeTab = 'elenco';
return;
}
$this->hydrateSchedaFromSelected();
$this->activeTab = 'scheda';
}
public function apriTabDipendenti(): void
{
if ((int) ($this->selectedFornitoreId ?? 0) <= 0) {
Notification::make()
->title('Seleziona prima un fornitore dalla Tab 1')
->warning()
->send();
$this->activeTab = 'elenco';
return;
}
$this->hydrateSchedaFromSelected();
$this->activeTab = 'dipendenti';
}
public function applicaRicerca(): void
{
$this->fornitoriSearch = trim($this->searchInput);
$this->activeTab = 'elenco';
}
public function pulisciRicerca(): void
{
$this->fornitoriSearch = '';
$this->searchInput = '';
$this->activeTab = 'elenco';
}
public function removeSearchFilter(string $token): void
{
$tokens = array_values(array_filter(
$this->extractSearchTokens((string) $this->fornitoriSearch),
static fn(string $value): bool => mb_strtolower($value) !== mb_strtolower($token)
));
$this->fornitoriSearch = implode(' e ', $tokens);
$this->searchInput = $this->fornitoriSearch;
$this->activeTab = 'elenco';
}
public function apriScheda(int $fornitoreId): void public function apriScheda(int $fornitoreId): void
{ {
$fornitore = $this->getTableQuery()->find($fornitoreId); $fornitore = $this->getTableQuery()->find($fornitoreId);
@ -158,6 +345,246 @@ public function apriScheda(int $fornitoreId): void
$this->loadSchedaState($fornitore); $this->loadSchedaState($fornitore);
} }
public function updatedSelectedFornitoreId(): void
{
$this->hydrateSchedaFromSelected();
}
public function createFornitoreRapido(): void
{
$adminId = $this->resolveAmministratoreId();
if ($adminId <= 0) {
Notification::make()->title('Amministratore non risolto')->danger()->send();
return;
}
$data = Validator::make([
'ragione_sociale' => trim($this->newFornitoreRagioneSociale),
'nome' => trim($this->newFornitoreNome),
'cognome' => trim($this->newFornitoreCognome),
'email' => trim($this->newFornitoreEmail),
'telefono' => trim($this->newFornitoreTelefono),
'partita_iva' => trim($this->newFornitorePartitaIva),
'codice_fiscale' => trim($this->newFornitoreCodiceFiscale),
], [
'ragione_sociale' => ['nullable', 'string', 'max:255'],
'nome' => ['nullable', 'string', 'max:255'],
'cognome' => ['nullable', 'string', 'max:255'],
'email' => ['nullable', 'email', 'max:255'],
'telefono' => ['nullable', 'string', 'max:64'],
'partita_iva' => ['nullable', 'string', 'max:32'],
'codice_fiscale' => ['nullable', 'string', 'max:32'],
])->validate();
if (($data['ragione_sociale'] ?? '') === '' && (($data['nome'] ?? '') === '' && ($data['cognome'] ?? '') === '')) {
Notification::make()->title('Inserisci almeno ragione sociale o nominativo')->warning()->send();
return;
}
$fornitore = Fornitore::query()->create([
'amministratore_id' => $adminId,
'ragione_sociale' => $this->cleanNullable($data['ragione_sociale'] ?? null),
'nome' => $this->cleanNullable($data['nome'] ?? null),
'cognome' => $this->cleanNullable($data['cognome'] ?? null),
'email' => $this->cleanNullable($data['email'] ?? null),
'telefono' => $this->cleanNullable($data['telefono'] ?? null),
'partita_iva' => $this->cleanNullable($data['partita_iva'] ?? null),
'codice_fiscale' => $this->cleanNullable($data['codice_fiscale'] ?? null),
]);
$this->newFornitoreRagioneSociale = '';
$this->newFornitoreNome = '';
$this->newFornitoreCognome = '';
$this->newFornitoreEmail = '';
$this->newFornitoreTelefono = '';
$this->newFornitorePartitaIva = '';
$this->newFornitoreCodiceFiscale = '';
$this->fornitoriCount = (int) $this->getTableQuery()->count();
$this->apriScheda((int) $fornitore->id);
Notification::make()->title('Fornitore creato')->success()->send();
}
public function saveSchedaAnagrafica(): void
{
$fornitore = $this->selectedFornitore;
if (! $fornitore instanceof Fornitore) {
Notification::make()->title('Seleziona un fornitore')->danger()->send();
return;
}
$data = Validator::make([
'ragione_sociale' => trim($this->editRagioneSociale),
'titolo' => trim($this->editTitolo),
'nome' => trim($this->editNome),
'cognome' => trim($this->editCognome),
'email' => trim($this->editEmail),
'pec' => trim($this->editPec),
'telefono' => trim($this->editTelefono),
'cellulare' => trim($this->editCellulare),
'partita_iva' => trim($this->editPartitaIva),
'codice_fiscale' => trim($this->editCodiceFiscale),
'sito_web' => trim($this->editSitoWeb),
'tags' => trim($this->editTags),
'note' => trim($this->editNote),
], [
'ragione_sociale' => ['nullable', 'string', 'max:255'],
'titolo' => ['nullable', 'string', 'max:50'],
'nome' => ['nullable', 'string', 'max:255'],
'cognome' => ['nullable', 'string', 'max:255'],
'email' => ['nullable', 'email', 'max:255'],
'pec' => ['nullable', 'email', 'max:255'],
'telefono' => ['nullable', 'string', 'max:64'],
'cellulare' => ['nullable', 'string', 'max:64'],
'partita_iva' => ['nullable', 'string', 'max:32'],
'codice_fiscale' => ['nullable', 'string', 'max:32'],
'sito_web' => ['nullable', 'string', 'max:255'],
'tags' => ['nullable', 'string', 'max:1000'],
'note' => ['nullable', 'string', 'max:5000'],
])->validate();
$fornitore->ragione_sociale = $this->cleanNullable($data['ragione_sociale'] ?? null);
if (Schema::hasColumn('fornitori', 'titolo')) {
$fornitore->titolo = $this->cleanNullable($data['titolo'] ?? null);
}
$fornitore->nome = $this->cleanNullable($data['nome'] ?? null);
$fornitore->cognome = $this->cleanNullable($data['cognome'] ?? null);
$fornitore->email = $this->cleanNullable($data['email'] ?? null);
$fornitore->pec = $this->cleanNullable($data['pec'] ?? null);
$fornitore->telefono = $this->cleanNullable($data['telefono'] ?? null);
$fornitore->cellulare = $this->cleanNullable($data['cellulare'] ?? null);
$fornitore->partita_iva = $this->cleanNullable($data['partita_iva'] ?? null);
$fornitore->codice_fiscale = $this->cleanNullable($data['codice_fiscale'] ?? null);
$fornitore->sito_web = $this->cleanNullable($data['sito_web'] ?? null);
$fornitore->tags = $this->cleanNullable($data['tags'] ?? null);
$fornitore->note = $this->cleanNullable($data['note'] ?? null);
$fornitore->save();
$this->loadSchedaState($fornitore->fresh());
Notification::make()->title('Anagrafica fornitore aggiornata')->success()->send();
}
public function addDipendenteManuale(): void
{
$fornitore = $this->selectedFornitore;
if (! $fornitore instanceof Fornitore) {
Notification::make()->title('Seleziona un fornitore')->warning()->send();
return;
}
$nome = trim($this->newDipendenteNome);
$email = trim($this->newDipendenteEmail);
if ($nome === '') {
Notification::make()->title('Nome dipendente obbligatorio')->warning()->send();
return;
}
if ($email !== '') {
$exists = FornitoreDipendente::query()
->where('fornitore_id', (int) $fornitore->id)
->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])
->exists();
if ($exists) {
Notification::make()->title('Dipendente con stessa email gia presente')->warning()->send();
return;
}
}
FornitoreDipendente::query()->create([
'fornitore_id' => (int) $fornitore->id,
'nome' => $nome,
'cognome' => $this->cleanNullable($this->newDipendenteCognome),
'email' => $this->cleanNullable($email),
'telefono' => $this->cleanNullable($this->newDipendenteTelefono),
'attivo' => true,
'created_by_user_id' => Auth::id(),
'updated_by_user_id' => Auth::id(),
]);
$this->newDipendenteNome = '';
$this->newDipendenteCognome = '';
$this->newDipendenteEmail = '';
$this->newDipendenteTelefono = '';
Notification::make()->title('Dipendente aggiunto')->success()->send();
}
public function addDipendenteFromRubrica(int $rubricaId): void
{
$fornitore = $this->selectedFornitore;
if (! $fornitore instanceof Fornitore) {
Notification::make()->title('Seleziona prima un fornitore')->warning()->send();
$this->activeTab = 'elenco';
return;
}
$rubrica = RubricaUniversale::query()->find((int) $rubricaId);
if (! $rubrica instanceof RubricaUniversale) {
Notification::make()->title('Contatto rubrica non trovato')->danger()->send();
return;
}
$email = trim((string) ($rubrica->email ?? ''));
$nome = trim((string) ($rubrica->nome ?? ''));
$cognome = trim((string) ($rubrica->cognome ?? ''));
$telefono = trim((string) ($rubrica->telefono_cellulare ?: $rubrica->telefono_ufficio ?: ''));
if ($nome === '' && $cognome === '') {
$fromRs = trim((string) ($rubrica->ragione_sociale ?? ''));
if ($fromRs !== '') {
$nome = $fromRs;
} else {
Notification::make()->title('Rubrica senza nominativo utile')->warning()->send();
return;
}
}
$exists = FornitoreDipendente::query()
->where('fornitore_id', (int) $fornitore->id)
->where(function (Builder $q) use ($email, $nome, $cognome, $telefono): void {
if ($email !== '') {
$q->orWhereRaw('LOWER(email) = ?', [mb_strtolower($email)]);
}
$full = mb_strtolower(trim($nome . ' ' . $cognome));
if ($full !== '') {
$q->orWhereRaw("LOWER(TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, '')))) = ?", [$full]);
}
if ($telefono !== '') {
$normalized = preg_replace('/\D+/', '', $telefono) ?? '';
if ($normalized !== '') {
$q->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono, ''), ' ', ''), '-', ''), '.', ''), '(', ''), ')', '') = ?", [$normalized]);
}
}
})
->exists();
if ($exists) {
Notification::make()->title('Dipendente gia collegato a questo fornitore')->warning()->send();
return;
}
$dipendente = new FornitoreDipendente();
$dipendente->fornitore_id = (int) $fornitore->id;
$dipendente->nome = $nome;
$dipendente->cognome = $cognome !== '' ? $cognome : null;
$dipendente->email = $email !== '' ? $email : null;
$dipendente->telefono = $telefono !== '' ? $telefono : null;
$dipendente->attivo = true;
$dipendente->created_by_user_id = Auth::id();
$dipendente->updated_by_user_id = Auth::id();
$existingNote = trim((string) ($rubrica->note ?? ''));
$linkNote = 'Agganciato da rubrica ID #' . (int) $rubrica->id;
$dipendente->note = Str::limit(trim($linkNote . ($existingNote !== '' ? (' | ' . $existingNote) : '')), 2000, '');
$dipendente->save();
Notification::make()->title('Dipendente collegato al fornitore')->success()->send();
$this->activeTab = 'dipendenti';
}
public function saveSchedaAutomazioni(): void public function saveSchedaAutomazioni(): void
{ {
$fornitore = $this->selectedFornitore; $fornitore = $this->selectedFornitore;
@ -209,6 +636,52 @@ private function loadSchedaState(Fornitore $fornitore): void
$this->contoIntestazioneEsatta = is_string($fornitore->intestazione_cc_esatta ?? null) $this->contoIntestazioneEsatta = is_string($fornitore->intestazione_cc_esatta ?? null)
? $fornitore->intestazione_cc_esatta ? $fornitore->intestazione_cc_esatta
: null; : null;
$this->editRagioneSociale = (string) ($fornitore->ragione_sociale ?? '');
$this->editTitolo = Schema::hasColumn('fornitori', 'titolo')
? (string) ($fornitore->titolo ?? '')
: '';
$this->editNome = (string) ($fornitore->nome ?? '');
$this->editCognome = (string) ($fornitore->cognome ?? '');
$this->editEmail = (string) ($fornitore->email ?? '');
$this->editPec = (string) ($fornitore->pec ?? '');
$this->editTelefono = (string) ($fornitore->telefono ?? '');
$this->editCellulare = (string) ($fornitore->cellulare ?? '');
$this->editPartitaIva = (string) ($fornitore->partita_iva ?? '');
$this->editCodiceFiscale = (string) ($fornitore->codice_fiscale ?? '');
$this->editSitoWeb = (string) ($fornitore->sito_web ?? '');
$this->editTags = (string) ($fornitore->tags ?? '');
$this->editNote = (string) ($fornitore->note ?? '');
}
private function resolveAmministratoreId(): int
{
$user = Auth::user();
if (! $user instanceof User) {
return 0;
}
$adminId = (int) ($user->amministratore?->id ?? 0);
if ($adminId > 0) {
return $adminId;
}
$stabile = StabileContext::getActiveStabile($user);
return (int) ($stabile?->amministratore_id ?? 0);
}
private function hydrateSchedaFromSelected(): void
{
if ((int) ($this->selectedFornitoreId ?? 0) <= 0) {
return;
}
$fornitore = $this->getTableQuery()->find((int) $this->selectedFornitoreId);
if (! $fornitore instanceof Fornitore) {
return;
}
$this->loadSchedaState($fornitore);
} }
private function cleanNullable(?string $value): ?string private function cleanNullable(?string $value): ?string
@ -216,4 +689,25 @@ private function cleanNullable(?string $value): ?string
$value = trim((string) $value); $value = trim((string) $value);
return $value !== '' ? $value : null; return $value !== '' ? $value : null;
} }
/**
* @return array<int, string>
*/
private function extractSearchTokens(string $raw): array
{
$term = trim(preg_replace('/\s+/', ' ', $raw) ?? '');
if ($term === '') {
return [];
}
$hasLogicalSeparator = preg_match('/[,;|+]|\s(?:e|ed|and)\s/ui', $term) === 1;
if (! $hasLogicalSeparator) {
return [$term];
}
$parts = preg_split('/(?:[,;|+]|\s(?:e|ed|and)\s)+/ui', $term) ?: [];
$parts = array_values(array_filter(array_map(static fn(string $value): string => trim($value), $parts), static fn(string $value): bool => $value !== ''));
return $parts !== [] ? array_values(array_unique($parts)) : [$term];
}
} }

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Filament\Pages\Gescon; namespace App\Filament\Pages\Gescon;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
@ -14,6 +13,7 @@
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use UnitEnum; use UnitEnum;
@ -64,14 +64,51 @@ protected function getTableQuery(): Builder
$query = RubricaUniversale::query(); $query = RubricaUniversale::query();
// Tenant-aware: mostra TUTTI i contatti dell'amministratore (più eventuali globali se presenti). // Allineamento con Anagrafica Unica: include solo contatti realmente collegati al tenant.
$query->where(function (Builder $q) use ($adminId): void {
$q->whereRaw('1 = 0');
if (Schema::hasColumn('rubrica_universale', 'amministratore_id')) { if (Schema::hasColumn('rubrica_universale', 'amministratore_id')) {
$query->where(function (Builder $q) use ($adminId) { $q->orWhere('rubrica_universale.amministratore_id', $adminId);
$q->where('amministratore_id', $adminId) }
->orWhereNull('amministratore_id');
if (Schema::hasTable('fornitori')) {
$q->orWhereExists(function ($sub) use ($adminId) {
$sub->select(DB::raw(1))
->from('fornitori as f')
->whereColumn('f.rubrica_id', 'rubrica_universale.id')
->where('f.amministratore_id', $adminId);
}); });
} }
if (Schema::hasTable('stabili') && Schema::hasColumn('stabili', 'rubrica_id')) {
$q->orWhereExists(function ($sub) use ($adminId) {
$sub->select(DB::raw(1))
->from('stabili as s')
->whereColumn('s.rubrica_id', 'rubrica_universale.id')
->where('s.amministratore_id', $adminId);
});
}
if (Schema::hasTable('rubrica_ruoli') && Schema::hasTable('stabili')) {
$q->orWhereExists(function ($sub) use ($adminId) {
$sub->select(DB::raw(1))
->from('rubrica_ruoli as rr')
->join('stabili as s2', 's2.id', '=', 'rr.stabile_id')
->whereColumn('rr.rubrica_id', 'rubrica_universale.id')
->where('s2.amministratore_id', $adminId);
});
}
});
$query->where(function (Builder $q): void {
$q->whereNotNull('ragione_sociale')->where('ragione_sociale', '!=', '')
->orWhereNotNull('cognome')->where('cognome', '!=', '')
->orWhereNotNull('nome')->where('nome', '!=', '')
->orWhereNotNull('codice_fiscale')->where('codice_fiscale', '!=', '')
->orWhereNotNull('partita_iva')->where('partita_iva', '!=', '');
});
return $query return $query
->orderByRaw("COALESCE(ragione_sociale, '')") ->orderByRaw("COALESCE(ragione_sociale, '')")
->orderByRaw("COALESCE(cognome, '')") ->orderByRaw("COALESCE(cognome, '')")

View File

@ -2,6 +2,7 @@
namespace App\Filament\Pages\Gescon; namespace App\Filament\Pages\Gescon;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\RubricaContattoCanale; use App\Models\RubricaContattoCanale;
use App\Models\RubricaRuolo; use App\Models\RubricaRuolo;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
@ -22,7 +23,9 @@
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class RubricaUniversaleScheda extends Page class RubricaUniversaleScheda extends Page
{ {
@ -68,6 +71,23 @@ class RubricaUniversaleScheda extends Page
public bool $isInlineEditing = false; public bool $isInlineEditing = false;
public string $sideTab = 'collegamenti';
/** @var array<int,array<string,mixed>> */
public array $fornitoriCollegati = [];
/** @var array<int,array<string,mixed>> */
public array $dipendentiFornitoreRows = [];
/** @var array<int,int|string> */
public array $dipendenteRubricaSelect = [];
/** @var array<int,array<string,string>> */
public array $dipendenteManualDraft = [];
/** @var array<int,string> */
public array $dipendentePbxExtension = [];
/** @var array<string,mixed> */ /** @var array<string,mixed> */
public array $inlineForm = []; public array $inlineForm = [];
@ -240,6 +260,7 @@ public function mount(int | string $record): void
->all(); ->all();
$this->fillInlineForm(); $this->fillInlineForm();
$this->hydrateFornitoriWorkspace();
} }
public function startInlineEdit(): void public function startInlineEdit(): void
@ -1020,4 +1041,221 @@ protected function getUnitaOptions($stabileId): array
}) })
->all(); ->all();
} }
private function hydrateFornitoriWorkspace(): void
{
$rubricaId = (int) $this->rubrica->id;
$cf = trim((string) ($this->rubrica->codice_fiscale ?? ''));
$piva = trim((string) ($this->rubrica->partita_iva ?? ''));
$email = mb_strtolower(trim((string) ($this->rubrica->email ?? '')));
$query = Fornitore::query();
if ($this->adminId > 0) {
$query->where('amministratore_id', $this->adminId);
}
$query->where(function (Builder $q) use ($rubricaId, $cf, $piva, $email): void {
$q->where('rubrica_id', $rubricaId);
if ($cf !== '') {
$q->orWhere('codice_fiscale', $cf);
}
if ($piva !== '') {
$q->orWhere('partita_iva', $piva);
}
if ($email !== '') {
$q->orWhereRaw('LOWER(email) = ?', [$email]);
}
});
$fornitori = $query
->orderByRaw("COALESCE(ragione_sociale, '')")
->orderBy('id')
->limit(80)
->get(['id', 'rubrica_id', 'ragione_sociale', 'nome', 'cognome', 'email', 'telefono', 'cellulare', 'codice_fiscale', 'partita_iva']);
$this->fornitoriCollegati = $fornitori->map(fn(Fornitore $f) => [
'id' => (int) $f->id,
'nome' => (string) ($f->ragione_sociale ?: trim((string) ($f->nome ?? '') . ' ' . (string) ($f->cognome ?? '')) ?: ('Fornitore #' . $f->id)),
'rubrica_id' => (int) ($f->rubrica_id ?? 0),
'email' => (string) ($f->email ?? ''),
'telefono' => (string) ($f->telefono ?: $f->cellulare ?: ''),
'cf' => (string) ($f->codice_fiscale ?? ''),
'piva' => (string) ($f->partita_iva ?? ''),
])->all();
$fornitoreIds = $fornitori->pluck('id')->map(fn($v) => (int) $v)->all();
if ($fornitoreIds === []) {
$this->dipendentiFornitoreRows = [];
return;
}
$dipendenti = FornitoreDipendente::query()
->with(['user.roles', 'fornitore'])
->whereIn('fornitore_id', $fornitoreIds)
->orderByDesc('attivo')
->orderBy('cognome')
->orderBy('nome')
->limit(300)
->get();
$this->dipendentiFornitoreRows = $dipendenti->map(function (FornitoreDipendente $d): array {
$user = $d->user;
$this->dipendentePbxExtension[(int) $d->id] = (string) ($user?->pbx_extension ?? '');
return [
'id' => (int) $d->id,
'fornitore_id' => (int) $d->fornitore_id,
'fornitore_nome' => (string) ($d->fornitore?->ragione_sociale ?: ('Fornitore #' . $d->fornitore_id)),
'nome' => (string) $d->nome_completo,
'email' => (string) ($d->email ?? ''),
'telefono' => (string) ($d->telefono ?? ''),
'attivo' => (bool) $d->attivo,
'user_id' => $user ? (int) $user->id : null,
'ruoli' => $user ? $user->roles->pluck('name')->values()->all() : [],
'pbx_extension' => (string) ($user?->pbx_extension ?? ''),
];
})->all();
}
public function collegaRubricaDipendente(int $fornitoreId): void
{
$rubricaId = (int) ($this->dipendenteRubricaSelect[$fornitoreId] ?? 0);
if ($rubricaId <= 0) {
Notification::make()->title('Inserisci ID rubrica da collegare')->warning()->send();
return;
}
$fornitore = Fornitore::query()->find($fornitoreId);
if (! $fornitore || ($this->adminId > 0 && (int) ($fornitore->amministratore_id ?? 0) !== $this->adminId)) {
Notification::make()->title('Fornitore non valido')->danger()->send();
return;
}
$rubricaDip = RubricaUniversale::query()->find($rubricaId);
if (! $rubricaDip) {
Notification::make()->title('Rubrica dipendente non trovata')->danger()->send();
return;
}
$email = mb_strtolower(trim((string) ($rubricaDip->email ?? '')));
$query = FornitoreDipendente::query()->where('fornitore_id', (int) $fornitore->id);
if ($email !== '') {
$query->whereRaw('LOWER(email) = ?', [$email]);
} else {
$query->where('nome', (string) ($rubricaDip->nome ?? ''))
->where('cognome', (string) ($rubricaDip->cognome ?? ''));
}
$dip = $query->first();
if (! $dip) {
$dip = new FornitoreDipendente();
$dip->fornitore_id = (int) $fornitore->id;
$dip->created_by_user_id = Auth::id();
}
$dip->nome = (string) ($rubricaDip->nome ?: $rubricaDip->ragione_sociale ?: 'Dipendente');
$dip->cognome = (string) ($rubricaDip->cognome ?? '');
$dip->email = $email !== '' ? $email : null;
$dip->telefono = (string) ($rubricaDip->telefono_cellulare ?: $rubricaDip->telefono_ufficio ?: '');
$dip->attivo = true;
$dip->updated_by_user_id = Auth::id();
$dip->save();
unset($this->dipendenteRubricaSelect[$fornitoreId]);
$this->hydrateFornitoriWorkspace();
Notification::make()->title('Dipendente collegato da rubrica')->success()->send();
}
public function creaDipendenteManuale(int $fornitoreId): void
{
$draft = (array) ($this->dipendenteManualDraft[$fornitoreId] ?? []);
$nome = trim((string) ($draft['nome'] ?? ''));
$cognome = trim((string) ($draft['cognome'] ?? ''));
$email = mb_strtolower(trim((string) ($draft['email'] ?? '')));
$telefono = trim((string) ($draft['telefono'] ?? ''));
if ($nome === '') {
Notification::make()->title('Nome dipendente obbligatorio')->warning()->send();
return;
}
$fornitore = Fornitore::query()->find($fornitoreId);
if (! $fornitore || ($this->adminId > 0 && (int) ($fornitore->amministratore_id ?? 0) !== $this->adminId)) {
Notification::make()->title('Fornitore non valido')->danger()->send();
return;
}
$dip = new FornitoreDipendente();
$dip->fornitore_id = (int) $fornitore->id;
$dip->nome = $nome;
$dip->cognome = $cognome !== '' ? $cognome : null;
$dip->email = $email !== '' ? $email : null;
$dip->telefono = $telefono !== '' ? $telefono : null;
$dip->attivo = true;
$dip->created_by_user_id = Auth::id();
$dip->updated_by_user_id = Auth::id();
$dip->save();
unset($this->dipendenteManualDraft[$fornitoreId]);
$this->hydrateFornitoriWorkspace();
Notification::make()->title('Nuovo dipendente creato')->success()->send();
}
public function abilitaAccessoDipendente(int $dipendenteId): void
{
$dip = FornitoreDipendente::query()->with('fornitore')->find($dipendenteId);
if (! $dip || ($this->adminId > 0 && (int) ($dip->fornitore?->amministratore_id ?? 0) !== $this->adminId)) {
Notification::make()->title('Dipendente non valido')->danger()->send();
return;
}
$email = mb_strtolower(trim((string) ($dip->email ?? '')));
if ($email === '') {
Notification::make()->title('Email dipendente mancante')->warning()->send();
return;
}
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
if (! $user) {
$tmpPwd = Str::random(12);
$user = User::query()->create([
'name' => trim((string) ($dip->nome_completo ?: 'Utente fornitore')),
'email' => $email,
'password' => Hash::make($tmpPwd),
'email_verified_at' => now(),
'is_active' => true,
]);
}
$user->assignRole('fornitore');
$dip->user_id = (int) $user->id;
$dip->updated_by_user_id = Auth::id();
$dip->save();
$this->hydrateFornitoriWorkspace();
Notification::make()->title('Accesso dipendente abilitato')->success()->send();
}
public function salvaInternoDipendente(int $dipendenteId): void
{
$dip = FornitoreDipendente::query()->with('user', 'fornitore')->find($dipendenteId);
if (! $dip || ! $dip->user || ($this->adminId > 0 && (int) ($dip->fornitore?->amministratore_id ?? 0) !== $this->adminId)) {
Notification::make()->title('Dipendente/utente non valido')->danger()->send();
return;
}
if (! Schema::hasColumn('users', 'pbx_extension')) {
Notification::make()->title('Campo interno PBX non disponibile')->warning()->send();
return;
}
$ext = trim((string) ($this->dipendentePbxExtension[$dipendenteId] ?? ''));
$dip->user->pbx_extension = $ext !== '' ? $ext : null;
$dip->user->save();
$this->hydrateFornitoriWorkspace();
Notification::make()->title('Interno PBX aggiornato')->success()->send();
}
} }

View File

@ -2,11 +2,15 @@
namespace App\Filament\Pages\Impostazioni; namespace App\Filament\Pages\Impostazioni;
use App\Models\Amministratore; use App\Models\Amministratore;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\Stabile; use App\Models\Stabile;
use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Support\StabileContext; use App\Support\StabileContext;
use BackedEnum; use BackedEnum;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -21,6 +25,7 @@
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
use UnitEnum; use UnitEnum;
@ -51,6 +56,11 @@ class SchedaAmministratore extends Page implements HasForms
public ?string $lastUpdatePackageName = null; public ?string $lastUpdatePackageName = null;
public ?string $lastGeneratedPassword = null;
/** @var array<int,string> */
public array $collaboratorePbxExtension = [];
public Amministratore $amministratore; public Amministratore $amministratore;
public static function canAccess(): bool public static function canAccess(): bool
@ -182,6 +192,23 @@ public function form(Schema $schema): Schema
->email() ->email()
->maxLength(255), ->maxLength(255),
]), ]),
Section::make('Centralino studio')
->columns(2)
->schema([
TextInput::make('impostazioni.centralino.numero_principale')
->label('Numero principale centralino')
->maxLength(30),
TextInput::make('impostazioni.centralino.numero_backup')
->label('Numero secondario/backup')
->maxLength(30),
TextInput::make('impostazioni.centralino.numero_emergenza')
->label('Numero emergenza')
->maxLength(30),
TextInput::make('impostazioni.centralino.note_instradamento')
->label('Note instradamento')
->maxLength(255),
]),
]), ]),
Tab::make('Documento') Tab::make('Documento')
@ -557,6 +584,16 @@ public function form(Schema $schema): Schema
->maxLength(255), ->maxLength(255),
]), ]),
]), ]),
Tab::make('Operativita / Accessi')
->schema([
Section::make('Azioni operative e gestione accessi')
->schema([
Placeholder::make('ops_access_panel')
->hiddenLabel()
->content(fn() => view('filament.pages.impostazioni.partials.scheda-amministratore-operativita-accessi')),
]),
]),
]), ]),
]); ]);
} }
@ -794,6 +831,344 @@ public function disconnectGoogle(): void
->send(); ->send();
} }
/**
* @return array<int,array<string,mixed>>
*/
public function getAccessoFornitoriRowsProperty(): array
{
return FornitoreDipendente::query()
->with(['user.roles', 'fornitore'])
->whereHas('fornitore', function ($q): void {
$q->where('amministratore_id', (int) $this->amministratore->id);
})
->orderByDesc('attivo')
->orderBy('cognome')
->orderBy('nome')
->limit(120)
->get()
->map(function (FornitoreDipendente $dipendente): array {
$user = $dipendente->user;
return [
'dipendente_id' => (int) $dipendente->id,
'fornitore' => (string) ($dipendente->fornitore?->ragione_sociale ?: $dipendente->fornitore?->nome ?: 'Fornitore'),
'nome' => $dipendente->nome_completo,
'email' => (string) ($dipendente->email ?? ''),
'attivo' => (bool) $dipendente->attivo,
'user_id' => $user ? (int) $user->id : null,
'ruoli' => $user ? $user->roles->pluck('name')->values()->all() : [],
];
})
->all();
}
/**
* @return array<int,array<string,mixed>>
*/
public function getFornitoriDaTicketRowsProperty(): array
{
$fornitoreIds = Ticket::query()
->whereNotNull('assegnato_a_fornitore_id')
->whereHas('stabile', function ($q): void {
$q->where('amministratore_id', (int) $this->amministratore->id);
})
->distinct()
->pluck('assegnato_a_fornitore_id')
->filter(fn($v) => is_numeric($v))
->map(fn($v) => (int) $v)
->values()
->all();
if ($fornitoreIds === []) {
return [];
}
return Fornitore::query()
->whereIn('id', $fornitoreIds)
->orderByRaw("COALESCE(ragione_sociale, nome, '')")
->get()
->map(function (Fornitore $fornitore): array {
$linkedDipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $fornitore->id)
->whereNotNull('user_id')
->first();
return [
'fornitore_id' => (int) $fornitore->id,
'fornitore_nome' => (string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? '')) ?: ('Fornitore #' . $fornitore->id)),
'email' => (string) ($fornitore->email ?? ''),
'telefono' => (string) ($fornitore->telefono ?: $fornitore->cellulare ?: ''),
'linked_user_id' => $linkedDipendente ? (int) $linkedDipendente->user_id : null,
];
})
->values()
->all();
}
public function abilitaAccessoFornitoreDaTicket(int $fornitoreId): void
{
$fornitore = Fornitore::query()->find($fornitoreId);
if (! $fornitore || (int) ($fornitore->amministratore_id ?? 0) !== (int) $this->amministratore->id) {
Notification::make()->title('Fornitore non valido')->danger()->send();
return;
}
$email = trim((string) ($fornitore->email ?? ''));
if ($email === '') {
Notification::make()
->title('Email fornitore mancante')
->warning()
->body('Inserisci email del fornitore per creare credenziali di accesso.')
->send();
return;
}
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $fornitore->id)
->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])
->first();
if (! $dipendente) {
$dipendente = FornitoreDipendente::query()->create([
'fornitore_id' => (int) $fornitore->id,
'nome' => (string) ($fornitore->ragione_sociale ?: ($fornitore->nome ?: 'Referente fornitore')),
'cognome' => $fornitore->ragione_sociale ? null : (($fornitore->cognome ?? null) ?: null),
'email' => $email,
'telefono' => $fornitore->telefono ?: $fornitore->cellulare,
'attivo' => true,
'created_by_user_id' => Auth::id(),
'updated_by_user_id' => Auth::id(),
]);
}
$this->abilitaAccessoFornitore((int) $dipendente->id);
}
/**
* @return array<int,array<string,string>>
*/
public function getPendingUsersReviewRowsProperty(): array
{
$output = (string) ($this->opsLastOutput ?? '');
if ($output === '' || ! str_contains($output, 'PENDING_USERS=')) {
return [];
}
$rows = [];
$lines = preg_split('/\r\n|\r|\n/', $output) ?: [];
foreach ($lines as $line) {
$line = trim((string) $line);
if (! str_starts_with($line, 'id=')) {
continue;
}
preg_match('/id=(\d+)/', $line, $idMatch);
preg_match('/email=([^|]+)/', $line, $emailMatch);
preg_match('/name=([^|]+)/', $line, $nameMatch);
preg_match('/roles=([^|]+)/', $line, $rolesMatch);
preg_match('/flags=([^|]+)/', $line, $flagsMatch);
preg_match('/created=(.+)$/', $line, $createdMatch);
$rows[] = [
'id' => isset($idMatch[1]) ? trim($idMatch[1]) : '-',
'email' => isset($emailMatch[1]) ? trim($emailMatch[1]) : '-',
'name' => isset($nameMatch[1]) ? trim($nameMatch[1]) : '-',
'roles' => isset($rolesMatch[1]) ? trim($rolesMatch[1]) : '-',
'flags' => isset($flagsMatch[1]) ? trim($flagsMatch[1]) : '-',
'created' => isset($createdMatch[1]) ? trim($createdMatch[1]) : '-',
];
}
return $rows;
}
/**
* @return array<int,array<string,mixed>>
*/
public function getAccessoAmministratoreRowsProperty(): array
{
$query = User::query()
->with('roles')
->whereHas('roles', function ($q): void {
$q->whereIn('name', ['super-admin', 'admin', 'amministratore']);
})
->where(function ($q): void {
$q->whereKey((int) ($this->amministratore->user_id ?? 0))
->orWhereHas('amministratore', function ($qa): void {
$qa->whereKey((int) $this->amministratore->id);
});
})
->orderBy('name')
->limit(40);
return $query
->get()
->map(fn(User $user): array=> [
'user_id' => (int) $user->id,
'name' => (string) $user->name,
'email' => (string) $user->email,
'ruoli' => $user->roles->pluck('name')->values()->all(),
])
->all();
}
/**
* @return array<int,array<string,mixed>>
*/
public function getCollaboratoriCentralinoRowsProperty(): array
{
$query = User::query()
->with('roles')
->whereHas('roles', function ($q): void {
$q->where('name', 'collaboratore');
})
->whereHas('stabiliAssegnati', function ($q): void {
$q->where('amministratore_id', (int) $this->amministratore->id);
})
->orderBy('name')
->limit(200);
return $query
->get()
->map(function (User $user): array {
$this->collaboratorePbxExtension[(int) $user->id] = (string) ($user->pbx_extension ?? '');
return [
'user_id' => (int) $user->id,
'name' => (string) $user->name,
'email' => (string) $user->email,
'ruoli' => $user->roles->pluck('name')->values()->all(),
'pbx_extension' => (string) ($user->pbx_extension ?? ''),
'pbx_popup_enabled' => (bool) ($user->pbx_popup_enabled ?? false),
'pbx_click_to_call_enabled' => (bool) ($user->pbx_click_to_call_enabled ?? false),
];
})
->all();
}
public function salvaInternoCollaboratore(int $userId): void
{
$user = User::query()->find($userId);
if (! $user) {
Notification::make()->title('Utente non trovato')->danger()->send();
return;
}
$isCollaboratoreDelTenant = $user->hasRole('collaboratore')
&& $user->stabiliAssegnati()->where('amministratore_id', (int) $this->amministratore->id)->exists();
if (! $isCollaboratoreDelTenant) {
Notification::make()->title('Collaboratore non valido')->danger()->send();
return;
}
$extension = trim((string) ($this->collaboratorePbxExtension[$userId] ?? ''));
$user->pbx_extension = $extension !== '' ? $extension : null;
$user->save();
Notification::make()
->title('Interno collaboratore aggiornato')
->success()
->send();
}
public function abilitaAccessoFornitore(int $dipendenteId): void
{
$dipendente = FornitoreDipendente::query()
->with('fornitore')
->whereKey($dipendenteId)
->first();
if (! $dipendente || (int) ($dipendente->fornitore?->amministratore_id ?? 0) !== (int) $this->amministratore->id) {
Notification::make()->title('Dipendente non valido')->danger()->send();
return;
}
$email = trim((string) ($dipendente->email ?? ''));
if ($email === '') {
Notification::make()
->title('Email mancante')
->warning()
->body('Imposta prima un\'email per il dipendente fornitore.')
->send();
return;
}
$user = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first();
$created = false;
if (! $user) {
$createdPassword = $this->generateTemporaryPassword();
$user = User::query()->create([
'name' => trim((string) ($dipendente->nome_completo ?: 'Utente Fornitore')),
'email' => $email,
'password' => Hash::make($createdPassword),
'email_verified_at' => now(),
'is_active' => true,
]);
$this->lastGeneratedPassword = $createdPassword;
$created = true;
}
$user->assignRole('fornitore');
$dipendente->user_id = (int) $user->id;
$dipendente->save();
$body = $created
? 'Accesso creato. Password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)')
: 'Accesso attivo/aggiornato. Se necessario, usa il reset password.';
Notification::make()
->title('Accesso fornitore abilitato')
->success()
->body($body)
->send();
}
public function resetPasswordUtente(int $userId): void
{
$user = User::query()->find($userId);
if (! $user) {
Notification::make()->title('Utente non trovato')->danger()->send();
return;
}
$isAllowedAdminUser = (int) $user->id === (int) ($this->amministratore->user_id ?? 0);
$isAllowedSupplierUser = FornitoreDipendente::query()
->where('user_id', (int) $user->id)
->whereHas('fornitore', function ($q): void {
$q->where('amministratore_id', (int) $this->amministratore->id);
})
->exists();
if (! $isAllowedAdminUser && ! $isAllowedSupplierUser) {
Notification::make()
->title('Operazione non consentita')
->danger()
->body('Puoi resettare solo utenti collegati al tuo gruppo amministratore/fornitore.')
->send();
return;
}
$newPassword = $this->generateTemporaryPassword();
$user->password = Hash::make($newPassword);
$user->save();
$this->lastGeneratedPassword = $newPassword;
Notification::make()
->title('Password resettata')
->success()
->body('Nuova password temporanea: ' . $newPassword)
->send();
}
private function generateTemporaryPassword(): string
{
return Str::random(12);
}
private function runOpsProcess(array $command, string $successTitle, string $errorTitle, int $timeoutSeconds): void private function runOpsProcess(array $command, string $successTitle, string $errorTitle, int $timeoutSeconds): void
{ {
$process = new Process($command, base_path()); $process = new Process($command, base_path());

View File

@ -1,25 +1,24 @@
<?php <?php
namespace App\Filament\Pages\RevisioneContabile; namespace App\Filament\Pages\RevisioneContabile;
use App\Models\RevisionePrecedenteAmministratore; use App\Models\RevisionePrecedenteAmministratore;
use App\Models\RevisionePrecedenteMovimentoBanca; use App\Models\RevisionePrecedenteMovimentoBanca;
use App\Services\Contabilita\CedhouseCsvParser;
use App\Models\UnitaImmobiliare; use App\Models\UnitaImmobiliare;
use App\Models\User; use App\Models\User;
use App\Services\Contabilita\CedhouseCsvParser;
use BackedEnum; use BackedEnum;
use Filament\Pages\Page;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Symfony\Component\Process\Process; use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Livewire\WithFileUploads;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\WithFileUploads;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Symfony\Component\Process\Process;
use UnitEnum; use UnitEnum;
class PrecedentiAmministratoriDettaglio extends Page class PrecedentiAmministratoriDettaglio extends Page
@ -224,7 +223,7 @@ private function loadPdfFiles(): void
]; ];
} }
usort($list, fn ($a, $b) => strcmp($a['name'], $b['name'])); usort($list, fn($a, $b) => strcmp($a['name'], $b['name']));
$this->pdfFiles = $list; $this->pdfFiles = $list;
} }
@ -261,7 +260,7 @@ private function loadBilancioFiles(): void
} }
} }
usort($list, fn ($a, $b) => strcmp($a['name'], $b['name'])); usort($list, fn($a, $b) => strcmp($a['name'], $b['name']));
$this->bilancioFiles = $list; $this->bilancioFiles = $list;
if ($this->bilancioFile === null) { if ($this->bilancioFile === null) {
@ -328,7 +327,7 @@ public function updatedBilancioFile(): void
public function togglePdfDone(string $name): void public function togglePdfDone(string $name): void
{ {
if (in_array($name, $this->pdfDone, true)) { if (in_array($name, $this->pdfDone, true)) {
$this->pdfDone = array_values(array_filter($this->pdfDone, fn ($v) => $v !== $name)); $this->pdfDone = array_values(array_filter($this->pdfDone, fn($v) => $v !== $name));
} else { } else {
$this->pdfDone[] = $name; $this->pdfDone[] = $name;
} }
@ -459,7 +458,7 @@ public function getEstrattiGestioneStraordinariaAllProperty(): array
public function getOrdinaria2023CondominoRiepilogoComparisonProperty(): array public function getOrdinaria2023CondominoRiepilogoComparisonProperty(): array
{ {
$gestioneRows = $this->estrattoGestioneOrdinaria; $gestioneRows = $this->estrattoGestioneOrdinaria;
$versRows = array_values(array_filter($gestioneRows, fn ($r) => ($r['colonna'] ?? '') === 'versamenti')); $versRows = array_values(array_filter($gestioneRows, fn($r) => ($r['colonna'] ?? '') === 'versamenti'));
$linked = $this->estrattoLinkedUnita; $linked = $this->estrattoLinkedUnita;
$nominativo = trim((string) ($linked['denominazione'] ?? '')); $nominativo = trim((string) ($linked['denominazione'] ?? ''));
@ -468,7 +467,7 @@ public function getOrdinaria2023CondominoRiepilogoComparisonProperty(): array
$nominativo = $this->extractNominativoFromDescrizione((string) ($versRows[0]['descrizione'] ?? '')); $nominativo = $this->extractNominativoFromDescrizione((string) ($versRows[0]['descrizione'] ?? ''));
} }
$dates = array_values(array_filter(array_map(fn ($r) => $r['data'] ?? null, $versRows))); $dates = array_values(array_filter(array_map(fn($r) => $r['data'] ?? null, $versRows)));
$from = null; $from = null;
$to = null; $to = null;
foreach ($dates as $d) { foreach ($dates as $d) {
@ -484,7 +483,7 @@ public function getOrdinaria2023CondominoRiepilogoComparisonProperty(): array
} }
} }
$estrattoTot = (float) collect($versRows)->sum(fn ($r) => (float) ($r['importo'] ?? 0)); $estrattoTot = (float) collect($versRows)->sum(fn($r) => (float) ($r['importo'] ?? 0));
$riepilogoRows = []; $riepilogoRows = [];
$riepilogoTot = 0.0; $riepilogoTot = 0.0;
@ -604,7 +603,7 @@ public function getRiepilogoVersamentiForCurrentNominativoProperty(): array
$estrattoRows = $this->estrattiGestioneOrdinaria2023All; $estrattoRows = $this->estrattiGestioneOrdinaria2023All;
} }
$estrattoRows = array_values(array_filter($estrattoRows, fn ($r) => ($r['tipo'] ?? '') === 'versamento')); $estrattoRows = array_values(array_filter($estrattoRows, fn($r) => ($r['tipo'] ?? '') === 'versamento'));
$filesSet = []; $filesSet = [];
$fileRanges = []; $fileRanges = [];
foreach ($estrattoRows as $row) { foreach ($estrattoRows as $row) {
@ -760,7 +759,7 @@ private function buildVersamentiSummary(string $gestione): array
$estrattoRows = $this->estrattiGestioneOrdinaria2023All; $estrattoRows = $this->estrattiGestioneOrdinaria2023All;
} }
$estrattoRows = array_values(array_filter($estrattoRows, fn ($r) => ($r['tipo'] ?? '') === 'versamento')); $estrattoRows = array_values(array_filter($estrattoRows, fn($r) => ($r['tipo'] ?? '') === 'versamento'));
$filesSet = []; $filesSet = [];
$fileRanges = []; $fileRanges = [];
foreach ($estrattoRows as $row) { foreach ($estrattoRows as $row) {
@ -1234,7 +1233,7 @@ public function getEstrattoConguagliProperty(): array
return []; return [];
} }
return array_values(array_filter($rows, fn (array $row): bool => ($row['tipo'] ?? '') === 'conguaglio')); return array_values(array_filter($rows, fn(array $row): bool => ($row['tipo'] ?? '') === 'conguaglio'));
} }
/** /**
@ -1511,7 +1510,7 @@ public function getEstrattiVersamentiTotalProperty(): float
} }
return (float) collect($rows) return (float) collect($rows)
->where('colonna', 'versamenti') ->where('colonna', 'versamenti')
->sum(fn ($r) => (float) ($r['importo'] ?? 0)); ->sum(fn($r) => (float) ($r['importo'] ?? 0));
} }
/** /**
@ -1635,7 +1634,7 @@ public function getBilancioStatoPatrimonialeProperty(): array
return $rows; return $rows;
} }
$totalsLabels = array_map(fn ($t) => $normalize($t['label'] ?? ''), $fornitoriTotals); $totalsLabels = array_map(fn($t) => $normalize($t['label'] ?? ''), $fornitoriTotals);
$totalsFirst = array_search($firstLabel, $totalsLabels, true); $totalsFirst = array_search($firstLabel, $totalsLabels, true);
$totalsLast = array_search($lastLabel, $totalsLabels, true); $totalsLast = array_search($lastLabel, $totalsLabels, true);
if ($totalsFirst === false || $totalsLast === false || $totalsFirst > $totalsLast) { if ($totalsFirst === false || $totalsLast === false || $totalsFirst > $totalsLast) {
@ -1956,11 +1955,11 @@ public function getEstrattiVersamentiOrdinaria2023TotalsProperty(): array
$rows = $this->parseGestioneOrdinariaFromContent($content, 2023); $rows = $this->parseGestioneOrdinariaFromContent($content, 2023);
$versamenti = (float) collect($rows) $versamenti = (float) collect($rows)
->where('colonna', 'versamenti') ->where('colonna', 'versamenti')
->sum(fn ($r) => (float) ($r['importo'] ?? 0)); ->sum(fn($r) => (float) ($r['importo'] ?? 0));
$rateOrd = (float) collect($rows) $rateOrd = (float) collect($rows)
->where('colonna', 'rate') ->where('colonna', 'rate')
->where('tipo', 'rata') ->where('tipo', 'rata')
->sum(fn ($r) => (float) ($r['importo'] ?? 0)); ->sum(fn($r) => (float) ($r['importo'] ?? 0));
$totaleOrd = $versamenti + $rateOrd; $totaleOrd = $versamenti + $rateOrd;
$pdfRow = null; $pdfRow = null;
@ -2525,7 +2524,7 @@ public function getUnitaImmobiliariListProperty(): array
->orderByRaw('CAST(interno AS UNSIGNED)') ->orderByRaw('CAST(interno AS UNSIGNED)')
->orderBy('interno') ->orderBy('interno')
->get() ->get()
->map(fn (UnitaImmobiliare $unita): array => [ ->map(fn(UnitaImmobiliare $unita): array=> [
'id' => $unita->id, 'id' => $unita->id,
'codice_unita' => $unita->codice_unita, 'codice_unita' => $unita->codice_unita,
'scala' => $unita->scala, 'scala' => $unita->scala,
@ -2800,10 +2799,10 @@ public function importMovimentiCedhouse(): void
$matchQuery = RevisionePrecedenteMovimentoBanca::query() $matchQuery = RevisionePrecedenteMovimentoBanca::query()
->where('revisione_precedente_amministratore_id', $this->record->id) ->where('revisione_precedente_amministratore_id', $this->record->id)
->when($rowData !== null, fn ($q) => $q->whereDate('data', $rowData)) ->when($rowData !== null, fn($q) => $q->whereDate('data', $rowData))
->when($rowNumero !== null && $rowNumero !== '', fn ($q) => $q->where('numero', $rowNumero)) ->when($rowNumero !== null && $rowNumero !== '', fn($q) => $q->where('numero', $rowNumero))
->when($rowTipo !== null && $rowTipo !== '', fn ($q) => $q->where('tipo_riga', $rowTipo)) ->when($rowTipo !== null && $rowTipo !== '', fn($q) => $q->where('tipo_riga', $rowTipo))
->when($rowDescrizione !== null && $rowDescrizione !== '', fn ($q) => $q->where('descrizione', $rowDescrizione)); ->when($rowDescrizione !== null && $rowDescrizione !== '', fn($q) => $q->where('descrizione', $rowDescrizione));
$matchedRows = $matchQuery->get(); $matchedRows = $matchQuery->get();
if ($matchedRows->isNotEmpty()) { if ($matchedRows->isNotEmpty()) {
@ -3096,7 +3095,7 @@ public function getRiepilogoMovimentiComparisonProperty(): array
]; ];
} }
usort($rows, fn ($a, $b) => strcmp($a['data'], $b['data'])); usort($rows, fn($a, $b) => strcmp($a['data'], $b['data']));
return $rows; return $rows;
} }
@ -3516,7 +3515,7 @@ public static function canAccess(): bool
{ {
$user = Auth::user(); $user = Auth::user();
return $user instanceof User return $user instanceof User
&& ($user->hasRole('super-admin') || $user->can('manage-accounting') || $user->can('contabilita.casse-banche')); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
} }
/** /**
* @return float[] * @return float[]

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Filament\Pages\RevisioneContabile; namespace App\Filament\Pages\RevisioneContabile;
use App\Models\RevisionePrecedenteAmministratore; use App\Models\RevisionePrecedenteAmministratore;
@ -8,15 +7,14 @@
use App\Models\User; use App\Models\User;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\WithPagination; use Livewire\WithPagination;
use UnitEnum; use UnitEnum;
@ -34,7 +32,7 @@ class PrecedentiAmministratoriElenco extends Page
protected static ?int $navigationSort = 10; protected static ?int $navigationSort = 10;
protected static bool $shouldRegisterNavigation = false; protected static bool $shouldRegisterNavigation = true;
protected string $view = 'filament.pages.revisione-contabile.precedenti-amministratori-elenco'; protected string $view = 'filament.pages.revisione-contabile.precedenti-amministratori-elenco';
@ -137,6 +135,6 @@ public static function canAccess(): bool
{ {
$user = Auth::user(); $user = Auth::user();
return $user instanceof User return $user instanceof User
&& ($user->hasRole('super-admin') || $user->can('manage-accounting') || $user->can('contabilita.casse-banche')); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
} }
} }

View File

@ -1,7 +1,10 @@
<?php <?php
namespace App\Filament\Pages\Strumenti; namespace App\Filament\Pages\Strumenti;
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
use App\Models\ChiamataPostIt; use App\Models\ChiamataPostIt;
use App\Models\CommunicationMessage;
use App\Models\RubricaUniversale;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use BackedEnum; use BackedEnum;
@ -30,9 +33,14 @@ class PostItGestione extends Page
protected string $view = 'filament.pages.strumenti.post-it-gestione'; protected string $view = 'filament.pages.strumenti.post-it-gestione';
public string $activeTab = 'storico';
/** @var array<int,string> */ /** @var array<int,string> */
public array $riaperturaNote = []; public array $riaperturaNote = [];
/** @var array<string, int|null> */
private array $rubricaPhoneCache = [];
public function getPostItInserimentoUrl(): string public function getPostItInserimentoUrl(): string
{ {
return PostIt::getUrl(panel: 'admin-filament'); return PostIt::getUrl(panel: 'admin-filament');
@ -186,11 +194,150 @@ public function getRecentiProperty()
} }
} }
public function getChiamateTecnicheProperty()
{
if (! Schema::hasTable('communication_messages')) {
return collect();
}
try {
return CommunicationMessage::query()
->with(['assignedUser:id,name', 'stabile:id,denominazione'])
->where('channel', 'smdr')
->latest('id')
->limit(250)
->get();
} catch (QueryException) {
return collect();
}
}
public function creaPostItDaMessaggio(int $messageId): void
{
if (! $this->isPostItTableReady()) {
Notification::make()->title('Tabella post-it non pronta')->danger()->send();
return;
}
$message = CommunicationMessage::query()->find($messageId);
if (! $message) {
return;
}
if (! empty($message->post_it_id)) {
Notification::make()->title('Post-it già associato')->warning()->send();
return;
}
$smdr = (array) data_get($message->metadata, 'smdr', []);
$line = trim((string) ($message->message_text ?? ''));
$postIt = ChiamataPostIt::query()->create([
'stabile_id' => $message->stabile_id,
'creato_da_user_id' => Auth::id(),
'telefono' => $message->phone_number,
'nome_chiamante' => 'SMDR ' . strtoupper((string) $message->direction),
'direzione' => $this->normalizePostItDirection((string) $message->direction),
'origine' => 'smdr_table',
'origine_id' => (string) $message->id,
'durata_secondi' => is_numeric($smdr['duration_seconds'] ?? null) ? (int) $smdr['duration_seconds'] : null,
'oggetto' => 'Chiamata da pannello tecnico SMDR',
'nota' => $line !== '' ? $line : 'Evento SMDR senza testo riga',
'priorita' => 'Media',
'stato' => 'post_it',
'chiamata_il' => $message->received_at ?? now(),
]);
$message->post_it_id = $postIt->id;
$message->save();
Notification::make()
->title('Post-it creato da riga tecnica')
->body('Post-it #' . $postIt->id . ' collegato al messaggio #' . $message->id)
->success()
->send();
}
private function normalizePostItDirection(string $direction): string
{
$d = strtolower(trim($direction));
if ($d === 'inbound') {
return 'in_arrivo';
}
if ($d === 'outbound' || $d === 'internal') {
return 'in_uscita';
}
return 'in_arrivo';
}
public function isPostItTableReady(): bool public function isPostItTableReady(): bool
{ {
return Schema::hasTable('chiamate_post_it'); return Schema::hasTable('chiamate_post_it');
} }
public function getRubricaUrlByPhone(?string $phone): ?string
{
$rubricaId = $this->resolveRubricaIdByPhone($phone);
if (! $rubricaId) {
return null;
}
return RubricaUniversaleScheda::getUrl(['record' => $rubricaId], panel: 'admin-filament');
}
public function getRubricaNomeByPhone(?string $phone): ?string
{
$rubricaId = $this->resolveRubricaIdByPhone($phone);
if (! $rubricaId) {
return null;
}
$rubrica = RubricaUniversale::query()->find($rubricaId, ['id', 'ragione_sociale', 'nome', 'cognome']);
if (! $rubrica) {
return null;
}
$nome = trim((string) ($rubrica->ragione_sociale ?: ((string) ($rubrica->nome ?? '') . ' ' . (string) ($rubrica->cognome ?? ''))));
return $nome !== '' ? $nome : null;
}
private function resolveRubricaIdByPhone(?string $phone): ?int
{
$digits = preg_replace('/\D+/', '', (string) $phone);
if (! is_string($digits) || $digits === '') {
return null;
}
if (array_key_exists($digits, $this->rubricaPhoneCache)) {
return $this->rubricaPhoneCache[$digits];
}
$adminId = (int) (Auth::user()?->amministratore?->id ?? 0);
$query = RubricaUniversale::query();
if ($adminId > 0 && Schema::hasColumn('rubrica_universale', 'amministratore_id')) {
$query->where(function ($q) use ($adminId): void {
$q->where('amministratore_id', $adminId)
->orWhereNull('amministratore_id');
});
}
$rubricaId = $query
->where(function ($q) use ($digits): void {
$q->orWhereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio,''), ' ', ''), '+', ''), '-', '') LIKE ?", ['%' . $digits . '%'])
->orWhereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare,''), ' ', ''), '+', ''), '-', '') LIKE ?", ['%' . $digits . '%'])
->orWhereRaw("REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa,''), ' ', ''), '+', ''), '-', '') LIKE ?", ['%' . $digits . '%']);
})
->orderByDesc('id')
->value('id');
$result = is_numeric($rubricaId) ? (int) $rubricaId : null;
$this->rubricaPhoneCache[$digits] = $result;
return $result;
}
public static function canAccess(): bool public static function canAccess(): bool
{ {
$user = Auth::user(); $user = Auth::user();

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use UnitEnum; use UnitEnum;
@ -61,6 +62,16 @@ public function form(Schema $schema): Schema
return $schema return $schema
->statePath('data') ->statePath('data')
->components([ ->components([
Select::make('tipo_richiesta')
->label('Tipo richiesta')
->options([
'bug' => 'Bug',
'implementazione' => 'Implementazione',
'modulo_nuovo' => 'Nuovo modulo',
'supporto_operativo' => 'Supporto operativo',
])
->default('supporto_operativo')
->required(),
TextInput::make('titolo') TextInput::make('titolo')
->label('Titolo') ->label('Titolo')
->required() ->required()
@ -78,6 +89,10 @@ public function form(Schema $schema): Schema
'Urgente' => 'Urgente', 'Urgente' => 'Urgente',
]) ])
->required(), ->required(),
Textarea::make('procedura_attivata')
->label('Procedura attivata / passi già fatti')
->rows(4)
->helperText('Inserisci i passaggi eseguiti, test fatti e risultato.'),
FileUpload::make('allegati') FileUpload::make('allegati')
->label('Foto / allegati') ->label('Foto / allegati')
->helperText('Da smartphone puoi scattare una foto al momento o selezionarla dalla galleria.') ->helperText('Da smartphone puoi scattare una foto al momento o selezionarla dalla galleria.')
@ -115,6 +130,21 @@ public function submit(): void
$state = $this->getSchema('form')?->getState() ?? []; $state = $this->getSchema('form')?->getState() ?? [];
$gitBranch = trim((string) Process::path(base_path())->run('git rev-parse --abbrev-ref HEAD')->output());
$gitHash = trim((string) Process::path(base_path())->run('git rev-parse --short HEAD')->output());
$gitContext = 'Branch: ' . ($gitBranch !== '' ? $gitBranch : 'n/a') . ' | Commit: ' . ($gitHash !== '' ? $gitHash : 'n/a');
$tipoRichiesta = (string) ($state['tipo_richiesta'] ?? 'supporto_operativo');
$procedura = trim((string) ($state['procedura_attivata'] ?? ''));
$descrizioneBase = trim((string) ($state['descrizione'] ?? ''));
$descrizione = $descrizioneBase;
$descrizione .= "\n\n[Tracking Sviluppo]";
$descrizione .= "\nTipo richiesta: " . $tipoRichiesta;
$descrizione .= "\n" . $gitContext;
if ($procedura !== '') {
$descrizione .= "\nProcedura attivata: " . $procedura;
}
$categoria = CategoriaTicket::query()->firstOrCreate( $categoria = CategoriaTicket::query()->firstOrCreate(
['nome' => 'Supporto'], ['nome' => 'Supporto'],
['descrizione' => 'Richieste help, bug e supporto operativo'] ['descrizione' => 'Richieste help, bug e supporto operativo']
@ -124,8 +154,8 @@ public function submit(): void
'stabile_id' => $stabileId, 'stabile_id' => $stabileId,
'aperto_da_user_id' => $user->id, 'aperto_da_user_id' => $user->id,
'categoria_ticket_id' => $categoria->id, 'categoria_ticket_id' => $categoria->id,
'titolo' => (string) ($state['titolo'] ?? ''), 'titolo' => '[' . strtoupper($tipoRichiesta) . '] ' . (string) ($state['titolo'] ?? ''),
'descrizione' => (string) ($state['descrizione'] ?? ''), 'descrizione' => $descrizione,
'priorita' => (string) ($state['priorita'] ?? 'Media'), 'priorita' => (string) ($state['priorita'] ?? 'Media'),
'stato' => 'Aperto', 'stato' => 'Aperto',
]); ]);
@ -157,7 +187,9 @@ public function submit(): void
$this->getSchema('form')?->fill([ $this->getSchema('form')?->fill([
'titolo' => null, 'titolo' => null,
'descrizione' => null, 'descrizione' => null,
'tipo_richiesta' => 'supporto_operativo',
'priorita' => 'Media', 'priorita' => 'Media',
'procedura_attivata' => null,
'allegati' => [], 'allegati' => [],
]); ]);

View File

@ -0,0 +1,23 @@
<?php
namespace App\Filament\Pages\Supporto;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class SupportoDashboard extends Page
{
protected static ?string $navigationLabel = 'Dashboard Supporto';
protected static ?string $title = 'Dashboard Supporto';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-lifebuoy';
protected static UnitEnum|string|null $navigationGroup = 'Supporto';
protected static ?int $navigationSort = 1;
protected static ?string $slug = 'supporto/dashboard';
protected string $view = 'filament.pages.supporto.dashboard';
}

View File

@ -1,7 +1,9 @@
<?php <?php
namespace App\Filament\Pages\Supporto; namespace App\Filament\Pages\Supporto;
use App\Models\CategoriaTicket;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketAttachment; use App\Models\TicketAttachment;
use App\Models\TicketIntervento; use App\Models\TicketIntervento;
@ -11,8 +13,10 @@
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use UnitEnum; use UnitEnum;
@ -54,6 +58,17 @@ class TicketGestione extends Page
public ?string $descrizioneAllegati = null; public ?string $descrizioneAllegati = null;
public ?string $lastGeneratedPassword = null;
public ?int $selectedCategoriaId = null;
public ?string $categoriaNome = null;
public ?string $categoriaDescrizione = null;
/** @var array<int,array{id:int,nome:string,descrizione:string}> */
public array $categorieOptions = [];
/** @var array<int,array{id:int,nome:string}> */ /** @var array<int,array{id:int,nome:string}> */
public array $fornitoriOptions = []; public array $fornitoriOptions = [];
@ -97,6 +112,7 @@ public function mount(): void
} }
$this->loadFornitoriOptions(); $this->loadFornitoriOptions();
$this->loadCategorieTicketOptions();
$this->refreshFornitoriAttiviRows(); $this->refreshFornitoriAttiviRows();
if ($this->selectedTicket) { if ($this->selectedTicket) {
$this->fornitoreId = (int) ($this->selectedTicket->assegnato_a_fornitore_id ?? 0) ?: null; $this->fornitoreId = (int) ($this->selectedTicket->assegnato_a_fornitore_id ?? 0) ?: null;
@ -105,6 +121,72 @@ public function mount(): void
$this->refreshData(); $this->refreshData();
} }
public function updatedSelectedCategoriaId($value): void
{
$id = (int) $value;
if ($id <= 0) {
$this->categoriaNome = null;
$this->categoriaDescrizione = null;
return;
}
$categoria = CategoriaTicket::query()->find($id);
if (! $categoria instanceof CategoriaTicket) {
return;
}
$this->categoriaNome = (string) $categoria->nome;
$this->categoriaDescrizione = (string) ($categoria->descrizione ?? '');
}
public function creaCategoriaTicket(): void
{
$this->validate([
'categoriaNome' => ['required', 'string', 'max:255', Rule::unique('categorie_ticket', 'nome')],
'categoriaDescrizione' => ['nullable', 'string', 'max:2000'],
]);
$categoria = CategoriaTicket::query()->create([
'nome' => trim((string) $this->categoriaNome),
'descrizione' => filled($this->categoriaDescrizione) ? trim((string) $this->categoriaDescrizione) : null,
]);
$this->loadCategorieTicketOptions();
$this->selectedCategoriaId = (int) $categoria->id;
$this->updatedSelectedCategoriaId($this->selectedCategoriaId);
Notification::make()->title('Categoria creata')->success()->send();
}
public function aggiornaCategoriaTicket(): void
{
$id = (int) ($this->selectedCategoriaId ?? 0);
if ($id <= 0) {
Notification::make()->title('Seleziona una categoria da modificare')->warning()->send();
return;
}
$this->validate([
'categoriaNome' => ['required', 'string', 'max:255', Rule::unique('categorie_ticket', 'nome')->ignore($id)],
'categoriaDescrizione' => ['nullable', 'string', 'max:2000'],
]);
$categoria = CategoriaTicket::query()->find($id);
if (! $categoria instanceof CategoriaTicket) {
Notification::make()->title('Categoria non trovata')->danger()->send();
return;
}
$categoria->update([
'nome' => trim((string) $this->categoriaNome),
'descrizione' => filled($this->categoriaDescrizione) ? trim((string) $this->categoriaDescrizione) : null,
]);
$this->loadCategorieTicketOptions();
Notification::make()->title('Categoria aggiornata')->success()->send();
$this->refreshData();
}
public function updatedStatus(): void public function updatedStatus(): void
{ {
$this->refreshData(); $this->refreshData();
@ -153,7 +235,7 @@ public function getFornitoreAnagraficaUrl(int $fornitoreId): string
public function getAttachmentPublicUrl(TicketAttachment $attachment): string public function getAttachmentPublicUrl(TicketAttachment $attachment): string
{ {
return route('admin.tickets.attachments.view', ['attachment' => (int) $attachment->id]); return route('filament.tickets.attachments.view', ['attachment' => (int) $attachment->id]);
} }
public function openAttachmentPreview(int $attachmentId): void public function openAttachmentPreview(int $attachmentId): void
@ -289,6 +371,9 @@ public function aggiungiNotaInterna(): void
$this->notaInterna = null; $this->notaInterna = null;
// Force refresh of selected ticket relations so the new note is visible immediately.
$this->refreshData();
Notification::make()->title('Nota aggiunta')->success()->send(); Notification::make()->title('Nota aggiunta')->success()->send();
} }
@ -344,6 +429,7 @@ public function caricaAllegati(): void
$this->nuoveFoto = []; $this->nuoveFoto = [];
$this->nuoviAllegati = []; $this->nuoviAllegati = [];
$this->descrizioneAllegati = null; $this->descrizioneAllegati = null;
$this->refreshData();
Notification::make() Notification::make()
->title('Allegati caricati') ->title('Allegati caricati')
@ -372,6 +458,110 @@ public function chiudiTicket(int $ticketId): void
$this->aggiornaStatoTicket($ticketId, 'Chiuso'); $this->aggiornaStatoTicket($ticketId, 'Chiuso');
} }
public function abilitaAccessoFornitoreDaTicket(int $fornitoreId): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Stabile attivo non disponibile')->warning()->send();
return;
}
$stabile = \App\Models\Stabile::query()->find($stabileId);
$adminId = (int) ($stabile->amministratore_id ?? 0);
$fornitore = Fornitore::query()->find($fornitoreId);
if (! $fornitore instanceof Fornitore) {
Notification::make()->title('Fornitore non trovato')->danger()->send();
return;
}
if ($adminId > 0 && (int) ($fornitore->amministratore_id ?? 0) > 0 && (int) ($fornitore->amministratore_id ?? 0) !== $adminId) {
Notification::make()->title('Fornitore non valido per questo stabile')->danger()->send();
return;
}
$email = trim((string) ($fornitore->email ?? ''));
if ($email === '') {
Notification::make()->title('Email fornitore mancante')->warning()->send();
return;
}
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $fornitore->id)
->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])
->first();
if (! $dipendente instanceof FornitoreDipendente) {
$dipendente = FornitoreDipendente::query()->create([
'fornitore_id' => (int) $fornitore->id,
'nome' => (string) ($fornitore->ragione_sociale ?: ($fornitore->nome ?: 'Referente fornitore')),
'cognome' => $fornitore->ragione_sociale ? null : (($fornitore->cognome ?? null) ?: null),
'email' => $email,
'telefono' => $fornitore->telefono ?: $fornitore->cellulare,
'attivo' => true,
'created_by_user_id' => Auth::id(),
'updated_by_user_id' => Auth::id(),
]);
}
$this->abilitaAccessoDipendente($dipendente);
$this->refreshData();
}
public function resetPasswordFornitoreUser(int $userId): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Stabile attivo non disponibile')->warning()->send();
return;
}
$stabile = \App\Models\Stabile::query()->find($stabileId);
$adminId = (int) ($stabile->amministratore_id ?? 0);
$canReset = FornitoreDipendente::query()
->where('user_id', $userId)
->whereHas('fornitore', function ($q) use ($adminId): void {
if ($adminId > 0) {
$q->where('amministratore_id', $adminId);
}
})
->exists();
if (! $canReset) {
Notification::make()->title('Utente non autorizzato per reset')->danger()->send();
return;
}
$target = User::query()->find($userId);
if (! $target instanceof User) {
Notification::make()->title('Utente non trovato')->danger()->send();
return;
}
$newPassword = Str::random(12);
$target->password = Hash::make($newPassword);
$target->save();
$this->lastGeneratedPassword = $newPassword;
Notification::make()
->title('Password fornitore resettata')
->body('Nuova password temporanea: ' . $newPassword)
->success()
->send();
}
private function loadTickets(): void private function loadTickets(): void
{ {
$user = Auth::user(); $user = Auth::user();
@ -484,6 +674,19 @@ private function refreshFornitoriAttiviRows(): void
} }
$rows = []; $rows = [];
$accessRows = FornitoreDipendente::query()
->whereIn('fornitore_id', $fornitoriIds->all())
->get(['fornitore_id', 'user_id']);
$dipCountByFornitore = $accessRows
->groupBy('fornitore_id')
->map(fn($r) => $r->count());
$linkedUserByFornitore = $accessRows
->filter(fn($r) => (int) ($r->user_id ?? 0) > 0)
->groupBy('fornitore_id')
->map(fn($r) => (int) ($r->first()->user_id ?? 0));
foreach (Fornitore::query()->whereIn('id', $fornitoriIds->all())->orderBy('ragione_sociale')->orderBy('cognome')->orderBy('nome')->get() as $fornitore) { foreach (Fornitore::query()->whereIn('id', $fornitoriIds->all())->orderBy('ragione_sociale')->orderBy('cognome')->orderBy('nome')->get() as $fornitore) {
$rows[] = [ $rows[] = [
'id' => (int) $fornitore->id, 'id' => (int) $fornitore->id,
@ -495,6 +698,8 @@ private function refreshFornitoriAttiviRows(): void
'ticket_focus' => (array) ($ticketSummaries->get((string) $fornitore->id) ?? $ticketSummaries->get((int) $fornitore->id) ?? []), 'ticket_focus' => (array) ($ticketSummaries->get((string) $fornitore->id) ?? $ticketSummaries->get((int) $fornitore->id) ?? []),
'intervento_stati' => (string) ($interventoStatiByFornitore->get((string) $fornitore->id) ?? $interventoStatiByFornitore->get((int) $fornitore->id) ?? ''), 'intervento_stati' => (string) ($interventoStatiByFornitore->get((string) $fornitore->id) ?? $interventoStatiByFornitore->get((int) $fornitore->id) ?? ''),
'ultimo_aggiornamento' => (string) ($lastInterventoAtByFornitore->get((string) $fornitore->id) ?? $lastInterventoAtByFornitore->get((int) $fornitore->id) ?? ''), 'ultimo_aggiornamento' => (string) ($lastInterventoAtByFornitore->get((string) $fornitore->id) ?? $lastInterventoAtByFornitore->get((int) $fornitore->id) ?? ''),
'dipendenti_count' => (int) ($dipCountByFornitore->get((string) $fornitore->id) ?? $dipCountByFornitore->get((int) $fornitore->id) ?? 0),
'linked_user_id' => (int) ($linkedUserByFornitore->get((string) $fornitore->id) ?? $linkedUserByFornitore->get((int) $fornitore->id) ?? 0),
]; ];
} }
@ -507,6 +712,43 @@ private function refreshFornitoriAttiviRows(): void
$this->fornitoriAttiviRows = $rows; $this->fornitoriAttiviRows = $rows;
} }
private function abilitaAccessoDipendente(FornitoreDipendente $dipendente): void
{
$email = trim((string) ($dipendente->email ?? ''));
if ($email === '') {
Notification::make()->title('Email dipendente mancante')->warning()->send();
return;
}
$existingUser = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first();
$created = false;
if (! $existingUser instanceof User) {
$generatedPassword = Str::random(12);
$existingUser = User::query()->create([
'name' => trim((string) ($dipendente->nome_completo ?: 'Utente fornitore')),
'email' => $email,
'password' => Hash::make($generatedPassword),
'email_verified_at' => now(),
'is_active' => true,
]);
$this->lastGeneratedPassword = $generatedPassword;
$created = true;
}
$existingUser->assignRole('fornitore');
$dipendente->user_id = (int) $existingUser->id;
$dipendente->updated_by_user_id = Auth::id();
$dipendente->save();
$body = $created
? 'Accesso creato. Password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)')
: 'Accesso attivato/aggiornato per utente esistente.';
Notification::make()->title('Accesso fornitore abilitato')->body($body)->success()->send();
}
private function loadFornitoriOptions(): void private function loadFornitoriOptions(): void
{ {
$user = Auth::user(); $user = Auth::user();
@ -540,6 +782,44 @@ private function loadFornitoriOptions(): void
})->all(); })->all();
} }
private function loadCategorieTicketOptions(): void
{
if (! Schema::hasTable('categorie_ticket')) {
$this->categorieOptions = [];
return;
}
$this->categorieOptions = CategoriaTicket::query()
->orderBy('nome')
->get(['id', 'nome', 'descrizione'])
->map(fn(CategoriaTicket $c): array=> [
'id' => (int) $c->id,
'nome' => (string) $c->nome,
'descrizione' => (string) ($c->descrizione ?? ''),
])
->all();
}
public function getTipoInterventoLabel(Ticket $ticket): string
{
$categoria = trim((string) optional($ticket->categoriaTicket)->nome);
$titolo = trim((string) ($ticket->titolo ?? ''));
if ($categoria !== '' && $titolo !== '') {
return $categoria . ' - ' . $titolo;
}
if ($categoria !== '') {
return $categoria;
}
if ($titolo !== '') {
return $titolo;
}
return 'N/D';
}
private function loadCounters(): void private function loadCounters(): void
{ {
$user = Auth::user(); $user = Auth::user();

View File

@ -1,8 +1,14 @@
<?php <?php
namespace App\Filament\Pages\Supporto; namespace App\Filament\Pages\Supporto;
use App\Filament\Pages\Contabilita\EstrattoContoSoggetto;
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
use App\Models\CategoriaTicket; use App\Models\CategoriaTicket;
use App\Models\ChiamataPostIt;
use App\Models\CommunicationMessage;
use App\Models\PbxClickToCallRequest;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use App\Models\Soggetto;
use App\Models\Stabile; use App\Models\Stabile;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketAttachment; use App\Models\TicketAttachment;
@ -73,6 +79,11 @@ class TicketMobile extends Page
/** @var \Illuminate\Support\Collection<int, RubricaUniversale> */ /** @var \Illuminate\Support\Collection<int, RubricaUniversale> */
public $callerMatches; public $callerMatches;
/** @var array<string,mixed>|null */
public ?array $liveIncomingCall = null;
public bool $liveCallCanClickToCall = false;
/** @var array<string,int> */ /** @var array<string,int> */
public array $ticketCounters = [ public array $ticketCounters = [
'open' => 0, 'open' => 0,
@ -117,6 +128,7 @@ public function mount(): void
$this->status = 'all'; $this->status = 'all';
} }
$this->refreshLiveCallBanner();
$this->refreshData(); $this->refreshData();
} }
@ -135,6 +147,195 @@ public function refreshData(): void
$this->loadCounters(); $this->loadCounters();
$this->loadTickets(); $this->loadTickets();
$this->searchCaller(); $this->searchCaller();
$this->refreshLiveCallBanner();
}
public function refreshLiveCallBanner(): void
{
$authUser = Auth::user();
if (! $authUser instanceof User) {
$this->liveIncomingCall = null;
$this->liveCallCanClickToCall = false;
return;
}
$userExtension = trim((string) ($authUser->pbx_extension ?? ''));
$popupRestricted = (bool) ($authUser->pbx_popup_enabled ?? false) && $userExtension !== '';
$latest = CommunicationMessage::query()
->where('channel', 'smdr')
->where('direction', 'inbound')
->whereNotNull('received_at')
->where('received_at', '>=', now()->subMinutes(8))
->when($popupRestricted, function ($q) use ($userExtension): void {
$q->where('target_extension', $userExtension);
})
->latest('id')
->first(['id', 'phone_number', 'target_extension', 'received_at', 'message_text', 'metadata']);
if (! $latest) {
$this->liveIncomingCall = null;
$this->liveCallCanClickToCall = false;
return;
}
$phone = $this->normalizePhoneDigits((string) ($latest->phone_number ?? ''));
if ($phone === '') {
$phone = $this->normalizePhoneDigits((string) data_get($latest->metadata, 'smdr.dial_number', ''));
}
if ($phone === '') {
$this->liveIncomingCall = null;
$this->liveCallCanClickToCall = false;
return;
}
$rubrica = $this->matchRubricaByPhone($phone);
$soggetto = $this->matchSoggetto($rubrica, $phone);
$this->liveIncomingCall = [
'message_id' => (int) $latest->id,
'phone' => $phone,
'received_at' => optional($latest->received_at)->format('d/m/Y H:i:s'),
'line' => (string) ($latest->message_text ?? ''),
'target_extension' => (string) ($latest->target_extension ?? ''),
'rubrica_id' => (int) ($rubrica?->id ?? 0),
'rubrica_nome' => (string) ($rubrica?->nome_completo ?: $rubrica?->ragione_sociale ?: ''),
'soggetto_id' => (int) ($soggetto?->id ?? 0),
];
$this->liveCallCanClickToCall = (bool) ($authUser->pbx_click_to_call_enabled ?? false)
&& $userExtension !== '';
}
public function creaPostItDaChiamataInIngresso(): void
{
if (! $this->liveIncomingCall || empty($this->liveIncomingCall['phone'])) {
return;
}
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()
->title('Seleziona prima uno stabile attivo')
->warning()
->send();
return;
}
$phone = (string) $this->liveIncomingCall['phone'];
$line = (string) ($this->liveIncomingCall['line'] ?? '');
$this->prefillTicketFromLiveCall($phone, $line);
ChiamataPostIt::query()->create([
'stabile_id' => (int) $stabileId,
'creato_da_user_id' => (int) $user->id,
'rubrica_id' => (int) ($this->selectedCallerId ?? 0) ?: null,
'telefono' => $phone,
'nome_chiamante' => (string) (($this->liveIncomingCall['rubrica_nome'] ?? '') ?: ('Chiamata ' . $phone)),
'direzione' => 'in_arrivo',
'origine' => 'smdr_live_banner',
'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''),
'oggetto' => 'Richiesta telefonica da valutare',
'nota' => $line !== '' ? $line : ('Chiamata in ingresso da ' . $phone),
'priorita' => 'Media',
'stato' => 'post_it',
'chiamata_il' => now(),
]);
Notification::make()
->title('Post-it creato dalla chiamata in arrivo')
->success()
->send();
}
public function usaChiamataInIngresso(): void
{
if (! $this->liveIncomingCall || empty($this->liveIncomingCall['phone'])) {
return;
}
$phone = (string) $this->liveIncomingCall['phone'];
$line = (string) ($this->liveIncomingCall['line'] ?? '');
$this->prefillTicketFromLiveCall($phone, $line);
Notification::make()
->title('Ticket precompilato dalla chiamata in arrivo')
->success()
->send();
}
public function richiediClickToCallDaInterno(): void
{
if (! $this->liveIncomingCall || empty($this->liveIncomingCall['phone'])) {
return;
}
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$extension = trim((string) ($user->pbx_extension ?? ''));
if (! (bool) ($user->pbx_click_to_call_enabled ?? false) || $extension === '') {
Notification::make()->title('Click-to-call non abilitato per il tuo utente')->warning()->send();
return;
}
$phone = $this->normalizePhoneDigits((string) $this->liveIncomingCall['phone']);
if ($phone === '') {
Notification::make()->title('Numero non valido')->warning()->send();
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
PbxClickToCallRequest::query()->create([
'requested_by_user_id' => (int) $user->id,
'assigned_user_id' => (int) $user->id,
'stabile_id' => $stabileId,
'communication_message_id' => (int) ($this->liveIncomingCall['message_id'] ?? 0) ?: null,
'source_extension' => $extension,
'target_number' => $phone,
'status' => 'pending',
'note' => 'Richiesta da banner chiamata in arrivo',
'requested_at' => now(),
'metadata' => [
'from_live_banner' => true,
'target_extension' => (string) ($this->liveIncomingCall['target_extension'] ?? ''),
],
]);
Notification::make()
->title('Richiesta click-to-call registrata')
->body('Interno ' . $extension . ' -> ' . $phone)
->success()
->send();
}
public function getLiveRubricaUrl(): ?string
{
$rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0);
if ($rubricaId <= 0) {
return null;
}
return RubricaUniversaleScheda::getUrl(['record' => $rubricaId], panel: 'admin-filament');
}
public function getLiveEstrattoContoUrl(): ?string
{
$soggettoId = (int) ($this->liveIncomingCall['soggetto_id'] ?? 0);
if ($soggettoId <= 0) {
return null;
}
return EstrattoContoSoggetto::getUrl(['record' => $soggettoId], panel: 'admin-filament');
} }
public function getRubricaFilamentUrl(): string public function getRubricaFilamentUrl(): string
@ -544,6 +745,86 @@ private function resolveAttachmentDescription(int $index): string
return 'Allegato da Ticket Mobile'; return 'Allegato da Ticket Mobile';
} }
private function prefillTicketFromLiveCall(string $phone, string $line): void
{
$this->callerSearch = $phone;
$this->searchCaller();
$rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0);
if ($rubricaId > 0) {
$this->selezionaCaller($rubricaId);
} elseif ($this->callerMatches->count() === 1) {
$only = $this->callerMatches->first();
if ($only) {
$this->selezionaCaller((int) $only->id);
}
}
if (blank($this->newTicketTitolo)) {
$this->newTicketTitolo = 'Chiamata in ingresso ' . $phone;
}
if (blank($this->newTicketDescrizione)) {
$this->newTicketDescrizione = 'Segnalazione aperta durante chiamata in ingresso da ' . $phone . ".\n" . $line;
}
}
private function normalizePhoneDigits(string $value): string
{
return preg_replace('/\D+/', '', $value) ?: '';
}
private function matchRubricaByPhone(string $digits): ?RubricaUniversale
{
if ($digits === '') {
return null;
}
$needle = '%' . $digits . '%';
$tail = strlen($digits) >= 7 ? substr($digits, -7) : $digits;
$tailNeedle = '%' . $tail . '%';
return RubricaUniversale::query()
->where(function ($q) use ($needle, $tailNeedle): void {
$q->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle]);
})
->orderByDesc('updated_at')
->first();
}
private function matchSoggetto(?RubricaUniversale $rubrica, string $phone): ?Soggetto
{
if ($rubrica) {
$cf = trim((string) ($rubrica->codice_fiscale ?? ''));
if ($cf !== '') {
$found = Soggetto::query()->where('codice_fiscale', $cf)->first();
if ($found) {
return $found;
}
}
$piva = trim((string) ($rubrica->partita_iva ?? ''));
if ($piva !== '') {
$found = Soggetto::query()->where('partita_iva', $piva)->first();
if ($found) {
return $found;
}
}
}
$tail = strlen($phone) >= 7 ? substr($phone, -7) : $phone;
if ($tail === '') {
return null;
}
return Soggetto::query()->where('telefono', 'like', '%' . $tail . '%')->first();
}
public function excerpt(?string $text, int $limit = 160): string public function excerpt(?string $text, int $limit = 160): string
{ {
return Str::limit((string) $text, $limit); return Str::limit((string) $text, $limit);

View File

@ -0,0 +1,7 @@
<?php
namespace App\Filament\Widgets;
class GoogleScadenziarioOverview extends GoogleWorkspaceOverview
{
public bool $showTechnicalBoxes = false;
}

View File

@ -3,6 +3,7 @@
use App\Filament\Pages\Gescon\RubricaUniversaleArchivio; use App\Filament\Pages\Gescon\RubricaUniversaleArchivio;
use App\Filament\Pages\Strumenti\Calendario; use App\Filament\Pages\Strumenti\Calendario;
use App\Filament\Pages\Supporto\TicketGestione;
use App\Filament\Pages\Supporto\TicketMobile; use App\Filament\Pages\Supporto\TicketMobile;
use App\Models\Amministratore; use App\Models\Amministratore;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
@ -43,6 +44,8 @@ class GoogleWorkspaceOverview extends Widget
public ?string $errorMessage = null; public ?string $errorMessage = null;
public bool $showTechnicalBoxes = true;
public function mount(): void public function mount(): void
{ {
$amministratore = $this->resolveAmministratore(); $amministratore = $this->resolveAmministratore();
@ -91,6 +94,11 @@ public function getTicketApertiUrl(): string
return TicketMobile::getUrl(panel: 'admin-filament'); return TicketMobile::getUrl(panel: 'admin-filament');
} }
public function getTicketGestioneUrl(int $ticketId): string
{
return TicketGestione::getUrl(['ticket' => $ticketId, 'tab' => 'scheda'], panel: 'admin-filament');
}
public function importContactsToRubrica(): void public function importContactsToRubrica(): void
{ {
$amministratore = $this->resolveAmministratore(); $amministratore = $this->resolveAmministratore();
@ -756,8 +764,21 @@ private function loadContactsPreview(string $token): void
private function loadOpenTickets(): void private function loadOpenTickets(): void
{ {
$user = Auth::user();
if (! $user instanceof User) {
$this->openTickets = [];
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
$this->openTickets = [];
return;
}
$items = Ticket::query() $items = Ticket::query()
->with('stabile:id,denominazione') ->with('stabile:id,denominazione')
->where('stabile_id', $stabileId)
->aperti() ->aperti()
->orderByDesc('created_at') ->orderByDesc('created_at')
->limit(8) ->limit(8)

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;

View File

@ -5,6 +5,7 @@
use App\Models\CategoriaTicket; use App\Models\CategoriaTicket;
use App\Models\Documento; use App\Models\Documento;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\InsuranceClaim;
use App\Models\Stabile; use App\Models\Stabile;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
@ -110,6 +111,7 @@ public function show(Ticket $ticket)
'messages.emlDocumento', 'messages.emlDocumento',
'interventi.fornitore', 'interventi.fornitore',
'interventi.presoInCaricoDaUser', 'interventi.presoInCaricoDaUser',
'insuranceClaim',
]); ]);
$comunicazioni = $ticket->messages() $comunicazioni = $ticket->messages()
@ -199,6 +201,45 @@ public function storeEmailMessage(Request $request, Ticket $ticket)
->with('success', 'Email archiviata e collegata al ticket.'); ->with('success', 'Email archiviata e collegata al ticket.');
} }
public function openInsuranceClaim(Request $request, Ticket $ticket)
{
if ($ticket->stabile->amministratore_id !== Auth::user()->amministratore->id_amministratore ?? null) {
abort(403);
}
$validated = $request->validate([
'policy_reference' => 'nullable|string|max:255',
'claim_number' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:4000',
]);
$claim = InsuranceClaim::query()->updateOrCreate(
['ticket_id' => $ticket->id],
[
'stabile_id' => $ticket->stabile_id,
'policy_reference' => $validated['policy_reference'] ?? null,
'claim_number' => $validated['claim_number'] ?? null,
'status' => 'aperta',
'opened_at' => now(),
'notes' => $validated['notes'] ?? null,
]
);
$ticket->messages()->create([
'user_id' => Auth::id(),
'messaggio' => 'Apertura sinistro assicurativo collegata al ticket. Riferimento sinistro: ' . ($claim->claim_number ?: 'da definire'),
'canale' => 'assicurazione',
'direzione' => 'outbound',
'inviato_il' => now(),
'metadata' => [
'insurance_claim_id' => $claim->id,
],
]);
return redirect()->route('admin.tickets.show', $ticket)
->with('success', 'Sinistro assicurativo collegato al ticket.');
}
/** /**
* Estrae gli header principali da un file EML senza dipendenze esterne. * Estrae gli header principali da un file EML senza dipendenze esterne.
* *

View File

@ -0,0 +1,212 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ChiamataPostIt;
use App\Models\CommunicationMessage;
use App\Models\InsuranceClaim;
use App\Models\SystemSetting;
use App\Models\Ticket;
use App\Models\TicketMessage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class CommunicationWebhookController extends Controller
{
public function sendTelegramTest(Request $request): JsonResponse
{
$validated = $request->validate([
'chat_id' => ['required', 'string'],
'text' => ['required', 'string', 'max:4000'],
]);
$botToken = (string) SystemSetting::getValue('telegram_bot_token', '');
if ($botToken === '') {
return response()->json(['ok' => false, 'error' => 'telegram_bot_token_not_configured'], 422);
}
$response = Http::post("https://api.telegram.org/bot{$botToken}/sendMessage", [
'chat_id' => $validated['chat_id'],
'text' => $validated['text'],
]);
return response()->json([
'ok' => $response->ok(),
'status' => $response->status(),
'response' => $response->json(),
], $response->ok() ? 200 : 422);
}
public function sendWhatsappTest(Request $request): JsonResponse
{
$validated = $request->validate([
'to' => ['required', 'string'],
'text' => ['required', 'string', 'max:4000'],
]);
$phoneNumberId = (string) SystemSetting::getValue('whatsapp_phone_number_id', '');
$accessToken = (string) SystemSetting::getValue('whatsapp_access_token', '');
if ($phoneNumberId === '' || $accessToken === '') {
return response()->json(['ok' => false, 'error' => 'whatsapp_credentials_not_configured'], 422);
}
$response = Http::withToken($accessToken)
->post("https://graph.facebook.com/v22.0/{$phoneNumberId}/messages", [
'messaging_product' => 'whatsapp',
'to' => $validated['to'],
'type' => 'text',
'text' => [
'preview_url' => false,
'body' => $validated['text'],
],
]);
return response()->json([
'ok' => $response->ok(),
'status' => $response->status(),
'response' => $response->json(),
], $response->ok() ? 200 : 422);
}
public function telegram(Request $request): JsonResponse
{
if (! $this->authorizeWebhook($request, 'telegram_webhook_token')) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
$payload = $request->all();
$message = data_get($payload, 'message', []);
return $this->storeIncoming([
'channel' => 'telegram',
'external_chat_id' => (string) data_get($message, 'chat.id', ''),
'external_message_id' => (string) data_get($message, 'message_id', ''),
'sender_name' => trim((string) data_get($message, 'from.first_name', '') . ' ' . (string) data_get($message, 'from.last_name', '')),
'phone_number' => null,
'message_text' => (string) data_get($message, 'text', ''),
'attachments' => null,
'received_at' => now(),
'metadata' => ['raw' => $payload],
], $request);
}
public function whatsapp(Request $request): JsonResponse
{
if (! $this->authorizeWebhook($request, 'whatsapp_webhook_token')) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
$payload = $request->all();
$entry = data_get($payload, 'entry.0.changes.0.value', []);
$message = data_get($entry, 'messages.0', []);
$contact = data_get($entry, 'contacts.0', []);
$attachments = [];
foreach (['image', 'document', 'video', 'audio'] as $type) {
if (data_get($message, $type)) {
$attachments[] = [
'type' => $type,
'id' => (string) data_get($message, $type . '.id', ''),
'mime_type' => (string) data_get($message, $type . '.mime_type', ''),
'caption' => (string) data_get($message, $type . '.caption', ''),
];
}
}
return $this->storeIncoming([
'channel' => 'whatsapp',
'external_chat_id' => (string) data_get($message, 'from', ''),
'external_message_id' => (string) data_get($message, 'id', ''),
'sender_name' => (string) data_get($contact, 'profile.name', ''),
'phone_number' => (string) data_get($message, 'from', ''),
'message_text' => (string) data_get($message, 'text.body', ''),
'attachments' => $attachments,
'received_at' => now(),
'metadata' => ['raw' => $payload],
], $request);
}
private function storeIncoming(array $data, Request $request): JsonResponse
{
$handlingMode = (string) $request->query('mode', 'log_only');
$ticketId = (int) $request->query('ticket_id', 0);
$postItId = (int) $request->query('post_it_id', 0);
$insuranceClaimId = (int) $request->query('insurance_claim_id', 0);
$ticket = $ticketId > 0 ? Ticket::query()->find($ticketId) : null;
$postIt = $postItId > 0 ? ChiamataPostIt::query()->find($postItId) : null;
$insuranceClaim = $insuranceClaimId > 0 ? InsuranceClaim::query()->find($insuranceClaimId) : null;
if ($handlingMode === 'auto_post_it' && ! $ticket && ! $postIt) {
$postIt = ChiamataPostIt::query()->create([
'telefono' => $data['phone_number'] ?? null,
'nome_chiamante' => $data['sender_name'] ?: 'Contatto esterno',
'oggetto' => strtoupper((string) $data['channel']) . ' messaggio in ingresso',
'nota' => (string) ($data['message_text'] ?? ''),
'stato' => 'post_it',
'priorita' => 'Media',
'chiamata_il' => now(),
]);
}
$record = CommunicationMessage::query()->create([
'channel' => $data['channel'],
'direction' => 'inbound',
'external_chat_id' => $data['external_chat_id'] ?: null,
'external_message_id' => $data['external_message_id'] ?: null,
'phone_number' => $data['phone_number'] ?: null,
'sender_name' => $data['sender_name'] ?: null,
'message_text' => $data['message_text'] ?: null,
'attachments' => $data['attachments'],
'ticket_id' => $ticket?->id,
'post_it_id' => $postIt?->id,
'insurance_claim_id' => $insuranceClaim?->id,
'status' => 'received',
'received_at' => $data['received_at'],
'metadata' => array_merge((array) $data['metadata'], ['handling_mode' => $handlingMode]),
]);
if ($ticket) {
TicketMessage::query()->create([
'ticket_id' => (int) $ticket->id,
'user_id' => null,
'messaggio' => (string) ($data['message_text'] ?: 'Messaggio in ingresso da canale esterno.'),
'canale' => (string) $data['channel'],
'direzione' => 'inbound',
'external_message_id' => (string) ($data['external_message_id'] ?: null),
'inviato_il' => $data['received_at'],
'metadata' => ['communication_message_id' => $record->id],
]);
}
if ($postIt && ! $ticket) {
$existing = trim((string) ($postIt->nota ?? ''));
$line = '[' . now()->format('d/m/Y H:i') . '] ' . strtoupper((string) $data['channel']) . ': ' . (string) ($data['message_text'] ?: '(allegato)');
$postIt->nota = $existing !== '' ? ($existing . "\n" . $line) : $line;
$postIt->save();
}
return response()->json([
'ok' => true,
'protocol' => $record->protocol_number,
'communication_message_id' => $record->id,
'ticket_id' => $ticket?->id,
'post_it_id' => $postIt?->id,
'insurance_claim_id' => $insuranceClaim?->id,
]);
}
private function authorizeWebhook(Request $request, string $settingKey): bool
{
$expected = (string) SystemSetting::getValue($settingKey, '');
if ($expected === '') {
return true;
}
$provided = (string) ($request->header('X-Netgescon-Webhook-Token') ?: $request->query('token', ''));
return $provided !== '' && hash_equals($expected, $provided);
}
}

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;

View File

@ -1,12 +1,13 @@
<?php <?php
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\LoginRequest;
use App\Models\SystemSetting;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View; use Illuminate\View\View;
class AuthenticatedSessionController extends Controller class AuthenticatedSessionController extends Controller
@ -26,6 +27,31 @@ public function store(LoginRequest $request): RedirectResponse
{ {
$request->authenticate(); $request->authenticate();
$user = $request->user();
if ($user && ! $user->is_active) {
Auth::guard('web')->logout();
throw ValidationException::withMessages([
'email' => 'Account disattivato. Contatta il super-admin.',
]);
}
if ($user && $user->registration_status !== 'approved') {
Auth::guard('web')->logout();
throw ValidationException::withMessages([
'email' => 'Registrazione in attesa di approvazione del super-admin.',
]);
}
$requireEmailVerification = SystemSetting::bool('require_email_verification', true);
$isSelfRegistered = $user && ! empty($user->requested_role);
if ($user && $isSelfRegistered && $requireEmailVerification && ! $user->hasVerifiedEmail()) {
Auth::guard('web')->logout();
throw ValidationException::withMessages([
'email' => 'Devi confermare la tua email prima di accedere.',
]);
}
$request->session()->regenerate(); $request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false)); return redirect()->intended(route('dashboard', absolute: false));

View File

@ -1,19 +1,18 @@
<?php <?php
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\SystemSetting;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View; use Illuminate\View\View;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
use App\Models\Amministratore;
use App\Models\Soggetto;
class RegisteredUserController extends Controller class RegisteredUserController extends Controller
{ {
@ -22,7 +21,11 @@ class RegisteredUserController extends Controller
*/ */
public function create(): View public function create(): View
{ {
return view('auth.register'); return view('auth.register', [
'registrationEnabled' => SystemSetting::bool('registration_enabled', true),
'recaptchaVersion' => (string) SystemSetting::getValue('recaptcha_version', 'none'),
'recaptchaSiteKey' => (string) SystemSetting::getValue('recaptcha_site_key', ''),
]);
} }
/** /**
@ -32,14 +35,21 @@ public function create(): View
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
if (! SystemSetting::bool('registration_enabled', true)) {
return redirect()->route('login')->withErrors([
'email' => 'La registrazione e attualmente disabilitata. Contatta il supporto.',
]);
}
$request->validate([ $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
'role' => ['nullable', 'in:amministratore,collaboratore,condomino,inquilino'], 'role' => ['nullable', 'in:amministratore,collaboratore,condomino,inquilino'],
'cognome' => ['required_if:role,amministratore', 'nullable', 'string', 'max:255'],
]); ]);
$this->validateRecaptchaIfEnabled($request);
$user = User::create([ $user = User::create([
'name' => $request->name, 'name' => $request->name,
'email' => $request->email, 'email' => $request->email,
@ -58,35 +68,47 @@ public function store(Request $request): RedirectResponse
$role = in_array($requestedRole, ['amministratore', 'collaboratore', 'condomino', 'inquilino'], true) $role = in_array($requestedRole, ['amministratore', 'collaboratore', 'condomino', 'inquilino'], true)
? $requestedRole ? $requestedRole
: 'amministratore'; : 'amministratore';
$user->forceFill([
'requested_role' => $role,
'registration_status' => 'pending',
'is_active' => false,
])->save();
$user->assignRole($role); $user->assignRole($role);
// Auto-create Amministratore profile ONLY for role 'amministratore'
if ($role === 'amministratore') {
Amministratore::firstOrCreate(['user_id' => $user->id], [
'nome' => $user->name,
'cognome' => (string) $request->string('cognome'),
]);
}
if (in_array($role, ['condomino', 'inquilino'], true)) {
Soggetto::firstOrCreate(
['email' => $user->email],
[
'nome' => $user->name,
'cognome' => (string) $request->string('cognome'),
'tipo' => $role === 'condomino' ? 'proprietario' : 'inquilino',
]
);
}
event(new Registered($user)); event(new Registered($user));
Auth::login($user); return redirect()->route('login')->with('status', 'Registrazione ricevuta. Conferma la tua email e attendi l\'approvazione del super-admin.');
if (in_array($role, ['condomino', 'inquilino'], true)) {
return redirect()->route('condomino.tickets.create');
} }
return redirect()->route('admin.dashboard'); private function validateRecaptchaIfEnabled(Request $request): void
{
$version = (string) SystemSetting::getValue('recaptcha_version', 'none');
$siteKey = (string) SystemSetting::getValue('recaptcha_site_key', '');
$secret = (string) SystemSetting::getValue('recaptcha_secret_key', '');
if ($version === 'none' || $siteKey === '' || $secret === '') {
return;
}
$token = (string) $request->input('g-recaptcha-response', '');
if ($token === '') {
throw ValidationException::withMessages([
'g-recaptcha-response' => 'Conferma reCAPTCHA obbligatoria.',
]);
}
$response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
'secret' => $secret,
'response' => $token,
'remoteip' => $request->ip(),
]);
if (! $response->ok() || ! ((bool) data_get($response->json(), 'success', false))) {
throw ValidationException::withMessages([
'g-recaptcha-response' => 'Verifica reCAPTCHA non valida.',
]);
}
} }
} }

View File

@ -69,7 +69,6 @@ public function store(Request $request): RedirectResponse
if ((bool) ($validated['create_user_access'] ?? false) && $email !== '') { if ((bool) ($validated['create_user_access'] ?? false) && $email !== '') {
$user = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first(); $user = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first();
$generatedPassword = null;
if (! $user) { if (! $user) {
$generatedPassword = Str::random(12); $generatedPassword = Str::random(12);
$user = User::query()->create([ $user = User::query()->create([
@ -86,7 +85,6 @@ public function store(Request $request): RedirectResponse
} }
$dipendente->user_id = (int) $user->id; $dipendente->user_id = (int) $user->id;
$dipendente->note = trim((string) (($dipendente->note ?? '') . ($generatedPassword ? ('\nCredenziali iniziali generate il ' . now()->format('d/m/Y H:i') . ': ' . $generatedPassword) : '')));
} }
$dipendente->save(); $dipendente->save();

View File

@ -7,7 +7,6 @@
use App\Models\TicketAttachment; use App\Models\TicketAttachment;
use App\Models\TicketIntervento; use App\Models\TicketIntervento;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@ -34,7 +33,7 @@ public function index(Request $request)
$interventi = $query->paginate(15)->withQueryString(); $interventi = $query->paginate(15)->withQueryString();
$rows = collect($interventi->items()) $rows = collect($interventi->items())
->map(fn(TicketIntervento $intervento): array => $this->buildInterventoRow($intervento)); ->map(fn(TicketIntervento $intervento): array=> $this->buildInterventoRow($intervento));
$fatturabili = TicketIntervento::query() $fatturabili = TicketIntervento::query()
->where('fornitore_id', $fornitore->id) ->where('fornitore_id', $fornitore->id)

View File

@ -1,12 +1,14 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest; use App\Http\Requests\ProfileUpdateRequest;
use App\Support\Security\TotpService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Redirect;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View; use Illuminate\View\View;
class ProfileController extends Controller class ProfileController extends Controller
@ -16,8 +18,22 @@ class ProfileController extends Controller
*/ */
public function edit(Request $request): View public function edit(Request $request): View
{ {
$user = $request->user();
$pendingSecret = session('two_factor_pending_secret');
$pendingUri = null;
if ($pendingSecret && $user) {
$pendingUri = app(TotpService::class)->buildOtpAuthUri(
config('app.name', 'NetGescon'),
(string) $user->email,
(string) $pendingSecret
);
}
return view('profile.edit', [ return view('profile.edit', [
'user' => $request->user(), 'user' => $user,
'twoFactorPendingSecret' => $pendingSecret,
'twoFactorPendingUri' => $pendingUri,
]); ]);
} }
@ -57,4 +73,75 @@ public function destroy(Request $request): RedirectResponse
return Redirect::to('/'); return Redirect::to('/');
} }
public function twoFactorEnable(Request $request, TotpService $totp): RedirectResponse
{
$user = $request->user();
if (! $user) {
abort(403);
}
$secret = $totp->generateSecret();
$request->session()->put('two_factor_pending_secret', $secret);
return Redirect::route('profile.edit')->with('status', 'two-factor-pending');
}
public function twoFactorConfirm(Request $request, TotpService $totp): RedirectResponse
{
$request->validate([
'code' => ['required', 'digits:6'],
]);
$user = $request->user();
$secret = (string) $request->session()->get('two_factor_pending_secret', '');
if (! $user || $secret === '') {
throw ValidationException::withMessages([
'code' => 'Sessione 2FA scaduta. Riavvia la procedura.',
]);
}
if (! $totp->verify($secret, (string) $request->input('code'))) {
throw ValidationException::withMessages([
'code' => 'Codice 2FA non valido.',
]);
}
$recoveryCodes = collect(range(1, 8))
->map(fn() => strtoupper(bin2hex(random_bytes(4))))
->all();
$user->forceFill([
'two_factor_secret' => Crypt::encryptString($secret),
'two_factor_recovery_codes' => Crypt::encryptString(json_encode($recoveryCodes)),
'two_factor_confirmed_at' => now(),
])->save();
$request->session()->forget('two_factor_pending_secret');
return Redirect::route('profile.edit')->with('status', 'two-factor-enabled');
}
public function twoFactorDisable(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
if (! $user) {
abort(403);
}
$user->forceFill([
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
])->save();
$request->session()->forget('two_factor_pending_secret');
return Redirect::route('profile.edit')->with('status', 'two-factor-disabled');
}
} }

View File

@ -1,10 +1,13 @@
<?php <?php
namespace App\Http\Controllers\SuperAdmin; namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Amministratore;
use App\Models\SystemSetting;
use App\Models\UserSetting; use App\Models\UserSetting;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
class ImpostazioniController extends Controller class ImpostazioniController extends Controller
{ {
@ -19,9 +22,22 @@ public function index()
'sidebar_bg_color' => UserSetting::get('sidebar_bg_color', '#fde047'), 'sidebar_bg_color' => UserSetting::get('sidebar_bg_color', '#fde047'),
'sidebar_text_color' => UserSetting::get('sidebar_text_color', '#1e293b'), 'sidebar_text_color' => UserSetting::get('sidebar_text_color', '#1e293b'),
'sidebar_accent_color' => UserSetting::get('sidebar_accent_color', '#6366f1'), 'sidebar_accent_color' => UserSetting::get('sidebar_accent_color', '#6366f1'),
'registration_enabled' => (string) (SystemSetting::bool('registration_enabled', true) ? 'true' : 'false'),
'require_email_verification' => (string) (SystemSetting::bool('require_email_verification', true) ? 'true' : 'false'),
'recaptcha_version' => (string) SystemSetting::getValue('recaptcha_version', 'none'),
'recaptcha_site_key' => (string) SystemSetting::getValue('recaptcha_site_key', ''),
'recaptcha_secret_key' => (string) SystemSetting::getValue('recaptcha_secret_key', ''),
'telegram_bot_token' => (string) SystemSetting::getValue('telegram_bot_token', ''),
'telegram_webhook_token' => (string) SystemSetting::getValue('telegram_webhook_token', ''),
'whatsapp_phone_number_id' => (string) SystemSetting::getValue('whatsapp_phone_number_id', ''),
'whatsapp_business_account_id' => (string) SystemSetting::getValue('whatsapp_business_account_id', ''),
'whatsapp_access_token' => (string) SystemSetting::getValue('whatsapp_access_token', ''),
'whatsapp_webhook_token' => (string) SystemSetting::getValue('whatsapp_webhook_token', ''),
]; ];
return view('superadmin.impostazioni.index', compact('settings')); $googleStatus = $this->googleStatus();
return view('superadmin.impostazioni.index', compact('settings', 'googleStatus'));
} }
public function store(Request $request) public function store(Request $request)
@ -34,16 +50,48 @@ public function store(Request $request)
'sidebar_bg_color' => 'string|max:7', 'sidebar_bg_color' => 'string|max:7',
'sidebar_text_color' => 'string|max:7', 'sidebar_text_color' => 'string|max:7',
'sidebar_accent_color' => 'string|max:7', 'sidebar_accent_color' => 'string|max:7',
'registration_enabled' => 'nullable|string|in:true,false',
'require_email_verification' => 'nullable|string|in:true,false',
'recaptcha_version' => 'nullable|string|in:none,v2,v3',
'recaptcha_site_key' => 'nullable|string|max:255',
'recaptcha_secret_key' => 'nullable|string|max:255',
'telegram_bot_token' => 'nullable|string|max:255',
'telegram_webhook_token' => 'nullable|string|max:255',
'whatsapp_phone_number_id' => 'nullable|string|max:255',
'whatsapp_business_account_id' => 'nullable|string|max:255',
'whatsapp_access_token' => 'nullable|string|max:500',
'whatsapp_webhook_token' => 'nullable|string|max:255',
]); ]);
// Salva le impostazioni per l'utente corrente $globalKeys = [
'registration_enabled',
'require_email_verification',
'recaptcha_version',
'recaptcha_site_key',
'recaptcha_secret_key',
'telegram_bot_token',
'telegram_webhook_token',
'whatsapp_phone_number_id',
'whatsapp_business_account_id',
'whatsapp_access_token',
'whatsapp_webhook_token',
];
foreach ($validated as $key => $value) { foreach ($validated as $key => $value) {
if (in_array($key, $globalKeys, true)) {
SystemSetting::setValue($key, $value);
} else {
UserSetting::set($key, $value); UserSetting::set($key, $value);
} }
}
if ($request->expectsJson()) {
return response()->json(['success' => true, 'message' => 'Impostazioni salvate con successo!']); return response()->json(['success' => true, 'message' => 'Impostazioni salvate con successo!']);
} }
return back()->with('success', 'Impostazioni salvate con successo.');
}
public function theme(Request $request) public function theme(Request $request)
{ {
$theme = $request->input('theme', 'default'); $theme = $request->input('theme', 'default');
@ -83,4 +131,34 @@ public function theme(Request $request)
return response()->json(['success' => true, 'settings' => $themes[$theme] ?? []]); return response()->json(['success' => true, 'settings' => $themes[$theme] ?? []]);
} }
private function googleStatus(): array
{
$clientId = (string) config('services.google.client_id', '');
$clientSecret = (string) config('services.google.client_secret', '');
$redirect = (string) config('services.google.redirect', '');
$admin = null;
$user = Auth::user();
if ($user && method_exists($user, 'amministratore') && $user->amministratore) {
$admin = $user->amministratore;
}
if (! $admin instanceof Amministratore) {
$admin = Amministratore::query()->latest('id')->first();
}
$google = Arr::get($admin?->impostazioni ?? [], 'google', []);
$oauth = Arr::get($google, 'oauth', []);
return [
'client_id_configured' => $clientId !== '',
'client_secret_configured' => $clientSecret !== '',
'redirect_configured' => $redirect !== '',
'oauth_connected' => (bool) Arr::get($oauth, 'connected', false),
'oauth_email' => (string) Arr::get($oauth, 'email', ''),
'calendar_enabled' => (bool) Arr::get($oauth, 'connected', false),
'contacts_enabled' => (bool) Arr::get($oauth, 'connected', false),
'drive_enabled' => (bool) Arr::get($oauth, 'connected', false),
];
}
} }

View File

@ -1,16 +1,19 @@
<?php <?php
namespace App\Http\Controllers\SuperAdmin; namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Amministratore;
use App\Models\Soggetto;
use App\Models\SubscriptionPlan;
use App\Models\SystemSetting;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Spatie\Permission\Models\Permission; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Role;
class UserController extends Controller class UserController extends Controller
{ {
@ -18,26 +21,28 @@ public function __construct()
{ // Proteggi le rotte con permessi specifici per il Super Admin { // Proteggi le rotte con permessi specifici per il Super Admin
$this->middleware('permission:view-users', ['only' => ['index']]); $this->middleware('permission:view-users', ['only' => ['index']]);
$this->middleware('permission:create-users', ['only' => ['create', 'store']]); $this->middleware('permission:create-users', ['only' => ['create', 'store']]);
$this->middleware('permission:manage-users', ['only' => ['create', 'store', 'edit', 'update', 'destroy', 'updateRole']]); $this->middleware('permission:manage-users', ['only' => ['create', 'store', 'edit', 'update', 'destroy', 'updateRole', 'approve', 'reject', 'toggleActive', 'assignPlan']]);
$this->middleware('permission:impersonate-users', ['only' => ['impersonate']]); $this->middleware('permission:impersonate-users', ['only' => ['impersonate']]);
} }
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index() public function index()
{ {
$users = User::with(['roles', 'subscriptionPlan'])->latest('id')->paginate(10);
$users = User::with('roles')->paginate(10);
$roles = Role::all(); // Per la selezione dei ruoli nella vista $roles = Role::all(); // Per la selezione dei ruoli nella vista
return view('superadmin.users.index', compact('users', 'roles')); $plans = SubscriptionPlan::query()->where('is_active', true)->orderBy('name')->get();
return view('superadmin.users.index', compact('users', 'roles', 'plans'));
} }
public function create() public function create()
{ // <-- QUESTA PARENTESI MANCAVA! { // <-- QUESTA PARENTESI MANCAVA!
$roles = Role::all(); // Definisci $roles qui $roles = Role::all(); // Definisci $roles qui
return view('superadmin.users.create', compact('roles')); $plans = SubscriptionPlan::query()->where('is_active', true)->orderBy('name')->get();
return view('superadmin.users.create', compact('roles', 'plans'));
} }
public function store(Request $request) public function store(Request $request)
@ -48,6 +53,7 @@ public function store(Request $request)
'password' => 'required|string|min:8|confirmed', 'password' => 'required|string|min:8|confirmed',
'role' => 'required|exists:roles,name', 'role' => 'required|exists:roles,name',
'expires_at' => 'nullable|date', 'expires_at' => 'nullable|date',
'subscription_plan_id' => 'nullable|exists:subscription_plans,id',
'permissions' => 'array', 'permissions' => 'array',
'permissions.*' => 'string|exists:permissions,name', 'permissions.*' => 'string|exists:permissions,name',
]); ]);
@ -57,9 +63,15 @@ public function store(Request $request)
'email' => $request->email, 'email' => $request->email,
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
'expires_at' => $request->filled('expires_at') ? $request->date('expires_at') : null, 'expires_at' => $request->filled('expires_at') ? $request->date('expires_at') : null,
'subscription_plan_id' => $request->input('subscription_plan_id'),
'registration_status' => 'approved',
'is_active' => true,
'approved_by_user_id' => Auth::id(),
'approved_at' => now(),
]); ]);
$user->assignRole($request->role); $user->assignRole($request->role);
$this->ensureDomainProfiles($user, $request->role);
// Sincronizza permessi espliciti (oltre ai ruoli) // Sincronizza permessi espliciti (oltre ai ruoli)
$selectedPerms = collect($request->input('permissions', []))->unique()->values()->all(); $selectedPerms = collect($request->input('permissions', []))->unique()->values()->all();
@ -71,8 +83,9 @@ public function store(Request $request)
public function edit(User $user) public function edit(User $user)
{ {
$roles = Role::all(); $roles = Role::all();
$plans = SubscriptionPlan::query()->where('is_active', true)->orderBy('name')->get();
// Verifica che l'utente corrente abbia i permessi per modificare gli utenti // Verifica che l'utente corrente abbia i permessi per modificare gli utenti
return view('superadmin.users.edit', compact('user', 'roles')); return view('superadmin.users.edit', compact('user', 'roles', 'plans'));
} }
public function update(Request $request, User $user) public function update(Request $request, User $user)
@ -82,6 +95,10 @@ public function update(Request $request, User $user)
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user)], 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user)],
'role' => 'required|exists:roles,name', 'role' => 'required|exists:roles,name',
'expires_at' => 'nullable|date', 'expires_at' => 'nullable|date',
'registration_status' => 'required|in:pending,approved,rejected',
'is_active' => 'nullable|boolean',
'subscription_plan_id' => 'nullable|exists:subscription_plans,id',
'rejection_reason' => 'nullable|string|max:500',
'permissions' => 'array', 'permissions' => 'array',
'permissions.*' => 'string|exists:permissions,name', 'permissions.*' => 'string|exists:permissions,name',
]); ]);
@ -90,9 +107,14 @@ public function update(Request $request, User $user)
'name' => $request->name, 'name' => $request->name,
'email' => $request->email, 'email' => $request->email,
'expires_at' => $request->filled('expires_at') ? $request->date('expires_at') : null, 'expires_at' => $request->filled('expires_at') ? $request->date('expires_at') : null,
'registration_status' => $request->input('registration_status', 'approved'),
'is_active' => $request->boolean('is_active'),
'subscription_plan_id' => $request->input('subscription_plan_id'),
'rejection_reason' => $request->input('rejection_reason'),
]); ]);
$user->syncRoles([$request->role]); // Assegna il nuovo ruolo all'utente $user->syncRoles([$request->role]); // Assegna il nuovo ruolo all'utente
$this->ensureDomainProfiles($user, $request->role);
$selectedPerms = collect($request->input('permissions', []))->unique()->values()->all(); $selectedPerms = collect($request->input('permissions', []))->unique()->values()->all();
$user->syncPermissions($selectedPerms); $user->syncPermissions($selectedPerms);
@ -130,16 +152,114 @@ public function impersonate(User $user)
$impersonator = Auth::user(); $impersonator = Auth::user();
// Verifica che l'utente corrente possa impersonare e che l'utente target possa essere impersonato // Verifica che l'utente corrente possa impersonare e che l'utente target possa essere impersonato
if (!Gate::allows('impersonate', $impersonator)) { if (! Gate::allows('impersonate', $impersonator)) {
return back()->with('error', 'Non hai i permessi per impersonare utenti.'); return back()->with('error', 'Non hai i permessi per impersonare utenti.');
} }
// Verifica se l'utente target può essere impersonato // Verifica se l'utente target può essere impersonato
if (!Gate::allows('canBeImpersonated', $user)) { if (! Gate::allows('canBeImpersonated', $user)) {
return back()->with('error', 'Questo utente non può essere impersonato.'); return back()->with('error', 'Questo utente non può essere impersonato.');
} }
$impersonator->impersonate($user); $impersonator->impersonate($user);
return redirect('/dashboard')->with('status', 'Ora stai impersonando ' . $user->name); return redirect('/dashboard')->with('status', 'Ora stai impersonando ' . $user->name);
} }
public function approve(Request $request, User $user): RedirectResponse
{
$request->validate([
'role' => 'nullable|exists:roles,name',
]);
if ($user->id === Auth::id()) {
return redirect()->route('superadmin.users.index')->with('error', 'Non puoi approvare te stesso.');
}
$role = $request->input('role', $user->requested_role ?: 'amministratore');
$requireEmailVerification = SystemSetting::bool('require_email_verification', true);
if ($requireEmailVerification && ! $user->hasVerifiedEmail()) {
return redirect()->route('superadmin.users.index')->with('error', 'Utente non approvabile: email non ancora verificata.');
}
$user->forceFill([
'registration_status' => 'approved',
'is_active' => true,
'approved_by_user_id' => Auth::id(),
'approved_at' => now(),
'rejected_at' => null,
'rejection_reason' => null,
])->save();
$user->syncRoles([$role]);
$this->ensureDomainProfiles($user, $role);
return redirect()->route('superadmin.users.index')->with('success', 'Utente approvato con successo.');
}
public function reject(Request $request, User $user): RedirectResponse
{
$request->validate([
'reason' => 'nullable|string|max:500',
]);
if ($user->id === Auth::id()) {
return redirect()->route('superadmin.users.index')->with('error', 'Non puoi rifiutare te stesso.');
}
$user->forceFill([
'registration_status' => 'rejected',
'is_active' => false,
'rejected_at' => now(),
'rejection_reason' => $request->input('reason'),
])->save();
return redirect()->route('superadmin.users.index')->with('success', 'Richiesta registrazione rifiutata.');
}
public function toggleActive(User $user): RedirectResponse
{
if ($user->id === Auth::id()) {
return redirect()->route('superadmin.users.index')->with('error', 'Non puoi disattivare il tuo stesso account.');
}
$user->forceFill([
'is_active' => ! $user->is_active,
])->save();
$status = $user->is_active ? 'attivato' : 'disattivato';
return redirect()->route('superadmin.users.index')->with('success', "Utente {$status} con successo.");
}
public function assignPlan(Request $request, User $user): RedirectResponse
{
$request->validate([
'subscription_plan_id' => 'nullable|exists:subscription_plans,id',
]);
$user->forceFill([
'subscription_plan_id' => $request->input('subscription_plan_id'),
])->save();
return redirect()->route('superadmin.users.index')->with('success', 'Piano assegnato correttamente.');
}
private function ensureDomainProfiles(User $user, string $role): void
{
if ($role === 'amministratore') {
Amministratore::firstOrCreate(['user_id' => $user->id], [
'nome' => $user->name,
'cognome' => $user->name,
]);
}
if (in_array($role, ['condomino', 'inquilino'], true)) {
Soggetto::firstOrCreate(['email' => $user->email], [
'nome' => $user->name,
'cognome' => $user->name,
'tipo' => $role === 'condomino' ? 'proprietario' : 'inquilino',
]);
}
}
} }

View File

@ -1,11 +1,8 @@
<?php <?php
namespace App\Http; namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel class Kernel extends HttpKernel
{ {
/** /**
@ -68,7 +65,7 @@ class Kernel extends HttpKernel
'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class, 'precognitive' => \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 'verified' => \App\Http\Middleware\EnsureEmailIsVerifiedForNetgescon::class,
'expired' => \App\Http\Middleware\ExpireUser::class, 'expired' => \App\Http\Middleware\ExpireUser::class,
]; ];
} }

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureEmailIsVerifiedForNetgescon extends EnsureEmailIsVerified
{
public function handle($request, Closure $next, $redirectToRoute = null): Response
{
if (! $request instanceof Request) {
return parent::handle($request, $next, $redirectToRoute);
}
$user = $request->user();
if ($user && method_exists($user, 'hasAnyRole') && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore'])) {
return $next($request);
}
return parent::handle($request, $next, $redirectToRoute);
}
}

View File

@ -1,7 +1,7 @@
<?php <?php
namespace App\Http\Requests\Auth; namespace App\Http\Requests\Auth;
use App\Support\Security\TotpService;
use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -29,6 +29,7 @@ public function rules(): array
return [ return [
'email' => ['required', 'string', 'email'], 'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'], 'password' => ['required', 'string'],
'otp_code' => ['nullable', 'string'],
]; ];
} }
@ -49,6 +50,22 @@ public function authenticate(): void
]); ]);
} }
$user = Auth::user();
if ($user && method_exists($user, 'hasTwoFactorEnabled') && $user->hasTwoFactorEnabled()) {
$otpCode = trim((string) $this->input('otp_code', ''));
$totpSecret = $user->getTwoFactorSecretDecrypted();
$totp = app(TotpService::class);
if ($otpCode === '' || ! $totpSecret || ! $totp->verify($totpSecret, $otpCode)) {
Auth::logout();
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'otp_code' => 'Codice autenticatore non valido.',
]);
}
}
RateLimiter::clear($this->throttleKey()); RateLimiter::clear($this->throttleKey());
} }
@ -80,6 +97,6 @@ public function ensureIsNotRateLimited(): void
*/ */
public function throttleKey(): string public function throttleKey(): string
{ {
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); return Str::transliterate(Str::lower($this->string('email')) . '|' . $this->ip());
} }
} }

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\Request;
use Laravel\Fortify\Contracts\VerifyEmailViewResponse;
class FortifyVerifyEmailViewResponse implements VerifyEmailViewResponse
{
public function toResponse($request)
{
if (! $request instanceof Request) {
return view('auth.verify-email');
}
if ($request->user()?->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
return view('auth.verify-email');
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CommunicationMessage extends Model
{
use HasFactory;
protected $fillable = [
'channel',
'direction',
'protocol_number',
'external_chat_id',
'external_message_id',
'phone_number',
'target_extension',
'assigned_user_id',
'stabile_id',
'sender_name',
'message_text',
'attachments',
'ticket_id',
'post_it_id',
'insurance_claim_id',
'status',
'received_at',
'metadata',
];
protected $casts = [
'attachments' => 'array',
'received_at' => 'datetime',
'metadata' => 'array',
];
protected static function booted(): void
{
static::creating(function (self $message): void {
if (! empty($message->protocol_number)) {
return;
}
$stamp = now()->format('YmdHis');
$rand = strtoupper(bin2hex(random_bytes(2)));
$message->protocol_number = "COM-{$stamp}-{$rand}";
});
}
public function ticket()
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
public function assignedUser()
{
return $this->belongsTo(User::class, 'assigned_user_id');
}
public function stabile()
{
return $this->belongsTo(Stabile::class, 'stabile_id');
}
public function postIt()
{
return $this->belongsTo(ChiamataPostIt::class, 'post_it_id');
}
public function insuranceClaim()
{
return $this->belongsTo(InsuranceClaim::class, 'insurance_claim_id');
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class InsuranceClaim extends Model
{
use HasFactory;
protected $fillable = [
'ticket_id',
'stabile_id',
'policy_reference',
'claim_number',
'status',
'opened_at',
'notes',
'metadata',
];
protected $casts = [
'opened_at' => 'datetime',
'metadata' => 'array',
];
public function ticket()
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
public function communicationMessages()
{
return $this->hasMany(CommunicationMessage::class, 'insurance_claim_id');
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PaymentMethod extends Model
{
use HasFactory;
protected $fillable = [
'name',
'type',
'is_active',
'details',
];
protected $casts = [
'is_active' => 'boolean',
'details' => 'array',
];
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PbxClickToCallRequest extends Model
{
use HasFactory;
protected $fillable = [
'requested_by_user_id',
'assigned_user_id',
'stabile_id',
'communication_message_id',
'source_extension',
'target_number',
'status',
'note',
'requested_at',
'processed_at',
'metadata',
];
protected $casts = [
'requested_at' => 'datetime',
'processed_at' => 'datetime',
'metadata' => 'array',
];
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SubscriptionPlan extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'price_monthly',
'is_active',
'features',
];
protected $casts = [
'price_monthly' => 'decimal:2',
'is_active' => 'boolean',
'features' => 'array',
];
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SystemSetting extends Model
{
protected $fillable = [
'key',
'value',
];
public static function getValue(string $key, mixed $default = null): mixed
{
$setting = static::query()->where('key', $key)->value('value');
if ($setting === null) {
return $default;
}
$decoded = json_decode($setting, true);
return json_last_error() === JSON_ERROR_NONE ? $decoded : $setting;
}
public static function setValue(string $key, mixed $value): void
{
$storedValue = is_string($value) ? $value : json_encode($value);
static::query()->updateOrCreate(
['key' => $key],
['value' => $storedValue]
);
}
public static function bool(string $key, bool $default = false): bool
{
return filter_var(static::getValue($key, $default), FILTER_VALIDATE_BOOL);
}
}

View File

@ -122,6 +122,16 @@ public function interventi()
return $this->hasMany(TicketIntervento::class, 'ticket_id', 'id'); return $this->hasMany(TicketIntervento::class, 'ticket_id', 'id');
} }
public function insuranceClaim()
{
return $this->hasOne(InsuranceClaim::class, 'ticket_id', 'id');
}
public function communicationMessages()
{
return $this->hasMany(CommunicationMessage::class, 'ticket_id', 'id');
}
/** /**
* Scope per ticket aperti * Scope per ticket aperti
*/ */

View File

@ -1,37 +1,109 @@
<?php <?php
namespace App\Models; namespace App\Models;
// Import necessari // Import necessari
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Lab404\Impersonate\Models\Impersonate; // Per la funzionalità di impersonificazione
use Spatie\Permission\Traits\HasRoles; // Per la gestione dei ruoli e permessi
use Laravel\Sanctum\HasApiTokens;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Carbon; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable; // Per la funzionalità di impersonificazione
use Illuminate\Notifications\Notifiable; // Per la gestione dei ruoli e permessi
use Illuminate\Support\Facades\Crypt;
use Lab404\Impersonate\Models\Impersonate;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable class User extends Authenticatable implements MustVerifyEmail
{ {
// Aggiungi il trait Impersonate // Aggiungi il trait Impersonate
use HasApiTokens, HasFactory, Notifiable, Impersonate, HasRoles; // Aggiungi HasRoles qui use HasApiTokens, HasFactory, Notifiable, Impersonate, HasRoles; // Aggiungi HasRoles qui
protected $fillable = [
protected $fillable = ['name', 'email', 'password', 'email_verified_at', 'expires_at']; // Aggiunto expires_at 'name',
protected $hidden = ['password', 'remember_token']; 'email',
'password',
'email_verified_at',
'expires_at',
'is_active',
'registration_status',
'requested_role',
'subscription_plan_id',
'approved_by_user_id',
'approved_at',
'rejected_at',
'rejection_reason',
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
'pbx_extension',
'pbx_popup_enabled',
'pbx_click_to_call_enabled',
];
protected $hidden = ['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'password' => 'hashed' 'is_active' => 'boolean',
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
'two_factor_confirmed_at' => 'datetime',
'pbx_popup_enabled' => 'boolean',
'pbx_click_to_call_enabled' => 'boolean',
'password' => 'hashed',
]; ];
} }
public function hasTwoFactorEnabled(): bool
{
return ! empty($this->two_factor_secret) && $this->two_factor_confirmed_at !== null;
}
public function getTwoFactorSecretDecrypted(): ?string
{
if (! $this->two_factor_secret) {
return null;
}
try {
return Crypt::decryptString($this->two_factor_secret);
} catch (\Throwable) {
return null;
}
}
public function getTwoFactorRecoveryCodes(): array
{
if (! $this->two_factor_recovery_codes) {
return [];
}
try {
return json_decode(Crypt::decryptString($this->two_factor_recovery_codes), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable) {
return [];
}
}
public function subscriptionPlan(): BelongsTo
{
return $this->belongsTo(SubscriptionPlan::class, 'subscription_plan_id');
}
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by_user_id');
}
public function isPendingApproval(): bool
{
return $this->registration_status === 'pending';
}
/** /**
* Ritorna true se l'utente è scaduto. * Ritorna true se l'utente è scaduto.
*/ */
@ -73,7 +145,6 @@ public function stabiliAssegnati(): BelongsToMany
* ha il permesso di impersonare altri. * ha il permesso di impersonare altri.
*/ */
public function canImpersonate(): bool public function canImpersonate(): bool
{ {
// Solo il Super-Admin può farlo. // Solo il Super-Admin può farlo.
return $this->hasRole('super-admin'); return $this->hasRole('super-admin');

View File

@ -26,6 +26,13 @@ public function register(): void
return $connection->getSchemaBuilder(); return $connection->getSchemaBuilder();
}); });
if (interface_exists(\Laravel\Fortify\Contracts\VerifyEmailViewResponse::class)) {
$this->app->singleton(
\Laravel\Fortify\Contracts\VerifyEmailViewResponse::class,
\App\Http\Responses\FortifyVerifyEmailViewResponse::class
);
}
} }
/** /**

View File

@ -6,8 +6,9 @@
use App\Filament\Pages\Contabilita\PrimaNotaDettaglio; use App\Filament\Pages\Contabilita\PrimaNotaDettaglio;
use App\Filament\Pages\Contabilita\PrimaNotaModifica; use App\Filament\Pages\Contabilita\PrimaNotaModifica;
use App\Filament\Pages\Gescon\RubricaUniversaleArchivio; use App\Filament\Pages\Gescon\RubricaUniversaleArchivio;
use App\Filament\Pages\Supporto\SupportoDashboard;
use App\Filament\Pages\Supporto\TicketMobile; use App\Filament\Pages\Supporto\TicketMobile;
use App\Filament\Widgets\GoogleWorkspaceOverview; use App\Filament\Widgets\GoogleScadenziarioOverview;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@ -53,42 +54,44 @@ public function panel(Panel $panel): Panel
->url(fn() => RubricaUniversaleArchivio::getUrl(panel: 'admin-filament')) ->url(fn() => RubricaUniversaleArchivio::getUrl(panel: 'admin-filament'))
->visible(function () { ->visible(function () {
$user = Auth::user(); $user = Auth::user();
return $user instanceof \App\Models\User return $user instanceof \App\Models\User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}), }),
NavigationItem::make('Ticket Mobile') NavigationItem::make('Ticket Mobile')
->group('NetGescon') ->group('NetGescon')
->icon('heroicon-o-device-phone-mobile') ->icon('heroicon-o-device-phone-mobile')
->url(fn() => TicketMobile::getUrl(panel: 'admin-filament')) ->url(fn() => TicketMobile::getUrl(panel: 'admin-filament'))
->visible(function () { ->visible(function () {
$user = Auth::user(); $user = Auth::user();
return $user instanceof \App\Models\User return $user instanceof \App\Models\User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}), }),
NavigationItem::make('Scheda Stabile') NavigationItem::make('Scheda Stabile')
->group('NetGescon') ->group('NetGescon')
->icon('heroicon-o-building-office-2') ->icon('heroicon-o-building-office-2')
->url(fn() => StabilePage::getUrl(panel: 'admin-filament')) ->url(fn() => StabilePage::getUrl(panel: 'admin-filament'))
->visible(function () { ->visible(function () {
$user = Auth::user(); $user = Auth::user();
return $user instanceof \App\Models\User return $user instanceof \App\Models\User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']); && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}), }),
]) ])
->discoverResources(in: app_path('Filament/Resources'), for : 'App\\Filament\\Resources') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for : 'App\\Filament\\Pages') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([ ->pages([
Dashboard::class, Dashboard::class,
SupportoDashboard::class,
PrimaNotaDettaglio::class, PrimaNotaDettaglio::class,
PrimaNotaModifica::class, PrimaNotaModifica::class,
ContoMastrino::class, ContoMastrino::class,
]) ])
->discoverWidgets(in: app_path('Filament/Widgets'), for : 'App\\Filament\\Widgets') ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([ ->widgets([
AccountWidget::class, AccountWidget::class,
GoogleWorkspaceOverview::class, GoogleScadenziarioOverview::class,
]) ])
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,

View File

@ -0,0 +1,87 @@
<?php
namespace App\Support\Security;
class TotpService
{
public function generateSecret(int $length = 32): string
{
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$secret = '';
for ($i = 0; $i < $length; $i++) {
$secret .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return $secret;
}
public function verify(string $base32Secret, string $code, int $window = 1): bool
{
$code = trim($code);
if (! preg_match('/^\d{6}$/', $code)) {
return false;
}
$timeSlice = (int) floor(time() / 30);
for ($offset = -$window; $offset <= $window; $offset++) {
if (hash_equals($this->totp($base32Secret, $timeSlice + $offset), $code)) {
return true;
}
}
return false;
}
public function buildOtpAuthUri(string $issuer, string $label, string $secret): string
{
$issuerEncoded = rawurlencode($issuer);
$labelEncoded = rawurlencode($label);
return "otpauth://totp/{$issuerEncoded}:{$labelEncoded}?secret={$secret}&issuer={$issuerEncoded}&algorithm=SHA1&digits=6&period=30";
}
private function totp(string $base32Secret, int $timeSlice): string
{
$secret = $this->base32Decode($base32Secret);
if ($secret === '') {
return '000000';
}
$time = pack('N*', 0, $timeSlice);
$hash = hash_hmac('sha1', $time, $secret, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$truncated = substr($hash, $offset, 4);
$value = unpack('N', $truncated)[1] & 0x7FFFFFFF;
return str_pad((string) ($value % 1000000), 6, '0', STR_PAD_LEFT);
}
private function base32Decode(string $input): string
{
$input = strtoupper(preg_replace('/[^A-Z2-7]/', '', $input) ?? '');
if ($input === '') {
return '';
}
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
foreach (str_split($input) as $char) {
$index = strpos($alphabet, $char);
if ($index === false) {
continue;
}
$bits .= str_pad(decbin($index), 5, '0', STR_PAD_LEFT);
}
$output = '';
foreach (str_split($bits, 8) as $chunk) {
if (strlen($chunk) === 8) {
$output .= chr(bindec($chunk));
}
}
return $output;
}
}

View File

@ -1,7 +1,7 @@
<?php <?php
use App\Console\Commands\DedupeRateEmesseCommand;
use App\Console\Commands\CtiSyncPostItFromStagingCommand; use App\Console\Commands\CtiSyncPostItFromStagingCommand;
use App\Console\Commands\DedupeRateEmesseCommand;
use App\Console\Commands\FeCassettoImportLocal; use App\Console\Commands\FeCassettoImportLocal;
use App\Console\Commands\GesconCollegaFornitoreStabili; use App\Console\Commands\GesconCollegaFornitoreStabili;
use App\Console\Commands\GesconImportAlignCommand; use App\Console\Commands\GesconImportAlignCommand;
@ -21,7 +21,9 @@
use App\Console\Commands\IstatSetIndiceCommand; use App\Console\Commands\IstatSetIndiceCommand;
use App\Console\Commands\IstatSyncIndiciCommand; use App\Console\Commands\IstatSyncIndiciCommand;
use App\Console\Commands\NetgesconDistributionPullCommand; use App\Console\Commands\NetgesconDistributionPullCommand;
use App\Console\Commands\NetgesconPreupdateBackupCommand;
use App\Console\Commands\NetgesconQaUnitaNominativiCommand; use App\Console\Commands\NetgesconQaUnitaNominativiCommand;
use App\Console\Commands\NetgesconRestoreRecordCommand;
use App\Console\Commands\StabiliTransferCommand; use App\Console\Commands\StabiliTransferCommand;
use App\Console\Commands\SyncSoggettiToPersone; use App\Console\Commands\SyncSoggettiToPersone;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
@ -31,6 +33,7 @@
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__ . '/../routes/web.php', web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
commands: __DIR__ . '/../routes/console.php', commands: __DIR__ . '/../routes/console.php',
health: '/up', health: '/up',
) )
@ -53,6 +56,8 @@
ImportGesconF24LegacyCommand::class, ImportGesconF24LegacyCommand::class,
NetgesconQaUnitaNominativiCommand::class, NetgesconQaUnitaNominativiCommand::class,
NetgesconDistributionPullCommand::class, NetgesconDistributionPullCommand::class,
NetgesconPreupdateBackupCommand::class,
NetgesconRestoreRecordCommand::class,
GoogleSyncRubricaContactsCommand::class, GoogleSyncRubricaContactsCommand::class,
GooglePushRubricaContactsCommand::class, GooglePushRubricaContactsCommand::class,
GoogleImportKeepNotesCommand::class, GoogleImportKeepNotesCommand::class,
@ -68,8 +73,27 @@
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
'verified' => \App\Http\Middleware\EnsureEmailIsVerifiedForNetgescon::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// $exceptions->report(function (\Throwable $e): void {
if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface && $e->getStatusCode() < 500) {
return;
}
$record = [
'timestamp' => now()->toIso8601String(),
'type' => 'runtime',
'message' => $e->getMessage(),
'context' => sprintf('%s:%d', $e->getFile(), $e->getLine()),
];
$json = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (! is_string($json) || $json === '') {
return;
}
@file_put_contents(storage_path('logs/runtime-errors.ndjson'), $json . PHP_EOL, FILE_APPEND);
});
})->create(); })->create();

View File

@ -23,4 +23,8 @@
// Timeout client per check/download. // Timeout client per check/download.
'http_timeout_seconds' => (int) env('NETGESCON_UPDATE_HTTP_TIMEOUT', 60), 'http_timeout_seconds' => (int) env('NETGESCON_UPDATE_HTTP_TIMEOUT', 60),
// Override opzionale DNS per richieste update (formato CSV host:port:ip).
// Esempio: updates.netgescon.it:443:192.168.0.53
'http_resolve' => env('NETGESCON_UPDATE_RESOLVE', ''),
]; ];

View File

@ -1,7 +1,7 @@
<?php <?php
return [ return [
'version' => env('NETGESCON_VERSION', '0.8.0'), 'version' => env('NETGESCON_VERSION', '0.8.1'),
'ui' => [ 'ui' => [
'force_filament' => env('NETGESCON_FORCE_FILAMENT', false), 'force_filament' => env('NETGESCON_FORCE_FILAMENT', false),
], ],

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('subscription_plans', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->decimal('price_monthly', 10, 2)->default(0);
$table->boolean('is_active')->default(true);
$table->json('features')->nullable();
$table->timestamps();
});
DB::table('subscription_plans')->insert([
[
'name' => 'Base',
'slug' => 'base',
'price_monthly' => 0,
'is_active' => true,
'features' => json_encode(['ticket', 'anagrafiche']),
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'Pro',
'slug' => 'pro',
'price_monthly' => 49,
'is_active' => true,
'features' => json_encode(['ticket', 'anagrafiche', 'contabilita', 'google']),
'created_at' => now(),
'updated_at' => now(),
],
]);
}
public function down(): void
{
Schema::dropIfExists('subscription_plans');
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_methods', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('type')->default('manuale');
$table->boolean('is_active')->default(true);
$table->json('details')->nullable();
$table->timestamps();
});
DB::table('payment_methods')->insert([
[
'name' => 'Bonifico Bancario',
'type' => 'bank_transfer',
'is_active' => true,
'details' => json_encode(['manual_verification' => true]),
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'Carta di Credito',
'type' => 'card',
'is_active' => false,
'details' => json_encode(['provider' => 'pending']),
'created_at' => now(),
'updated_at' => now(),
],
]);
}
public function down(): void
{
Schema::dropIfExists('payment_methods');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('system_settings', function (Blueprint $table): void {
$table->id();
$table->string('key')->unique();
$table->longText('value')->nullable();
$table->timestamps();
});
DB::table('system_settings')->insert([
['key' => 'registration_enabled', 'value' => 'true', 'created_at' => now(), 'updated_at' => now()],
['key' => 'require_email_verification', 'value' => 'true', 'created_at' => now(), 'updated_at' => now()],
['key' => 'recaptcha_version', 'value' => 'none', 'created_at' => now(), 'updated_at' => now()],
['key' => 'recaptcha_site_key', 'value' => '', 'created_at' => now(), 'updated_at' => now()],
['key' => 'recaptcha_secret_key', 'value' => '', 'created_at' => now(), 'updated_at' => now()],
]);
}
public function down(): void
{
Schema::dropIfExists('system_settings');
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->boolean('is_active')->default(true)->after('remember_token');
$table->string('registration_status', 32)->default('approved')->after('is_active');
$table->string('requested_role', 64)->nullable()->after('registration_status');
$table->foreignId('subscription_plan_id')->nullable()->after('requested_role')->constrained('subscription_plans')->nullOnDelete();
$table->foreignId('approved_by_user_id')->nullable()->after('subscription_plan_id')->constrained('users')->nullOnDelete();
$table->timestamp('approved_at')->nullable()->after('approved_by_user_id');
$table->timestamp('rejected_at')->nullable()->after('approved_at');
$table->string('rejection_reason', 500)->nullable()->after('rejected_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropConstrainedForeignId('approved_by_user_id');
$table->dropConstrainedForeignId('subscription_plan_id');
$table->dropColumn([
'is_active',
'registration_status',
'requested_role',
'approved_at',
'rejected_at',
'rejection_reason',
]);
});
}
};

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
if (! Schema::hasColumn('users', 'two_factor_secret')) {
$table->text('two_factor_secret')->nullable()->after('password');
}
if (! Schema::hasColumn('users', 'two_factor_recovery_codes')) {
$table->text('two_factor_recovery_codes')->nullable()->after('two_factor_secret');
}
if (! Schema::hasColumn('users', 'two_factor_confirmed_at')) {
$table->timestamp('two_factor_confirmed_at')->nullable()->after('two_factor_recovery_codes');
}
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
if (Schema::hasColumn('users', 'two_factor_confirmed_at')) {
$table->dropColumn('two_factor_confirmed_at');
}
if (Schema::hasColumn('users', 'two_factor_recovery_codes')) {
$table->dropColumn('two_factor_recovery_codes');
}
if (Schema::hasColumn('users', 'two_factor_secret')) {
$table->dropColumn('two_factor_secret');
}
});
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('insurance_claims', function (Blueprint $table): void {
$table->id();
$table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete();
$table->foreignId('stabile_id')->nullable()->constrained('stabili')->nullOnDelete();
$table->string('policy_reference')->nullable();
$table->string('claim_number')->nullable();
$table->string('status', 32)->default('bozza');
$table->timestamp('opened_at')->nullable();
$table->text('notes')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['ticket_id', 'status']);
$table->index(['claim_number']);
});
}
public function down(): void
{
Schema::dropIfExists('insurance_claims');
}
};

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('communication_messages', function (Blueprint $table): void {
$table->id();
$table->string('channel', 32);
$table->string('direction', 16)->default('inbound');
$table->string('protocol_number', 40)->unique();
$table->string('external_chat_id')->nullable();
$table->string('external_message_id')->nullable();
$table->string('phone_number', 32)->nullable();
$table->string('sender_name')->nullable();
$table->longText('message_text')->nullable();
$table->json('attachments')->nullable();
$table->foreignId('ticket_id')->nullable()->constrained('tickets')->nullOnDelete();
$table->foreignId('post_it_id')->nullable()->constrained('chiamate_post_it')->nullOnDelete();
$table->foreignId('insurance_claim_id')->nullable()->constrained('insurance_claims')->nullOnDelete();
$table->string('status', 32)->default('received');
$table->timestamp('received_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['channel', 'direction']);
$table->index(['ticket_id', 'created_at']);
$table->index(['post_it_id', 'created_at']);
$table->index(['phone_number']);
$table->index(['external_message_id']);
});
}
public function down(): void
{
Schema::dropIfExists('communication_messages');
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$rows = [
['key' => 'telegram_bot_token', 'value' => ''],
['key' => 'telegram_webhook_token', 'value' => ''],
['key' => 'whatsapp_phone_number_id', 'value' => ''],
['key' => 'whatsapp_business_account_id', 'value' => ''],
['key' => 'whatsapp_access_token', 'value' => ''],
['key' => 'whatsapp_webhook_token', 'value' => ''],
];
foreach ($rows as $row) {
DB::table('system_settings')->updateOrInsert(
['key' => $row['key']],
['value' => $row['value'], 'updated_at' => now(), 'created_at' => now()]
);
}
}
public function down(): void
{
DB::table('system_settings')
->whereIn('key', [
'telegram_bot_token',
'telegram_webhook_token',
'whatsapp_phone_number_id',
'whatsapp_business_account_id',
'whatsapp_access_token',
'whatsapp_webhook_token',
])
->delete();
}
};

View File

@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
if (! Schema::hasColumn('users', 'pbx_extension')) {
$table->string('pbx_extension', 20)->nullable()->after('two_factor_confirmed_at');
}
if (! Schema::hasColumn('users', 'pbx_popup_enabled')) {
$table->boolean('pbx_popup_enabled')->default(false)->after('pbx_extension');
}
if (! Schema::hasColumn('users', 'pbx_click_to_call_enabled')) {
$table->boolean('pbx_click_to_call_enabled')->default(false)->after('pbx_popup_enabled');
}
});
Schema::table('communication_messages', function (Blueprint $table): void {
if (! Schema::hasColumn('communication_messages', 'target_extension')) {
$table->string('target_extension', 20)->nullable()->after('phone_number');
}
if (! Schema::hasColumn('communication_messages', 'assigned_user_id')) {
$table->foreignId('assigned_user_id')->nullable()->after('target_extension')->constrained('users')->nullOnDelete();
$table->index(['assigned_user_id', 'created_at'], 'comm_msg_assigned_user_created_idx');
}
if (! Schema::hasColumn('communication_messages', 'stabile_id')) {
$table->foreignId('stabile_id')->nullable()->after('assigned_user_id')->constrained('stabili')->nullOnDelete();
$table->index(['stabile_id', 'created_at'], 'comm_msg_stabile_created_idx');
}
});
}
public function down(): void
{
Schema::table('communication_messages', function (Blueprint $table): void {
if (Schema::hasColumn('communication_messages', 'stabile_id')) {
$table->dropConstrainedForeignId('stabile_id');
$table->dropIndex('comm_msg_stabile_created_idx');
}
if (Schema::hasColumn('communication_messages', 'assigned_user_id')) {
$table->dropConstrainedForeignId('assigned_user_id');
$table->dropIndex('comm_msg_assigned_user_created_idx');
}
if (Schema::hasColumn('communication_messages', 'target_extension')) {
$table->dropColumn('target_extension');
}
});
Schema::table('users', function (Blueprint $table): void {
$drop = [];
foreach (['pbx_extension', 'pbx_popup_enabled', 'pbx_click_to_call_enabled'] as $col) {
if (Schema::hasColumn('users', $col)) {
$drop[] = $col;
}
}
if ($drop !== []) {
$table->dropColumn($drop);
}
});
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pbx_click_to_call_requests', function (Blueprint $table): void {
$table->id();
$table->foreignId('requested_by_user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('assigned_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('stabile_id')->nullable()->constrained('stabili')->nullOnDelete();
$table->foreignId('communication_message_id')->nullable()->constrained('communication_messages')->nullOnDelete();
$table->string('source_extension', 20);
$table->string('target_number', 40);
$table->string('status', 20)->default('pending');
$table->text('note')->nullable();
$table->timestamp('requested_at')->nullable();
$table->timestamp('processed_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index(['status', 'requested_at']);
$table->index(['source_extension', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('pbx_click_to_call_requests');
}
};

View File

@ -0,0 +1,55 @@
# CTI Panasonic NS1000 - Checklist Operativa
## 1) Endpoint NetGescon da usare
- `POST /api/v1/cti/panasonic/incoming`
- `POST /api/v1/cti/panasonic/call-ended`
- `GET /api/v1/cti/panasonic/lookup?phone=...`
Esempio base:
- `https://staging.netgescon.it/api/v1/cti/panasonic/incoming`
## 2) Cosa controllare quando non arrivano chiamate
- Rete: il centralino deve raggiungere HTTPS 443 del server NetGescon.
- DNS: host configurato nel PBX deve risolvere l'IP corretto.
- Certificato TLS: il PBX non deve rifiutare il certificato del dominio.
- Token CTI: deve combaciare con il token lato NetGescon.
- Clock/NTP: orario PBX corretto (evita problemi firma/token e log).
- Firewall: apertura uscita dal PBX verso internet/DMZ su 443.
## 3) Porta 33333 / servizi monitoraggio
- La porta `33333` su NS1000 viene spesso usata per stream eventi/integrazioni CTI legacy.
- Se usata, configurare IP sorgente autorizzati e ACL precise (mai aperta pubblicamente).
- Preferire webhook HTTPS verso NetGescon come canale primario e 33333/syslog come canale diagnostico.
## 4) Syslog NS1000 (consigliato)
- Abilitare invio syslog remoto dal PBX verso un collector interno.
- Livello consigliato: eventi chiamata + errori integrazione.
- Conservare almeno 7-14 giorni per analisi incidenti.
- Correlare timestamp syslog con log applicativi NetGescon.
## 5) Configurazione web NS1000 (traccia operativa)
1. Accedi alla Web Maintenance Console.
2. Vai su sezione integrazioni CTI/CSTA e abilita notifica eventi chiamata.
3. Imposta URL callback HTTPS di NetGescon per eventi incoming/call-ended.
4. Inserisci token condiviso (stesso valore lato NetGescon).
5. In sezione sicurezza/rete limita destinazioni consentite al solo host NetGescon.
6. In sezione log abilita syslog remoto (se disponibile) verso collector interno.
7. Salva e riavvia il servizio CTI (o applica configurazione runtime).
## 6) Test rapido post-configurazione
1. Effettua una chiamata di test entrante sul centralino.
2. Verifica che sia creato/aggiornato il Post-it chiamata in NetGescon.
3. Chiudi la chiamata e verifica aggiornamento su endpoint `call-ended`.
4. Controlla log applicativi e, se attivo, log syslog del PBX.
## 7) Opzione interno VOIP monitor
- Si puo configurare un interno tecnico di monitoraggio per ascolto/diagnostica, rispettando policy privacy.
- Usarlo solo per troubleshooting, con accesso limitato e audit.

View File

@ -0,0 +1,53 @@
# SMDR Panasonic NS1000 - Integrazione Operativa
## Stato attuale
- Listener attivo via comando Laravel `smdr:listen`.
- Ingestione su:
- `communication_messages` (`channel = smdr`)
- `chiamate_post_it` (record operativi)
## Comando base
```bash
php artisan smdr:listen \
--host=192.168.0.101 \
--port=2300 \
--user=SMDR \
--pass=PCCSMDR \
--write-postit=1 \
--write-communications=1 \
--reconnect=1 \
--reconnect-delay=5
```
## Opzioni utili
- `--no-auth`: se il centralino non richiede credenziali su socket.
- `--max-lines=50`: test rapido su N righe e stop.
- `--timeout=30`: timeout lettura socket.
## Verifica dati acquisiti
```bash
php artisan tinker --execute="echo App\\Models\\CommunicationMessage::where('channel','smdr')->count();"
php artisan tinker --execute="echo App\\Models\\ChiamataPostIt::count();"
```
## URL operativi correlati
- Fornitore: `/fornitore/tickets`
- Mobile admin: `/admin/mobile`
- Mobile tickets: `/admin/tickets-mobile`
## Note parsing SMDR
- Le righe vengono salvate raw per audit.
- Esempio formato rilevato:
- `03/02/26 15:22 206 03 3333395405 00:00'03 EU00000.00`
- `03/02/26 17:03 201 03 <I>3478160508 00:00'59 EU00000.00`
- `\u003cI\u003e` indica tipicamente ingresso; numeri esterni senza `\u003cI\u003e` tipicamente uscita; `EXTxxx` chiamata interna.
## Prossimo step consigliato
- Mappare parser SMDR su campi strutturati (interno, trunk, numero, durata, costo, direzione) e dashboard live chiamate in ingresso/uscita.

View File

@ -0,0 +1,41 @@
# Online Error Sync (Git)
Obiettivo: vedere in `supporto/modifiche` anche errori prodotti in ambiente online/staging.
## Flusso consigliato
1. Sul server online/staging, nella root progetto, eseguire:
```bash
bash scripts/ops/netgescon-export-online-errors-to-git.sh
```
1. Commit/push del file esportato:
```bash
git add docs/support/online-runtime-errors.ndjson
git commit -m "chore: export online runtime errors"
git push
```
1. In ambiente sviluppo, fare pull e aprire:
- `admin-filament/supporto/modifiche`
- TAB `Errori`
Gli errori online compariranno con origine `online-runtime-errors.git`.
## File coinvolti
- Export script: `scripts/ops/netgescon-export-online-errors-to-git.sh`
- Feed Git online: `docs/support/online-runtime-errors.ndjson`
- Dashboard supporto: `app/Filament/Pages/Supporto/Modifiche.php`
## Nota
Se il feed non compare, usare in sviluppo:
```bash
php artisan optimize:clear
php artisan view:cache
```

View File

@ -351,6 +351,40 @@ class="bg-light0 hover:bg-gray-700 text-white fw-bold py-2 px-3 rounded">
@endif @endif
</div> </div>
<!-- Assicurazione / Sinistro -->
<div class="bg-light dark:bg-gray-700 p-4 rounded-lg mt-6">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">Assicurazione e Sinistro</h4>
<form method="POST" action="{{ route('admin.tickets.insurance.open', $ticket) }}" class="mb-4">
@csrf
<div class="grid row md:row gap-3">
<div>
<label for="policy_reference" class="form-label">Riferimento polizza</label>
<input id="policy_reference" type="text" name="policy_reference" class="form-control" value="{{ old('policy_reference', optional($ticket->insuranceClaim)->policy_reference) }}" placeholder="es. POL-2026-ABC">
</div>
<div>
<label for="claim_number" class="form-label">Numero sinistro</label>
<input id="claim_number" type="text" name="claim_number" class="form-control" value="{{ old('claim_number', optional($ticket->insuranceClaim)->claim_number) }}" placeholder="es. SIN-2026-0012">
</div>
<div class="md:col-span-2">
<label for="notes_claim" class="form-label">Note apertura sinistro</label>
<textarea id="notes_claim" name="notes" class="form-control" rows="2" placeholder="Dettagli utili per assicuratore/legale">{{ old('notes', optional($ticket->insuranceClaim)->notes) }}</textarea>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-outline-primary">Apri / Aggiorna Sinistro</button>
</div>
</form>
@if($ticket->insuranceClaim)
<div class="border rounded p-3 bg-white">
<p class="mb-1"><strong>Stato:</strong> {{ $ticket->insuranceClaim->status }}</p>
<p class="mb-1"><strong>Aperto il:</strong> {{ optional($ticket->insuranceClaim->opened_at)->format('d/m/Y H:i') ?: '-' }}</p>
<p class="mb-0 text-sm text-muted">Da questo momento puoi associare comunicazioni email/Telegram/WhatsApp al ticket e mantenerle nel protocollo.</p>
</div>
@endif
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -24,6 +24,18 @@
<x-input-error :messages="$errors->get('password')" class="mt-2" /> <x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> </div>
<div class="mt-4">
<x-input-label for="otp_code" :value="__('Codice App Authenticator (se abilitato)')" />
<x-text-input id="otp_code" class="block mt-1 w-full"
type="text"
name="otp_code"
inputmode="numeric"
pattern="[0-9]*"
maxlength="6"
autocomplete="one-time-code" />
<x-input-error :messages="$errors->get('otp_code')" class="mt-2" />
</div>
<!-- Remember Me --> <!-- Remember Me -->
<div class="block mt-4"> <div class="block mt-4">
<label for="remember_me" class="inline-flex items-center"> <label for="remember_me" class="inline-flex items-center">

View File

@ -1,4 +1,10 @@
<x-guest-layout> <x-guest-layout>
@if(!($registrationEnabled ?? true))
<div class="mb-4 p-4 rounded-md bg-yellow-50 border border-yellow-200 text-yellow-800 text-sm">
La registrazione e temporaneamente disabilitata. Contatta il supporto per l'attivazione dell'account.
</div>
@endif
<form method="POST" action="{{ route('register') }}"> <form method="POST" action="{{ route('register') }}">
@csrf @csrf
@ -9,14 +15,6 @@
<x-input-error :messages="$errors->get('name')" class="mt-2" /> <x-input-error :messages="$errors->get('name')" class="mt-2" />
</div> </div>
<!-- Cognome (richiesto per Amministratore) -->
<div class="mt-4">
<x-input-label for="cognome" :value="__('Cognome')" />
<x-text-input id="cognome" class="block mt-1 w-full" type="text" name="cognome" :value="old('cognome')" autocomplete="family-name" />
<p class="text-xs text-gray-500 mt-1">Obbligatorio se stai registrando un Amministratore.</p>
<x-input-error :messages="$errors->get('cognome')" class="mt-2" />
</div>
<!-- Email --> <!-- Email -->
<div class="mt-4"> <div class="mt-4">
<x-input-label for="email" :value="__('Email')" /> <x-input-label for="email" :value="__('Email')" />
@ -56,9 +54,38 @@
<option value="condomino" {{ old('role') === 'condomino' ? 'selected' : '' }}>Condomino</option> <option value="condomino" {{ old('role') === 'condomino' ? 'selected' : '' }}>Condomino</option>
<option value="inquilino" {{ old('role') === 'inquilino' ? 'selected' : '' }}>Inquilino</option> <option value="inquilino" {{ old('role') === 'inquilino' ? 'selected' : '' }}>Inquilino</option>
</select> </select>
<p class="text-sm text-gray-500 mt-1">Condomino/Inquilino accedono direttamente all'apertura ticket; Amministratore/Collaboratore entrano in area gestionale.</p> <p class="text-sm text-gray-500 mt-1">L'account resta in attesa di approvazione super-admin prima del primo accesso.</p>
</div> </div>
@if(($recaptchaVersion ?? 'none') === 'v2' && !empty($recaptchaSiteKey))
<div class="mt-4">
<div class="g-recaptcha" data-sitekey="{{ $recaptchaSiteKey }}"></div>
<x-input-error :messages="$errors->get('g-recaptcha-response')" class="mt-2" />
</div>
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
@endif
@if(($recaptchaVersion ?? 'none') === 'v3' && !empty($recaptchaSiteKey))
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response">
<x-input-error :messages="$errors->get('g-recaptcha-response')" class="mt-2" />
<script src="https://www.google.com/recaptcha/api.js?render={{ $recaptchaSiteKey }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
if (typeof grecaptcha === 'undefined') {
return;
}
grecaptcha.ready(function () {
grecaptcha.execute('{{ $recaptchaSiteKey }}', {action: 'register'}).then(function (token) {
var field = document.getElementById('g-recaptcha-response');
if (field) {
field.value = token;
}
});
});
});
</script>
@endif
<div class="flex items-center justify-end mt-4"> <div class="flex items-center justify-end mt-4">
<a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('login') }}"> <a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('login') }}">
{{ __('Hai già un account?') }} {{ __('Hai già un account?') }}
@ -68,5 +95,9 @@
{{ __('Registrati') }} {{ __('Registrati') }}
</x-primary-button> </x-primary-button>
</div> </div>
<p class="text-xs text-gray-500 mt-4">
Dopo la registrazione riceverai una email di verifica e l'accesso verra abilitato solo dopo approvazione.
</p>
</form> </form>
</x-guest-layout> </x-guest-layout>

View File

@ -43,6 +43,78 @@
</div> </div>
</div> </div>
<div class="rounded-lg border p-4">
<div class="text-lg font-semibold">Dipendenti e accessi fornitore</div>
<div class="mt-1 text-xs text-gray-500">La gestione accessi dei dipendenti fornitore e centralizzata qui.</div>
@if(filled($lastGeneratedPassword ?? null))
<div class="mt-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Ultima password temporanea generata: <span class="font-mono font-semibold">{{ $lastGeneratedPassword }}</span>
</div>
@endif
<div class="mt-3 grid grid-cols-1 gap-2 md:grid-cols-4">
<input type="text" wire:model.defer="nuovoDipendenteNome" class="rounded-md border-gray-300 text-xs" placeholder="Nome" />
<input type="text" wire:model.defer="nuovoDipendenteCognome" class="rounded-md border-gray-300 text-xs" placeholder="Cognome" />
<input type="email" wire:model.defer="nuovoDipendenteEmail" class="rounded-md border-gray-300 text-xs" placeholder="Email" />
<input type="text" wire:model.defer="nuovoDipendenteTelefono" class="rounded-md border-gray-300 text-xs" placeholder="Telefono" />
</div>
<div class="mt-2">
<x-filament::button size="sm" color="primary" type="button" wire:click="creaDipendenteFornitore">Aggiungi dipendente</x-filament::button>
</div>
<div class="mt-3 overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-1.5 text-left">Dipendente</th>
<th class="border px-2 py-1.5 text-left">Email</th>
<th class="border px-2 py-1.5 text-left">Telefono</th>
<th class="border px-2 py-1.5 text-left">Utente collegato</th>
<th class="border px-2 py-1.5 text-left">Stato</th>
<th class="border px-2 py-1.5 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($dipendentiRows as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5">{{ $row['nome'] }}</td>
<td class="border px-2 py-1.5">{{ $row['email'] !== '' ? $row['email'] : '-' }}</td>
<td class="border px-2 py-1.5">{{ $row['telefono'] !== '' ? $row['telefono'] : '-' }}</td>
<td class="border px-2 py-1.5">
@if((int) ($row['user_id'] ?? 0) > 0)
{{ $row['user_label'] }} (ID {{ (int) $row['user_id'] }})
@else
-
@endif
</td>
<td class="border px-2 py-1.5">
@if($row['attivo'])
<span class="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800">attivo</span>
@else
<span class="rounded bg-rose-100 px-1.5 py-0.5 text-[10px] font-semibold text-rose-800">non attivo</span>
@endif
</td>
<td class="border px-2 py-1.5">
<div class="flex flex-wrap gap-1">
<x-filament::button size="xs" color="primary" type="button" wire:click="abilitaAccessoDipendente({{ (int) $row['id'] }})">Abilita accesso</x-filament::button>
@if((int) ($row['user_id'] ?? 0) > 0)
<x-filament::button size="xs" color="warning" type="button" wire:click="resetPasswordDipendenteUser({{ (int) $row['user_id'] }})">Reset password</x-filament::button>
@endif
<x-filament::button size="xs" color="gray" type="button" wire:click="toggleDipendenteAttivo({{ (int) $row['id'] }})">Toggle attivo</x-filament::button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="border px-2 py-3 text-center text-gray-500">Nessun dipendente registrato per questo fornitore.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="rounded-lg border p-4"> <div class="rounded-lg border p-4">
<div class="text-lg font-semibold">Pagamenti</div> <div class="text-lg font-semibold">Pagamenti</div>
<div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2"> <div class="mt-2 grid grid-cols-1 gap-2 md:grid-cols-2">

View File

@ -285,6 +285,15 @@
</x-filament::section> </x-filament::section>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-white p-2">
<div class="flex flex-wrap gap-2">
<button type="button" wire:click="$set('sideTab', 'collegamenti')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $sideTab === 'collegamenti' ? 'bg-indigo-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">Collegamenti</button>
<button type="button" wire:click="$set('sideTab', 'dipendenti')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $sideTab === 'dipendenti' ? 'bg-indigo-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">Dipendenti</button>
<button type="button" wire:click="$set('sideTab', 'autorizzazioni')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $sideTab === 'autorizzazioni' ? 'bg-indigo-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">Ruoli e autorizzazioni</button>
</div>
</div>
@if($sideTab === 'collegamenti')
<x-filament::section> <x-filament::section>
<x-slot name="heading">Collegamenti</x-slot> <x-slot name="heading">Collegamenti</x-slot>
@ -325,6 +334,87 @@
</div> </div>
</div> </div>
</x-filament::section> </x-filament::section>
@endif
@if($sideTab === 'dipendenti')
<x-filament::section>
<x-slot name="heading">Collega o crea dipendenti fornitore</x-slot>
<x-slot name="description">Usa questa tab per agganciare un nominativo rubrica come dipendente o creare un nuovo dipendente manualmente.</x-slot>
<div class="space-y-4">
@forelse($fornitoriCollegati as $f)
<div class="rounded-lg border border-gray-200 p-3">
<div class="mb-2 text-sm font-semibold text-gray-800">{{ $f['nome'] }}</div>
<div class="mb-2 text-xs text-gray-500">Fornitore #{{ $f['id'] }} · Rubrica collegata: {{ $f['rubrica_id'] ?: '—' }}</div>
<div class="grid grid-cols-1 gap-2">
<div class="flex flex-wrap items-center gap-2">
<input type="number" min="1" wire:model.defer="dipendenteRubricaSelect.{{ (int) $f['id'] }}" class="w-40 rounded-md border-gray-300 text-xs" placeholder="ID rubrica dipendente" />
<x-filament::button size="xs" color="primary" type="button" wire:click="collegaRubricaDipendente({{ (int) $f['id'] }})">Collega da rubrica</x-filament::button>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-4">
<input type="text" wire:model.defer="dipendenteManualDraft.{{ (int) $f['id'] }}.nome" class="rounded-md border-gray-300 text-xs" placeholder="Nome" />
<input type="text" wire:model.defer="dipendenteManualDraft.{{ (int) $f['id'] }}.cognome" class="rounded-md border-gray-300 text-xs" placeholder="Cognome" />
<input type="email" wire:model.defer="dipendenteManualDraft.{{ (int) $f['id'] }}.email" class="rounded-md border-gray-300 text-xs" placeholder="Email" />
<input type="text" wire:model.defer="dipendenteManualDraft.{{ (int) $f['id'] }}.telefono" class="rounded-md border-gray-300 text-xs" placeholder="Telefono" />
</div>
<div>
<x-filament::button size="xs" color="gray" type="button" wire:click="creaDipendenteManuale({{ (int) $f['id'] }})">Crea dipendente</x-filament::button>
</div>
</div>
</div>
@empty
<div class="text-sm text-gray-500">Nessun fornitore collegato a questo nominativo rubrica.</div>
@endforelse
</div>
</x-filament::section>
@endif
@if($sideTab === 'autorizzazioni')
<x-filament::section>
<x-slot name="heading">Ruoli e autorizzazioni dipendenti</x-slot>
<x-slot name="description">Abilita accesso utente e assegna interno PBX per i dipendenti collegati ai fornitori.</x-slot>
<div class="overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-1.5 text-left">Fornitore</th>
<th class="border px-2 py-1.5 text-left">Dipendente</th>
<th class="border px-2 py-1.5 text-left">Email</th>
<th class="border px-2 py-1.5 text-left">Ruoli utente</th>
<th class="border px-2 py-1.5 text-left">Interno PBX</th>
<th class="border px-2 py-1.5 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($dipendentiFornitoreRows as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5">{{ $row['fornitore_nome'] }}</td>
<td class="border px-2 py-1.5">{{ $row['nome'] }}</td>
<td class="border px-2 py-1.5">{{ $row['email'] !== '' ? $row['email'] : '-' }}</td>
<td class="border px-2 py-1.5">{{ count($row['ruoli']) > 0 ? implode(', ', $row['ruoli']) : '-' }}</td>
<td class="border px-2 py-1.5">
<input type="text" wire:model.defer="dipendentePbxExtension.{{ (int) $row['id'] }}" class="w-24 rounded-md border-gray-300 text-xs" placeholder="Es. 101" />
</td>
<td class="border px-2 py-1.5">
@if(! $row['user_id'])
<x-filament::button size="xs" color="primary" type="button" wire:click="abilitaAccessoDipendente({{ (int) $row['id'] }})">Abilita accesso</x-filament::button>
@endif
<x-filament::button size="xs" color="gray" type="button" wire:click="salvaInternoDipendente({{ (int) $row['id'] }})">Salva interno</x-filament::button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="border px-2 py-3 text-center text-gray-500">Nessun dipendente fornitore collegato.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-filament::section>
@endif
<x-filament::section> <x-filament::section>
<x-slot name="heading">Azioni</x-slot> <x-slot name="heading">Azioni</x-slot>

View File

@ -1,21 +1,40 @@
@php
$hasFornitoriUi = method_exists($this, 'apriElenco')
&& method_exists($this, 'apriScheda')
&& method_exists($this, 'getFornitoreLabel');
@endphp
@if(! $hasFornitoriUi)
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
@else
<x-filament-panels::page> <x-filament-panels::page>
<div class="space-y-4"> <div class="space-y-4" wire:key="fornitori-archivio-{{ $this->activeTab }}-{{ (int) ($this->selectedFornitoreId ?? 0) }}">
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4">
<div class="mb-3 flex flex-wrap gap-2"> <div class="mb-3 flex flex-wrap gap-2">
<button <button
type="button" type="button"
wire:click="apriElenco" wire:click="apriElenco"
class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'elenco' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $this->activeTab === 'elenco' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}"
> >
Tab 1: Elenco Fornitori Tab 1: Elenco Fornitori
</button> </button>
<button <button
type="button" type="button"
wire:click="$set('activeTab', 'scheda')" wire:click="apriTabScheda"
class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'scheda' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}" x-on:click="$nextTick(() => document.getElementById('fornitore-scheda-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' }))"
class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $this->activeTab === 'scheda' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}"
> >
Tab 2: Scheda Fornitore (inline) Tab 2: Scheda Fornitore (inline)
</button> </button>
<button
type="button"
wire:click="apriTabDipendenti"
class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $this->activeTab === 'dipendenti' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}"
>
Tab 3: Dipendenti Fornitore
</button>
</div> </div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3"> <div class="grid grid-cols-1 gap-3 md:grid-cols-3">
@ -28,20 +47,62 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $a
</div> </div>
</div> </div>
@if($activeTab === 'elenco')
<div class="mt-3"> <div class="mt-3">
<label class="block text-xs font-medium text-gray-700">Ricerca fornitore</label> <label class="block text-xs font-medium text-gray-700">Ricerca fornitore</label>
<div class="mt-1">
<div class="flex w-full flex-col gap-2 md:w-[760px] md:flex-row md:items-center">
<input <input
type="text" type="text"
wire:model.live.debounce.400ms="search" wire:model.defer="searchInput"
wire:keydown.enter="applicaRicerca"
placeholder="Ragione sociale, nome, P.IVA, CF, email, telefono, TAG" placeholder="Ragione sociale, nome, P.IVA, CF, email, telefono, TAG"
class="mt-1 w-full rounded-lg border-gray-300 text-sm" class="w-full rounded-lg border-gray-300 text-sm"
> >
<div class="flex items-center gap-2">
<button
type="button"
wire:click="applicaRicerca"
class="inline-flex items-center rounded-md bg-slate-700 px-3 py-2 text-xs font-medium text-white hover:bg-slate-600"
>
Cerca
</button>
@if(trim((string) ($this->fornitoriSearch ?? '')) !== '')
<button
type="button"
wire:click="pulisciRicerca"
class="inline-flex items-center rounded-md bg-slate-100 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-200"
title="Cancella ricerca"
aria-label="Cancella ricerca"
>
Pulisci
</button>
@endif
</div>
</div>
@if(count($this->activeSearchFilters) > 0)
<div class="mt-2 flex flex-wrap gap-2">
@foreach($this->activeSearchFilters as $token)
<span class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-700">
{{ $token }}
<button
type="button"
wire:click='removeSearchFilter(@js($token))'
class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-500 hover:bg-slate-200 hover:text-slate-800"
title="Rimuovi filtro"
aria-label="Rimuovi filtro"
>
<span class="leading-none">X</span>
</button>
</span>
@endforeach
</div> </div>
@endif @endif
</div> </div>
</div>
</div>
@if($activeTab === 'elenco') @if($this->activeTab === 'elenco')
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full border-collapse border text-xs"> <table class="min-w-full border-collapse border text-xs">
@ -57,7 +118,7 @@ class="mt-1 w-full rounded-lg border-gray-300 text-sm"
</thead> </thead>
<tbody> <tbody>
@forelse($this->fornitoriRows as $f) @forelse($this->fornitoriRows as $f)
<tr class="hover:bg-slate-50"> <tr class="hover:bg-slate-50" wire:key="fornitore-row-{{ (int) $f->id }}">
<td class="border px-2 py-2"> <td class="border px-2 py-2">
<div class="font-medium">{{ $this->getFornitoreLabel($f) }}</div> <div class="font-medium">{{ $this->getFornitoreLabel($f) }}</div>
<div class="text-[11px] text-gray-500">#{{ (int) $f->id }}</div> <div class="text-[11px] text-gray-500">#{{ (int) $f->id }}</div>
@ -83,7 +144,7 @@ class="mt-1 w-full rounded-lg border-gray-300 text-sm"
</td> </td>
<td class="border px-2 py-2"> <td class="border px-2 py-2">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<button type="button" wire:click="apriScheda({{ (int) $f->id }})" class="inline-flex items-center rounded-md bg-slate-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-slate-600">Apri scheda inline</button> <button type="button" wire:click.prevent="apriScheda({{ (int) $f->id }})" x-on:click="$nextTick(() => document.getElementById('fornitore-scheda-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' }))" class="inline-flex items-center rounded-md bg-slate-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-slate-600">Apri scheda inline</button>
<a href="{{ $this->getFornitoreHubUrl((int) $f->id) }}" class="inline-flex items-center rounded-md bg-indigo-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-600">Apri HUB</a> <a href="{{ $this->getFornitoreHubUrl((int) $f->id) }}" class="inline-flex items-center rounded-md bg-indigo-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-600">Apri HUB</a>
</div> </div>
</td> </td>
@ -98,17 +159,113 @@ class="mt-1 w-full rounded-lg border-gray-300 text-sm"
</div> </div>
<div class="mt-2 text-[11px] text-gray-500">Mostrati max 400 record per mantenere la pagina rapida.</div> <div class="mt-2 text-[11px] text-gray-500">Mostrati max 400 record per mantenere la pagina rapida.</div>
</div> </div>
@else @elseif($this->activeTab === 'scheda')
@php($fornitore = $this->selectedFornitore) @php($fornitore = $this->selectedFornitore)
<div class="rounded-xl border bg-white p-4"> <div id="fornitore-scheda-panel" class="rounded-xl border bg-white p-4">
@if($fornitore) @if($fornitore)
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div> <div>
<div class="text-sm font-semibold">Scheda fornitore inline: {{ $this->getFornitoreLabel($fornitore) }}</div> <div class="text-sm font-semibold">Scheda fornitore inline: {{ $this->getFornitoreLabel($fornitore) }}</div>
<div class="text-xs text-gray-500">ID #{{ (int) $fornitore->id }} · Dipendenti collegati: {{ (int) ($fornitore->dipendenti_count ?? 0) }}</div> <div class="text-xs text-gray-500">ID #{{ (int) $fornitore->id }} · Dipendenti collegati: {{ (int) ($fornitore->dipendenti_count ?? 0) }}</div>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<button type="button" wire:click="apriElenco" class="inline-flex items-center rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-200">Torna a elenco</button>
<button type="button" wire:click="apriTabDipendenti" class="inline-flex items-center rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-200">Tab 3: Dipendenti fornitore</button>
<a href="{{ $this->getFornitoreAnagraficaUrl((int) $fornitore->id) }}" class="inline-flex items-center rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-200">Apri vecchia scheda (immutata)</a> <a href="{{ $this->getFornitoreAnagraficaUrl((int) $fornitore->id) }}" class="inline-flex items-center rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-200">Apri vecchia scheda (immutata)</a>
</div> </div>
</div>
<div class="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="rounded-lg border p-3 text-xs">
<div class="mb-2 text-xs font-semibold text-gray-700">Anagrafica fornitore</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<div class="md:col-span-2">
<label class="mb-1 block font-medium text-gray-700">Codice univoco</label>
<input type="text" value="{{ $fornitore->codice_univoco ?: '-' }}" readonly class="w-full rounded-md border-gray-300 bg-gray-50 text-xs text-gray-600">
</div>
<div>
<label class="mb-1 block font-medium text-gray-700">Titolo</label>
<input type="text" wire:model.defer="editTitolo" class="w-full rounded-md border-gray-300 text-xs" placeholder="Egr.">
</div>
<div>
<label class="mb-1 block font-medium text-gray-700">Ragione sociale</label>
<input type="text" wire:model.defer="editRagioneSociale" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block font-medium text-gray-700">Nome</label>
<input type="text" wire:model.defer="editNome" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block font-medium text-gray-700">Cognome</label>
<input type="text" wire:model.defer="editCognome" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block font-medium text-gray-700">P.IVA</label>
<input type="text" wire:model.defer="editPartitaIva" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block font-medium text-gray-700">Codice fiscale</label>
<input type="text" wire:model.defer="editCodiceFiscale" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div class="md:col-span-2">
<label class="mb-1 block font-medium text-gray-700">Sito web</label>
<input type="text" wire:model.defer="editSitoWeb" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div class="md:col-span-2">
<label class="mb-1 block font-medium text-gray-700">Tag</label>
<input type="text" wire:model.defer="editTags" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div class="md:col-span-2">
<label class="mb-1 block font-medium text-gray-700">Note</label>
<textarea wire:model.defer="editNote" rows="3" class="w-full rounded-md border-gray-300 text-xs"></textarea>
</div>
<div class="md:col-span-2 text-gray-500">Indirizzo: {{ $fornitore->indirizzo_completo ?: '-' }}</div>
</div>
<button type="button" wire:click="saveSchedaAnagrafica" class="mt-3 inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Salva anagrafica</button>
</div>
<div class="rounded-lg border p-3 text-xs">
<div class="mb-2 text-xs font-semibold text-gray-700">Contatti e pagamenti</div>
<div class="grid grid-cols-1 gap-2">
<div>
<label class="mb-1 block font-medium text-gray-700">Email fornitore</label>
<input type="email" wire:model.defer="editEmail" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block font-medium text-gray-700">PEC fornitore</label>
<input type="email" wire:model.defer="editPec" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block font-medium text-gray-700">Telefono</label>
<input type="text" wire:model.defer="editTelefono" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block font-medium text-gray-700">Cellulare</label>
<input type="text" wire:model.defer="editCellulare" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div><span class="font-medium">IBAN:</span> {{ $fornitore->iban ?: '-' }}</div>
<div><span class="font-medium">BIC:</span> {{ $fornitore->bic ?: '-' }}</div>
<div><span class="font-medium">Intestazione conto:</span> {{ $fornitore->intestazione_cc_esatta ?: '-' }}</div>
<div><span class="font-medium">Modalita pagamento:</span> {{ $fornitore->modalita_pagamento_predefinita ?: '-' }}</div>
</div>
<button type="button" wire:click="saveSchedaAnagrafica" class="mt-3 inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Salva contatti</button>
<div class="mt-3 border-t pt-3">
<div class="mb-1 text-xs font-semibold text-gray-700">Contatti rubrica collegata</div>
@if($fornitore->rubrica)
<div class="grid grid-cols-1 gap-1">
<div><span class="font-medium">Codice rubrica:</span> {{ $fornitore->rubrica->codice_univoco ?: '-' }}</div>
<div><span class="font-medium">Email rubrica:</span> {{ $fornitore->rubrica->email ?: '-' }}</div>
<div><span class="font-medium">PEC rubrica:</span> {{ $fornitore->rubrica->pec ?: '-' }}</div>
<div><span class="font-medium">Tel. ufficio:</span> {{ $fornitore->rubrica->telefono_ufficio ?: '-' }}</div>
<div><span class="font-medium">Tel. cellulare:</span> {{ $fornitore->rubrica->telefono_cellulare ?: '-' }}</div>
</div>
@else
<div class="text-gray-500">Nessuna rubrica collegata.</div>
@endif
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="rounded-lg border p-3"> <div class="rounded-lg border p-3">
@ -160,12 +317,215 @@ class="mt-1 w-full rounded-lg border-gray-300 text-sm"
<button type="button" wire:click="saveSchedaBancaria" class="mt-3 inline-flex items-center rounded-md bg-blue-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600">Salva conto</button> <button type="button" wire:click="saveSchedaBancaria" class="mt-3 inline-flex items-center rounded-md bg-blue-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600">Salva conto</button>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="rounded-lg border p-3">
<div class="mb-2 text-xs font-semibold text-gray-700">Aggiungi dipendente rapido (stessa scheda)</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">Nome *</label>
<input type="text" wire:model.defer="newDipendenteNome" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">Cognome</label>
<input type="text" wire:model.defer="newDipendenteCognome" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">Email</label>
<input type="email" wire:model.defer="newDipendenteEmail" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">Telefono</label>
<input type="text" wire:model.defer="newDipendenteTelefono" class="w-full rounded-md border-gray-300 text-xs">
</div>
</div>
<button type="button" wire:click="addDipendenteManuale" class="mt-3 inline-flex items-center rounded-md bg-slate-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-600">Aggiungi dipendente</button>
</div>
<div class="rounded-lg border p-3">
<div class="mb-2 text-xs font-semibold text-gray-700">Dipendenti collegati</div>
<div class="max-h-[260px] overflow-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-2 text-left">Nominativo</th>
<th class="border px-2 py-2 text-left">Contatti</th>
</tr>
</thead>
<tbody>
@forelse($this->dipendentiRows as $d)
<tr>
<td class="border px-2 py-2">{{ trim(($d->nome ?? '') . ' ' . ($d->cognome ?? '')) ?: '-' }}</td>
<td class="border px-2 py-2">{{ $d->email ?: '-' }}<div class="text-gray-500">{{ $d->telefono ?: '-' }}</div></td>
</tr>
@empty
<tr><td colspan="2" class="border px-2 py-3 text-center text-gray-500">Nessun dipendente collegato.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@else
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-xs text-amber-800">
<div>Seleziona prima un fornitore dalla Tab 1 e usa il pulsante "Apri scheda inline".</div>
<div class="mt-2">
<button type="button" wire:click="apriElenco" class="inline-flex items-center rounded-md bg-amber-100 px-3 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-200">Torna a Tab 1</button>
</div>
</div>
<div class="rounded-lg border p-4">
<div class="mb-2 text-xs font-semibold text-gray-700">Nuovo fornitore rapido</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">Ragione sociale</label>
<input type="text" wire:model.defer="newFornitoreRagioneSociale" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">Email</label>
<input type="email" wire:model.defer="newFornitoreEmail" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">Nome</label>
<input type="text" wire:model.defer="newFornitoreNome" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">Cognome</label>
<input type="text" wire:model.defer="newFornitoreCognome" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">Telefono</label>
<input type="text" wire:model.defer="newFornitoreTelefono" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">P.IVA</label>
<input type="text" wire:model.defer="newFornitorePartitaIva" class="w-full rounded-md border-gray-300 text-xs">
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-700">Codice fiscale</label>
<input type="text" wire:model.defer="newFornitoreCodiceFiscale" class="w-full rounded-md border-gray-300 text-xs">
</div>
</div>
<button type="button" wire:click="createFornitoreRapido" class="mt-3 inline-flex items-center rounded-md bg-slate-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-600">Crea fornitore</button>
</div>
</div>
@endif
</div>
@else
@php($fornitore = $this->selectedFornitore)
<div class="rounded-xl border bg-white p-4">
@if($fornitore)
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-sm font-semibold">Dipendenti collegati: {{ $this->getFornitoreLabel($fornitore) }}</div>
<div class="text-xs text-gray-500">Nominativi rubrica agganciati al fornitore (modello come stabile -> condomini/inquilini).</div>
</div>
<button type="button" wire:click="apriElenco" class="inline-flex items-center rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-200">Torna a elenco</button>
</div>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div class="rounded-lg border p-3">
<div class="mb-2 text-xs font-semibold text-gray-700">Dipendenti gia collegati</div>
<div class="overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-2 text-left">Nominativo</th>
<th class="border px-2 py-2 text-left">Contatti</th>
<th class="border px-2 py-2 text-left">Stato</th>
</tr>
</thead>
<tbody>
@forelse($this->dipendentiRows as $d)
<tr>
<td class="border px-2 py-2">
<div class="font-medium">{{ trim(($d->nome ?? '') . ' ' . ($d->cognome ?? '')) ?: '-' }}</div>
<div class="text-[11px] text-gray-500">#{{ (int) $d->id }}</div>
</td>
<td class="border px-2 py-2">
<div>{{ $d->email ?: '-' }}</div>
<div class="text-gray-500">{{ $d->telefono ?: '-' }}</div>
</td>
<td class="border px-2 py-2">
@if((bool) ($d->attivo ?? false))
<span class="inline-flex items-center rounded-md bg-emerald-100 px-2 py-1 text-[11px] font-medium text-emerald-800">Attivo</span>
@else
<span class="inline-flex items-center rounded-md bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-700">Disattivo</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="3" class="border px-2 py-4 text-center text-gray-500">Nessun dipendente collegato.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="rounded-lg border p-3">
<div class="mb-2 text-xs font-semibold text-gray-700">Rubrica nominativi da agganciare</div>
<div class="mb-3">
<input
type="text"
wire:model.live.debounce.300ms="dipendentiRubricaSearch"
placeholder="Cerca in rubrica: nome, cognome, email, telefono, CF"
class="w-full rounded-md border-gray-300 text-xs"
>
</div>
<div class="max-h-[420px] overflow-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-2 text-left">Rubrica</th>
<th class="border px-2 py-2 text-left">Contatti</th>
<th class="border px-2 py-2 text-left">Azione</th>
</tr>
</thead>
<tbody>
@forelse($this->rubricaDipendentiCandidates as $r)
<tr>
<td class="border px-2 py-2">
<div class="font-medium">{{ trim(($r->nome ?? '') . ' ' . ($r->cognome ?? '')) ?: ($r->ragione_sociale ?: '-') }}</div>
<div class="text-[11px] text-gray-500">Rubrica #{{ (int) $r->id }} · CF {{ $r->codice_fiscale ?: '-' }}</div>
</td>
<td class="border px-2 py-2">
<div>{{ $r->email ?: '-' }}</div>
<div class="text-gray-500">{{ $r->telefono_cellulare ?: ($r->telefono_ufficio ?: '-') }}</div>
</td>
<td class="border px-2 py-2">
<button
type="button"
wire:click="addDipendenteFromRubrica({{ (int) $r->id }})"
class="inline-flex items-center rounded-md bg-slate-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-slate-600"
>
Aggancia
</button>
</td>
</tr>
@empty
<tr>
<td colspan="3" class="border px-2 py-4 text-center text-gray-500">Nessun nominativo rubrica trovato con i filtri correnti.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@else @else
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-xs text-amber-800"> <div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-xs text-amber-800">
Seleziona prima un fornitore dalla Tab 1 e usa il pulsante "Apri scheda inline". <div>Per usare la Tab 3 seleziona prima un fornitore dalla Tab 1.</div>
<div class="mt-2">
<button type="button" wire:click="apriElenco" class="inline-flex items-center rounded-md bg-amber-100 px-3 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-200">Torna a Tab 1</button>
</div>
</div> </div>
@endif @endif
</div> </div>
@endif @endif
</div> </div>
</x-filament-panels::page> </x-filament-panels::page>
@endif

View File

@ -0,0 +1,155 @@
<div class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Centralino studio e interni collaboratori</div>
<div class="mb-3 text-xs text-gray-500">I numeri studio sono salvati nella scheda amministratore. Gli interni PBX dei collaboratori sono usati per instradamento SMDR/post-it.</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-4">
<input type="text" wire:model.defer="data.impostazioni.centralino.numero_principale" class="rounded-md border-gray-300 text-xs" placeholder="Numero principale centralino" />
<input type="text" wire:model.defer="data.impostazioni.centralino.numero_backup" class="rounded-md border-gray-300 text-xs" placeholder="Numero backup" />
<input type="text" wire:model.defer="data.impostazioni.centralino.numero_emergenza" class="rounded-md border-gray-300 text-xs" placeholder="Numero emergenza" />
<input type="text" wire:model.defer="data.impostazioni.centralino.note_instradamento" class="rounded-md border-gray-300 text-xs" placeholder="Note instradamento" />
</div>
<div class="mt-3 overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-1.5 text-left">Collaboratore</th>
<th class="border px-2 py-1.5 text-left">Email</th>
<th class="border px-2 py-1.5 text-left">Ruoli</th>
<th class="border px-2 py-1.5 text-left">Interno PBX</th>
<th class="border px-2 py-1.5 text-left">Flags PBX</th>
<th class="border px-2 py-1.5 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($this->collaboratoriCentralinoRows as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5">{{ $row['name'] }}</td>
<td class="border px-2 py-1.5">{{ $row['email'] }}</td>
<td class="border px-2 py-1.5">{{ count($row['ruoli']) > 0 ? implode(', ', $row['ruoli']) : '-' }}</td>
<td class="border px-2 py-1.5">
<input type="text" wire:model.defer="collaboratorePbxExtension.{{ (int) $row['user_id'] }}" class="w-24 rounded-md border-gray-300 text-xs" placeholder="Es. 101" />
</td>
<td class="border px-2 py-1.5">
popup={{ $row['pbx_popup_enabled'] ? 'on' : 'off' }} · click2call={{ $row['pbx_click_to_call_enabled'] ? 'on' : 'off' }}
</td>
<td class="border px-2 py-1.5">
<x-filament::button size="xs" color="gray" type="button" wire:click="salvaInternoCollaboratore({{ (int) $row['user_id'] }})">Salva interno</x-filament::button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="border px-2 py-3 text-center text-gray-500">Nessun collaboratore assegnato agli stabili di questo amministratore.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Operazioni distribuzione, backup e sincronizzazione</div>
<div class="flex flex-wrap items-center gap-2">
<x-filament::button type="button" color="warning" wire:click="runProductionUpgrade">Aggiorna produzione</x-filament::button>
<x-filament::button type="button" color="primary" wire:click="runBuildProdUpdatePackage">Crea pacchetto aggiornamento</x-filament::button>
<x-filament::button type="button" color="gray" wire:click="downloadLastUpdatePackage">Scarica ultimo pacchetto</x-filament::button>
<x-filament::button type="button" color="gray" wire:click="reviewUsersToEnable">Controlla nuovi utenti</x-filament::button>
<x-filament::button type="button" color="success" wire:click="runBackupProductionData">Backup produzione</x-filament::button>
<x-filament::button type="button" color="success" wire:click="runBackupModifiedDataToGdrive">Backup incrementale dati su Drive</x-filament::button>
<x-filament::button type="button" color="info" wire:click="runGoogleDriveCheck">Verifica Google Drive</x-filament::button>
<x-filament::button type="button" color="info" wire:click="runRestoreDataNonDistruttivo">Restore dati non distruttivo</x-filament::button>
<x-filament::button type="button" color="primary" wire:click="runSyncProdUpdate">Sync aggiornamenti + archivi</x-filament::button>
<x-filament::button type="button" color="success" wire:click="connectGoogle">Collega Google</x-filament::button>
<x-filament::button type="button" color="danger" wire:click="disconnectGoogle">Scollega Google</x-filament::button>
</div>
@if(filled($this->lastUpdatePackageName))
<div class="mt-3 rounded-lg border border-primary-200 bg-primary-50 p-3 text-sm text-primary-700">
Ultimo pacchetto aggiornamento: <strong>{{ $this->lastUpdatePackageName }}</strong>
</div>
@endif
@if(filled($this->opsLastOutput))
<div class="mt-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Output operazioni</div>
<pre class="max-h-72 overflow-auto whitespace-pre-wrap text-xs text-gray-800">{{ $this->opsLastOutput }}</pre>
</div>
@endif
</div>
<div class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Nuovi utenti da abilitare (risultato controllo)</div>
<div class="overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-1.5 text-left">ID</th>
<th class="border px-2 py-1.5 text-left">Email</th>
<th class="border px-2 py-1.5 text-left">Nome</th>
<th class="border px-2 py-1.5 text-left">Ruoli</th>
<th class="border px-2 py-1.5 text-left">Flags</th>
<th class="border px-2 py-1.5 text-left">Creato</th>
</tr>
</thead>
<tbody>
@forelse($this->pendingUsersReviewRows as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5">{{ $row['id'] }}</td>
<td class="border px-2 py-1.5">{{ $row['email'] }}</td>
<td class="border px-2 py-1.5">{{ $row['name'] }}</td>
<td class="border px-2 py-1.5">{{ $row['roles'] }}</td>
<td class="border px-2 py-1.5">{{ $row['flags'] }}</td>
<td class="border px-2 py-1.5">{{ $row['created'] }}</td>
</tr>
@empty
<tr>
<td colspan="6" class="border px-2 py-3 text-center text-gray-500">Nessun dato disponibile. Usa il pulsante "Controlla nuovi utenti".</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Gestione accessi gruppo amministratore</div>
<div class="mb-3 text-xs text-gray-500">Reset password temporanee per utenti del gruppo amministratore.</div>
@if(filled($this->lastGeneratedPassword))
<div class="mb-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Ultima password temporanea generata: <span class="font-mono font-semibold">{{ $this->lastGeneratedPassword }}</span>
</div>
@endif
<div class="mb-4 overflow-x-auto">
<div class="mb-1 text-xs font-semibold text-gray-700">Utenti gruppo amministratore</div>
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-1.5 text-left">Nome</th>
<th class="border px-2 py-1.5 text-left">Email</th>
<th class="border px-2 py-1.5 text-left">Ruoli</th>
<th class="border px-2 py-1.5 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($this->accessoAmministratoreRows as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5">{{ $row['name'] }}</td>
<td class="border px-2 py-1.5">{{ $row['email'] }}</td>
<td class="border px-2 py-1.5">{{ count($row['ruoli']) > 0 ? implode(', ', $row['ruoli']) : '-' }}</td>
<td class="border px-2 py-1.5">
<x-filament::button size="xs" color="warning" type="button" wire:click="resetPasswordUtente({{ (int) $row['user_id'] }})">Reset password</x-filament::button>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="border px-2 py-3 text-center text-gray-500">Nessun utente amministratore trovato.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>

View File

@ -4,52 +4,6 @@
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<x-filament::button type="submit">Salva</x-filament::button> <x-filament::button type="submit">Salva</x-filament::button>
<x-filament::button type="button" color="warning" wire:click="runProductionUpgrade">
Aggiorna produzione
</x-filament::button>
<x-filament::button type="button" color="primary" wire:click="runBuildProdUpdatePackage">
Crea pacchetto aggiornamento
</x-filament::button>
<x-filament::button type="button" color="gray" wire:click="downloadLastUpdatePackage">
Scarica ultimo pacchetto
</x-filament::button>
<x-filament::button type="button" color="gray" wire:click="reviewUsersToEnable">
Controlla nuovi utenti
</x-filament::button>
<x-filament::button type="button" color="success" wire:click="runBackupProductionData">
Backup produzione
</x-filament::button>
<x-filament::button type="button" color="success" wire:click="runBackupModifiedDataToGdrive">
Backup incrementale dati su Drive
</x-filament::button>
<x-filament::button type="button" color="info" wire:click="runGoogleDriveCheck">
Verifica Google Drive
</x-filament::button>
<x-filament::button type="button" color="info" wire:click="runRestoreDataNonDistruttivo">
Restore dati non distruttivo
</x-filament::button>
<x-filament::button type="button" color="primary" wire:click="runSyncProdUpdate">
Sync aggiornamenti + archivi
</x-filament::button>
<x-filament::button type="button" color="success" wire:click="connectGoogle">
Collega Google
</x-filament::button>
<x-filament::button type="button" color="danger" wire:click="disconnectGoogle">
Scollega Google
</x-filament::button>
</div> </div>
@if(filled($this->opsLastOutput))
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Output operazioni</div>
<pre class="max-h-80 overflow-auto whitespace-pre-wrap text-xs text-gray-800">{{ $this->opsLastOutput }}</pre>
</div>
@endif
@if(filled($this->lastUpdatePackageName))
<div class="rounded-lg border border-primary-200 bg-primary-50 p-3 text-sm text-primary-700">
Ultimo pacchetto aggiornamento: <strong>{{ $this->lastUpdatePackageName }}</strong>
</div>
@endif
</form> </form>
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -10,11 +10,23 @@
</div> </div>
</div> </div>
<div class="rounded-xl border bg-white p-3">
<div class="flex flex-wrap gap-2">
<button type="button" wire:click="$set('activeTab', 'storico')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'storico' ? 'bg-indigo-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
Storico Post-it
</button>
<button type="button" wire:click="$set('activeTab', 'tecnico')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'tecnico' ? 'bg-indigo-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
Chiamate Tecniche SMDR
</button>
</div>
</div>
@if(! $this->isPostItTableReady()) @if(! $this->isPostItTableReady())
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-800"> <div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-800">
Tabella chiamate non pronta. Esegui le migrazioni per usare la gestione Post-it. Tabella chiamate non pronta. Esegui le migrazioni per usare la gestione Post-it.
</div> </div>
@else @else
@if($activeTab === 'storico')
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4">
<h2 class="mb-3 text-base font-semibold">Storico chiamate recenti</h2> <h2 class="mb-3 text-base font-semibold">Storico chiamate recenti</h2>
@ -28,6 +40,14 @@
{{ $riga->nome_chiamante ?: 'Chiamante non identificato' }} {{ $riga->nome_chiamante ?: 'Chiamante non identificato' }}
@if($riga->telefono) @if($riga->telefono)
- {{ $riga->telefono }} - {{ $riga->telefono }}
@php($rubricaNome = $this->getRubricaNomeByPhone((string) $riga->telefono))
@if($rubricaNome)
- <span class="font-medium text-indigo-700">{{ $rubricaNome }}</span>
@endif
@php($rubricaUrl = $this->getRubricaUrlByPhone((string) $riga->telefono))
@if($rubricaUrl)
- <a href="{{ $rubricaUrl }}" class="text-indigo-700 hover:underline">Apri scheda rubrica</a>
@endif
@endif @endif
@if($riga->stabile) @if($riga->stabile)
- {{ $riga->stabile->denominazione ?: ('Stabile #' . $riga->stabile->id) }} - {{ $riga->stabile->denominazione ?: ('Stabile #' . $riga->stabile->id) }}
@ -73,6 +93,74 @@
@endforelse @endforelse
</div> </div>
</div> </div>
@else
<div class="rounded-xl border bg-white p-4">
<h2 class="mb-1 text-base font-semibold">Elenco tecnico chiamate (1 riga = 1 evento)</h2>
<p class="mb-3 text-xs text-gray-500">Qui vedi in colonne i dati disponibili dal centralino: interno, linea/trunk, numero, durata, costo, utente assegnato, stabile, raw line, ecc.</p>
<div class="overflow-x-auto">
<table class="min-w-full border text-xs">
<thead class="bg-gray-50">
<tr>
<th class="border px-2 py-1 text-left">ID</th>
<th class="border px-2 py-1 text-left">Data/Ora</th>
<th class="border px-2 py-1 text-left">Direzione</th>
<th class="border px-2 py-1 text-left">Interno</th>
<th class="border px-2 py-1 text-left">Linea/CO</th>
<th class="border px-2 py-1 text-left">Numero</th>
<th class="border px-2 py-1 text-left">Nominativo</th>
<th class="border px-2 py-1 text-left">Durata</th>
<th class="border px-2 py-1 text-left">Costo</th>
<th class="border px-2 py-1 text-left">Utente</th>
<th class="border px-2 py-1 text-left">Stabile</th>
<th class="border px-2 py-1 text-left">Post-it</th>
<th class="border px-2 py-1 text-left">Raw</th>
<th class="border px-2 py-1 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($this->chiamateTecniche as $m)
@php($smdr = (array) data_get($m->metadata, 'smdr', []))
@php($cost = trim((string) (($smdr['cost_currency'] ?? '') . ' ' . ($smdr['cost_amount'] ?? ''))))
@php($duration = (string) (($smdr['duration_raw'] ?? '') ?: ($smdr['ring_duration_raw'] ?? '')))
<tr>
<td class="border px-2 py-1">{{ $m->id }}</td>
<td class="border px-2 py-1">{{ optional($m->received_at)->format('d/m/Y H:i:s') }}</td>
<td class="border px-2 py-1">{{ $m->direction }}</td>
<td class="border px-2 py-1">{{ (string) ($m->target_extension ?: ($smdr['extension'] ?? '-')) }}</td>
<td class="border px-2 py-1">{{ (string) ($smdr['co'] ?? '-') }}</td>
<td class="border px-2 py-1">{{ (string) ($m->phone_number ?: ($smdr['dial_number'] ?? '-')) }}</td>
@php($phoneValue = (string) ($m->phone_number ?: ($smdr['dial_number'] ?? '')))
@php($rubricaNomeTech = $this->getRubricaNomeByPhone($phoneValue))
<td class="border px-2 py-1">{{ $rubricaNomeTech ?: '-' }}</td>
<td class="border px-2 py-1">{{ $duration !== '' ? $duration : '-' }}</td>
<td class="border px-2 py-1">{{ $cost !== '' ? $cost : '-' }}</td>
<td class="border px-2 py-1">{{ $m->assignedUser?->name ?: '-' }}</td>
<td class="border px-2 py-1">{{ $m->stabile?->denominazione ?: ($m->stabile_id ? ('#' . $m->stabile_id) : '-') }}</td>
<td class="border px-2 py-1">{{ $m->post_it_id ? ('#' . $m->post_it_id) : '-' }}</td>
<td class="border px-2 py-1 max-w-[340px] truncate" title="{{ $m->message_text }}">{{ $m->message_text }}</td>
<td class="border px-2 py-1">
@php($rubricaUrlTech = $this->getRubricaUrlByPhone($phoneValue))
@if($rubricaUrlTech)
<a href="{{ $rubricaUrlTech }}" class="inline-flex items-center rounded-md border px-2 py-1 text-[11px] text-indigo-700 hover:bg-indigo-50">Apri Rubrica</a>
@endif
@if(!$m->post_it_id)
<x-filament::button size="xs" wire:click="creaPostItDaMessaggio({{ (int) $m->id }})">Crea Post-it</x-filament::button>
@else
<span class="text-gray-500">Collegato</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="14" class="border px-2 py-3 text-center text-gray-500">Nessun evento SMDR disponibile.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
@endif @endif
</div> </div>
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -0,0 +1,10 @@
<x-filament-panels::page>
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="text-base font-semibold">Dashboard Supporto</div>
<div class="mt-1 text-xs text-gray-500">Area tecnica per manutenzione, sincronizzazione Google e debug operativo.</div>
</div>
@livewire(\App\Filament\Widgets\GoogleWorkspaceOverview::class)
</div>
</x-filament-panels::page>

View File

@ -1,4 +1,4 @@
<x-filament-panels::page> <div class="fi-page">
<div class="mx-auto max-w-7xl space-y-3 text-xs"> <div class="mx-auto max-w-7xl space-y-3 text-xs">
<x-filament.components.page-breadcrumbs <x-filament.components.page-breadcrumbs
:breadcrumbs="[ :breadcrumbs="[
@ -27,8 +27,33 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
wire:click="setActiveTab('errori')"
class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $this->activeTab === 'errori' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}"
>
Errori
</button>
<button
type="button"
wire:click="setActiveTab('commit')"
class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $this->activeTab === 'commit' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}"
>
Commit + Note
</button>
<button
type="button"
wire:click="setActiveTab('migliorie')"
class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $this->activeTab === 'migliorie' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}"
>
Migliorie
</button>
</div>
</x-filament::section> </x-filament::section>
@if($this->activeTab === 'errori')
<x-filament::section class="!p-4"> <x-filament::section class="!p-4">
<div class="grid gap-3 lg:grid-cols-3"> <div class="grid gap-3 lg:grid-cols-3">
<div class="rounded-lg border bg-gray-50 p-3 lg:col-span-2"> <div class="rounded-lg border bg-gray-50 p-3 lg:col-span-2">
@ -55,9 +80,46 @@
<x-filament::button size="sm" wire:click="runUpdate" :disabled="!$this->canRunUpdate()"> <x-filament::button size="sm" wire:click="runUpdate" :disabled="!$this->canRunUpdate()">
Lancia aggiornamento Lancia aggiornamento
</x-filament::button> </x-filament::button>
<x-filament::button size="sm" color="warning" wire:click="runUpdateFallback" :disabled="!$this->canRunUpdate()">
Lancia aggiornamento (riserva)
</x-filament::button>
<x-filament::button size="sm" color="info" wire:click="runUpdateConnectivityCheck">
Check connettivita update
</x-filament::button>
<x-filament::button size="sm" color="gray" wire:click="reloadDashboardData"> <x-filament::button size="sm" color="gray" wire:click="reloadDashboardData">
Aggiorna dati pagina Aggiorna dati pagina
</x-filament::button> </x-filament::button>
<x-filament::button size="sm" color="gray" wire:click="runMaintenanceOptimizeClear" :disabled="!$this->canRunUpdate()">
Pulisci cache remoto
</x-filament::button>
<x-filament::button size="sm" color="gray" wire:click="runMaintenanceViewRebuild" :disabled="!$this->canRunUpdate()">
Ricompila view remoto
</x-filament::button>
<x-filament::button size="sm" color="gray" wire:click="refreshUpdateProgress" :disabled="!$this->updateInProgress">
Aggiorna avanzamento
</x-filament::button>
</div>
@if($this->updateInProgress)
<div class="mt-3 rounded-lg border bg-white p-3" wire:poll.2s="refreshUpdateProgress">
@else
<div class="mt-3 rounded-lg border bg-white p-3">
@endif
<div class="mb-1 flex items-center justify-between text-[11px] text-gray-600">
<span class="font-semibold">Avanzamento aggiornamento</span>
<span class="font-mono">{{ (int) $this->updateProgressPercent }}%</span>
</div>
<div class="h-2 w-full overflow-hidden rounded bg-gray-200">
<div class="h-2 {{ $this->updateProgressStatus === 'failed' ? 'bg-rose-500' : 'bg-emerald-500' }}" style="width: {{ max(0, min(100, (int) $this->updateProgressPercent)) }}%"></div>
</div>
<div class="mt-1 text-[11px] text-gray-600">{{ $this->updateProgressMessage }}</div>
<div class="mt-1 text-[10px] text-gray-500">
Job: <span class="font-mono">{{ $this->updateJobId ?? '-' }}</span>
· Stato: <span class="font-semibold">{{ $this->updateProgressStatus }}</span>
@if($this->updateInProgress)
· in esecuzione
@endif
</div>
</div> </div>
@if(! $this->canRunUpdate()) @if(! $this->canRunUpdate())
@ -79,7 +141,29 @@
<span class="rounded bg-rose-100 px-1.5 py-0.5 font-semibold text-rose-800">{{ $this->lastUpdateExitCode }} ERRORE</span> <span class="rounded bg-rose-100 px-1.5 py-0.5 font-semibold text-rose-800">{{ $this->lastUpdateExitCode }} ERRORE</span>
@endif @endif
</div> </div>
@if(filled($this->lastPlannedUpdateSummary))
<div class="rounded bg-slate-50 px-2 py-1 text-[10px] text-slate-600">
Piano usato: {{ $this->lastPlannedUpdateSummary }}
</div> </div>
@endif
</div>
</div>
</div>
<div class="mt-3 rounded-lg border bg-blue-50 p-3">
<div class="mb-1 text-[11px] font-semibold text-blue-900">Aggiornamenti pianificati (prima del lancio)</div>
<ul class="list-disc space-y-1 pl-4 text-[11px] text-blue-900">
@foreach($this->updatePlannedSteps as $step)
<li>{{ $step }}</li>
@endforeach
</ul>
<div class="mt-3 rounded border border-blue-200 bg-white p-2 text-[11px] text-blue-900">
<div class="font-semibold">Ripristino differenziale disponibile</div>
<div class="mt-1">Ripristino singola scrittura:</div>
<pre class="mt-1 overflow-auto whitespace-pre-wrap rounded bg-slate-950 p-2 text-[10px] text-slate-100">php artisan netgescon:restore-record &lt;tabella&gt; &lt;pk&gt; --apply</pre>
<div class="mt-1">Preview senza scrivere:</div>
<pre class="mt-1 overflow-auto whitespace-pre-wrap rounded bg-slate-950 p-2 text-[10px] text-slate-100">php artisan netgescon:restore-record &lt;tabella&gt; &lt;pk&gt;</pre>
</div> </div>
</div> </div>
@ -89,11 +173,153 @@
<pre class="max-h-64 overflow-auto whitespace-pre-wrap text-[11px] leading-relaxed text-slate-100">{{ $this->lastUpdateOutput }}</pre> <pre class="max-h-64 overflow-auto whitespace-pre-wrap text-[11px] leading-relaxed text-slate-100">{{ $this->lastUpdateOutput }}</pre>
</div> </div>
@endif @endif
@if(filled($this->lastUpdateIssue))
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-[11px] text-amber-900">
<div class="font-semibold">Diagnostica automatica ultimo errore</div>
<div class="mt-1">{{ $this->lastUpdateIssue }}</div>
</div>
@endif
@if(filled($this->lastConnectivityCheckOutput))
<div class="mt-3 rounded-lg border bg-slate-100 p-3">
<div class="mb-1 text-[11px] font-semibold text-slate-700">Output check connettivita endpoint update</div>
<pre class="max-h-52 overflow-auto whitespace-pre-wrap text-[11px] text-slate-700">{{ $this->lastConnectivityCheckOutput }}</pre>
</div>
@endif
</x-filament::section> </x-filament::section>
<x-filament::section class="!p-4">
<div class="mb-2 text-xs font-semibold text-gray-700">Errori runtime, supporto e pregressi (laravel.log)</div>
<div class="mb-2 text-[11px] text-gray-500">Raccolta unificata da: <code>runtime-errors.ndjson</code>, <code>support-events.ndjson</code>, <code>laravel.log</code>, <code>docs/support/online-runtime-errors.ndjson</code> (feed Git da online).</div>
<div class="mb-2 flex flex-wrap items-center gap-2 text-[11px]">
<span class="rounded bg-rose-100 px-2 py-1 font-semibold text-rose-800">Aperti: {{ (int) $this->openBugCount }}</span>
<span class="rounded bg-emerald-100 px-2 py-1 font-semibold text-emerald-800">Risolti: {{ (int) $this->resolvedBugCount }}</span>
<span class="ml-2 text-gray-500">Filtro:</span>
<button type="button" wire:click="setBugFilterStatus('all')" class="rounded px-2 py-1 {{ $this->bugFilterStatus === 'all' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700' }}">Tutti</button>
<button type="button" wire:click="setBugFilterStatus('open')" class="rounded px-2 py-1 {{ $this->bugFilterStatus === 'open' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700' }}">Aperti</button>
<button type="button" wire:click="setBugFilterStatus('resolved')" class="rounded px-2 py-1 {{ $this->bugFilterStatus === 'resolved' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700' }}">Risolti</button>
</div>
<div class="mb-3 grid gap-3 lg:grid-cols-2">
<div class="rounded-lg border bg-gray-50 p-3">
<div class="mb-2 text-[11px] font-semibold text-gray-700">Aggiungi BUG manuale</div>
<label class="mb-2 block">
<span class="mb-1 block text-[11px] font-medium">Titolo</span>
<input type="text" wire:model.defer="manualBugTitle" class="w-full rounded-md border-gray-300 text-xs" placeholder="Es: Duplicati ricerca rubrica universale" />
</label>
<label class="block">
<span class="mb-1 block text-[11px] font-medium">Dettagli</span>
<textarea wire:model.defer="manualBugDetails" rows="3" class="w-full rounded-md border-gray-300 text-xs" placeholder="URL, cosa succede, risultato atteso, eventuale stabile/codice"></textarea>
</label>
<div class="mt-2">
<x-filament::button size="sm" wire:click="createManualBug">Crea BUG manuale</x-filament::button>
</div>
</div>
<div class="rounded-lg border bg-gray-50 p-3">
<div class="mb-2 text-[11px] font-semibold text-gray-700">Nota risoluzione BUG</div>
<div class="mb-2 text-[11px] text-gray-500">
BUG selezionato: <span class="font-mono font-semibold">{{ $this->selectedBugFingerprint ? 'selezionato' : 'nessuno' }}</span>
</div>
<label class="block">
<span class="mb-1 block text-[11px] font-medium">Nota</span>
<textarea wire:model.defer="bugResolutionNote" rows="4" class="w-full rounded-md border-gray-300 text-xs" placeholder="Es: risolto con fix parser X, migration Y, deploy Z"></textarea>
</label>
<div class="mt-2">
<x-filament::button size="sm" color="primary" wire:click="saveBugResolutionNote">Salva nota risoluzione</x-filament::button>
</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full border-collapse border text-[11px]">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-1.5 text-left">BUG #</th>
<th class="border px-2 py-1.5 text-left">Stato</th>
<th class="border px-2 py-1.5 text-left">Timestamp</th>
<th class="border px-2 py-1.5 text-left">Origine</th>
<th class="border px-2 py-1.5 text-left">Tipo</th>
<th class="border px-2 py-1.5 text-left">Occorrenze</th>
<th class="border px-2 py-1.5 text-left">Messaggio</th>
<th class="border px-2 py-1.5 text-left">Contesto</th>
<th class="border px-2 py-1.5 text-left">Nota risoluzione</th>
<th class="border px-2 py-1.5 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($this->runtimeErrors as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5 font-mono font-semibold">{{ $row['bug_code'] }}</td>
<td class="border px-2 py-1.5">
@if(($row['bug_status'] ?? 'open') === 'resolved')
<span class="rounded bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-800">risolto</span>
@else
<span class="rounded bg-rose-100 px-1.5 py-0.5 font-semibold text-rose-800">aperto</span>
@endif
</td>
<td class="border px-2 py-1.5 whitespace-nowrap">{{ $row['timestamp'] }}</td>
<td class="border px-2 py-1.5">{{ $row['source'] ?? '-' }}</td>
<td class="border px-2 py-1.5">{{ $row['type'] }}</td>
<td class="border px-2 py-1.5">{{ (int) ($row['occurrences'] ?? 1) }}</td>
<td class="border px-2 py-1.5">{{ $row['message'] }}</td>
<td class="border px-2 py-1.5">
<div class="max-w-[420px] break-words text-[10px] text-gray-700">{{ $row['context'] !== '' ? \Illuminate\Support\Str::limit($row['context'], 220) : '-' }}</div>
</td>
<td class="border px-2 py-1.5">
<div class="max-w-[260px] break-words text-[10px] text-gray-700">{{ $row['resolution_note'] !== '' ? \Illuminate\Support\Str::limit($row['resolution_note'], 160) : '-' }}</div>
@if(($row['bug_status'] ?? 'open') === 'resolved' && filled($row['resolved_at']))
<div class="mt-1 text-[10px] text-emerald-700">risolto: {{ $row['resolved_at'] }}</div>
@endif
</td>
<td class="border px-2 py-1.5">
<x-filament::button size="xs" color="gray" type="button" wire:click="selectBugForResolution('{{ $row['fingerprint'] }}')">Nota</x-filament::button>
@if(($row['bug_status'] ?? 'open') === 'resolved')
<x-filament::button size="xs" color="warning" type="button" wire:click="reopenBug('{{ $row['fingerprint'] }}')">Riapri</x-filament::button>
@else
<x-filament::button size="xs" color="success" type="button" wire:click="markBugResolved('{{ $row['fingerprint'] }}')">Segna risolto</x-filament::button>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="10" class="border px-2 py-3 text-center text-gray-500">Nessun errore registrato negli ultimi eventi.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-filament::section>
@endif
@if($this->activeTab === 'commit')
<x-filament::section class="!p-4"> <x-filament::section class="!p-4">
<div class="mb-2 text-xs font-semibold text-gray-700">Ultimi commit (descrizioni)</div> <div class="mb-2 text-xs font-semibold text-gray-700">Ultimi commit (descrizioni)</div>
<div class="mb-3 rounded-lg border bg-gray-50 p-3">
<div class="mb-2 text-[11px] font-semibold text-gray-700">Aggiungi nota operativa a un commit</div>
<div class="grid gap-2 sm:grid-cols-2">
<label class="block">
<span class="mb-1 block text-[11px] font-medium">Commit</span>
<select wire:model="selectedCommitHash" wire:change="onSelectedCommitHashUpdated" class="w-full rounded-md border-gray-300 text-xs">
<option value="">Seleziona commit</option>
@foreach($this->latestCommits as $c)
<option value="{{ $c['hash'] }}">{{ $c['short_hash'] }} - {{ $c['message'] }}</option>
@endforeach
</select>
</label>
<label class="block sm:col-span-2">
<span class="mb-1 block text-[11px] font-medium">Nota aggiuntiva</span>
<textarea wire:model.defer="newCommitNote" rows="3" class="w-full rounded-md border-gray-300 text-xs" placeholder="Es: Fix runtime error page X, aggiunta diagnostica update timeout, ecc."></textarea>
</label>
</div>
<div class="mt-2">
<x-filament::button size="sm" wire:click="saveCommitNote">Salva nota commit</x-filament::button>
</div>
</div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full border-collapse border text-[11px]"> <table class="min-w-full border-collapse border text-[11px]">
<thead> <thead>
@ -102,6 +328,7 @@
<th class="border px-2 py-1.5 text-left">Commit</th> <th class="border px-2 py-1.5 text-left">Commit</th>
<th class="border px-2 py-1.5 text-left">Autore</th> <th class="border px-2 py-1.5 text-left">Autore</th>
<th class="border px-2 py-1.5 text-left">Descrizione</th> <th class="border px-2 py-1.5 text-left">Descrizione</th>
<th class="border px-2 py-1.5 text-left">Nota aggiuntiva</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -111,16 +338,23 @@
<td class="border px-2 py-1.5 font-mono">{{ $commit['short_hash'] }}</td> <td class="border px-2 py-1.5 font-mono">{{ $commit['short_hash'] }}</td>
<td class="border px-2 py-1.5">{{ $commit['author'] }}</td> <td class="border px-2 py-1.5">{{ $commit['author'] }}</td>
<td class="border px-2 py-1.5">{{ $commit['message'] }}</td> <td class="border px-2 py-1.5">{{ $commit['message'] }}</td>
<td class="border px-2 py-1.5">
@php
$note = $this->getCommitNote($commit['hash']);
@endphp
<div class="max-w-[380px] break-words text-[11px] {{ $note !== '' ? 'text-slate-800' : 'text-gray-400' }}">{{ $note !== '' ? $note : '-' }}</div>
</td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="4" class="border px-2 py-3 text-center text-gray-500">Nessun commit disponibile.</td> <td colspan="5" class="border px-2 py-3 text-center text-gray-500">Nessun commit disponibile.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
</div> </div>
</x-filament::section> </x-filament::section>
@endif
@php @php
$path = base_path('docs/NETGESCON-MODIFICHE.md'); $path = base_path('docs/NETGESCON-MODIFICHE.md');
@ -144,6 +378,50 @@
$gitRawHtml = str_replace(array_keys($replacements), array_values($replacements), $gitRawHtml); $gitRawHtml = str_replace(array_keys($replacements), array_values($replacements), $gitRawHtml);
@endphp @endphp
@if($this->activeTab === 'migliorie')
<x-filament::section class="!p-4">
<div class="mb-2 text-xs font-semibold text-gray-700">Pagine nuove o rese piu funzionali (ultimi commit)</div>
<div class="overflow-x-auto">
<table class="min-w-full border-collapse border text-[11px]">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-1.5 text-left">Pagina</th>
<th class="border px-2 py-1.5 text-left">Percorso</th>
<th class="border px-2 py-1.5 text-left">Commit</th>
<th class="border px-2 py-1.5 text-left">Descrizione</th>
</tr>
</thead>
<tbody>
@forelse($this->recentFilamentPages as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5 font-medium">{{ $row['page'] }}</td>
<td class="border px-2 py-1.5 font-mono text-[10px]">{{ $row['path'] }}</td>
<td class="border px-2 py-1.5 font-mono">{{ $row['commit'] }}</td>
<td class="border px-2 py-1.5">{{ $row['message'] }}</td>
</tr>
@empty
<tr>
<td colspan="4" class="border px-2 py-3 text-center text-gray-500">Nessuna pagina recente rilevata dai commit.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-filament::section>
<x-filament::section class="!p-4">
<div class="mb-2 text-xs font-semibold text-gray-700">Migliorie e funzionalita aggiunte (estratte dal registro)</div>
<div class="rounded-lg border bg-gray-50 p-3">
<ul class="list-disc space-y-1 pl-4 text-[11px] text-gray-700">
@forelse($this->functionalHighlights as $item)
<li>{{ $item }}</li>
@empty
<li>Nessuna miglioria rilevata automaticamente nel file modifiche.</li>
@endforelse
</ul>
</div>
</x-filament::section>
<x-filament::section class="!p-4"> <x-filament::section class="!p-4">
<div class="mb-2 text-xs font-semibold text-gray-700">Registro funzionale (manuale)</div> <div class="mb-2 text-xs font-semibold text-gray-700">Registro funzionale (manuale)</div>
<div class="prose prose-sm max-w-none text-[11px] leading-relaxed"> <div class="prose prose-sm max-w-none text-[11px] leading-relaxed">
@ -157,5 +435,6 @@
{!! $gitRawHtml !!} {!! $gitRawHtml !!}
</div> </div>
</x-filament::section> </x-filament::section>
@endif
</div> </div>
</x-filament-panels::page> </div>

View File

@ -36,6 +36,33 @@
</div> </div>
@if($activeTab === 'elenco') @if($activeTab === 'elenco')
<div class="mt-3 rounded-lg border bg-slate-50 p-3">
<div class="mb-2 text-xs font-semibold text-slate-700">Categorie ticket</div>
<div class="grid gap-2 md:grid-cols-3">
<label class="block text-xs">
<span class="mb-1 block font-medium">Categoria esistente</span>
<select wire:model.live="selectedCategoriaId" class="w-full rounded-lg border-gray-300 text-sm">
<option value="">Seleziona categoria</option>
@foreach($categorieOptions as $cat)
<option value="{{ (int) $cat['id'] }}">{{ $cat['nome'] }}</option>
@endforeach
</select>
</label>
<label class="block text-xs">
<span class="mb-1 block font-medium">Nome categoria</span>
<input type="text" wire:model.defer="categoriaNome" class="w-full rounded-lg border-gray-300 text-sm" placeholder="Es. Ascensore" />
</label>
<label class="block text-xs">
<span class="mb-1 block font-medium">Descrizione</span>
<input type="text" wire:model.defer="categoriaDescrizione" class="w-full rounded-lg border-gray-300 text-sm" placeholder="Descrizione breve" />
</label>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-filament::button size="sm" color="success" wire:click="creaCategoriaTicket">Aggiungi categoria</x-filament::button>
<x-filament::button size="sm" color="warning" wire:click="aggiornaCategoriaTicket">Modifica categoria selezionata</x-filament::button>
</div>
</div>
<div class="mt-3 overflow-x-auto"> <div class="mt-3 overflow-x-auto">
<table class="min-w-full border-collapse border text-xs"> <table class="min-w-full border-collapse border text-xs">
<thead> <thead>
@ -43,6 +70,7 @@
<th class="border px-2 py-2 text-left">ID</th> <th class="border px-2 py-2 text-left">ID</th>
<th class="border px-2 py-2 text-left">Titolo</th> <th class="border px-2 py-2 text-left">Titolo</th>
<th class="border px-2 py-2 text-left">Categoria</th> <th class="border px-2 py-2 text-left">Categoria</th>
<th class="border px-2 py-2 text-left">Tipo intervento</th>
<th class="border px-2 py-2 text-left">Assegnato a</th> <th class="border px-2 py-2 text-left">Assegnato a</th>
<th class="border px-2 py-2 text-left">Priorita</th> <th class="border px-2 py-2 text-left">Priorita</th>
<th class="border px-2 py-2 text-left">Stato</th> <th class="border px-2 py-2 text-left">Stato</th>
@ -59,12 +87,13 @@
<span>{{ $ticket->titolo }}</span> <span>{{ $ticket->titolo }}</span>
@if(((int) ($ticket->attachments_count ?? 0)) > 0) @if(((int) ($ticket->attachments_count ?? 0)) > 0)
<span title="Presenza allegati" class="inline-flex items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800"> <span title="Presenza allegati" class="inline-flex items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800">
ATT {{ (int) $ticket->attachments_count }} Allegati {{ (int) $ticket->attachments_count }}
</span> </span>
@endif @endif
</div> </div>
</td> </td>
<td class="border px-2 py-2">{{ optional($ticket->categoriaTicket)->nome ?: '-' }}</td> <td class="border px-2 py-2">{{ optional($ticket->categoriaTicket)->nome ?: '-' }}</td>
<td class="border px-2 py-2">{{ $this->getTipoInterventoLabel($ticket) }}</td>
<td class="border px-2 py-2">{{ optional($ticket->assegnatoAUser)->name ?: '-' }}</td> <td class="border px-2 py-2">{{ optional($ticket->assegnatoAUser)->name ?: '-' }}</td>
<td class="border px-2 py-2">{{ $ticket->priorita }}</td> <td class="border px-2 py-2">{{ $ticket->priorita }}</td>
<td class="border px-2 py-2">{{ $ticket->stato }}</td> <td class="border px-2 py-2">{{ $ticket->stato }}</td>
@ -94,7 +123,7 @@
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="8" class="border px-2 py-4 text-center text-gray-500">Nessun ticket per il filtro selezionato.</td> <td colspan="9" class="border px-2 py-4 text-center text-gray-500">Nessun ticket per il filtro selezionato.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@ -105,6 +134,12 @@
<div class="text-sm font-semibold">Fornitori attivi su ticket aperti</div> <div class="text-sm font-semibold">Fornitori attivi su ticket aperti</div>
<div class="mt-1 text-xs text-gray-500">Mostra solo fornitori con ticket/interventi in corso, con stato operativo e ultimo aggiornamento.</div> <div class="mt-1 text-xs text-gray-500">Mostra solo fornitori con ticket/interventi in corso, con stato operativo e ultimo aggiornamento.</div>
@if(filled($lastGeneratedPassword ?? null))
<div class="mt-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Ultima password temporanea generata: <span class="font-mono font-semibold">{{ $lastGeneratedPassword }}</span>
</div>
@endif
<div class="mt-3 overflow-x-auto"> <div class="mt-3 overflow-x-auto">
<table class="min-w-full border-collapse border text-xs"> <table class="min-w-full border-collapse border text-xs">
<thead> <thead>
@ -114,6 +149,7 @@
<th class="border px-2 py-2 text-left">Ticket attivi</th> <th class="border px-2 py-2 text-left">Ticket attivi</th>
<th class="border px-2 py-2 text-left">Interventi aperti</th> <th class="border px-2 py-2 text-left">Interventi aperti</th>
<th class="border px-2 py-2 text-left">Come procede</th> <th class="border px-2 py-2 text-left">Come procede</th>
<th class="border px-2 py-2 text-left">Accesso fornitore</th>
<th class="border px-2 py-2 text-left">Azioni</th> <th class="border px-2 py-2 text-left">Azioni</th>
</tr> </tr>
</thead> </thead>
@ -133,8 +169,20 @@
@endif @endif
</div> </div>
</td> </td>
<td class="border px-2 py-2 text-[11px]">
<div>Dipendenti: <span class="font-semibold">{{ (int) ($f['dipendenti_count'] ?? 0) }}</span></div>
@if((int) ($f['linked_user_id'] ?? 0) > 0)
<div class="text-emerald-700">Accesso attivo (utente #{{ (int) $f['linked_user_id'] }})</div>
@else
<div class="text-amber-700">Nessun accesso attivo</div>
@endif
</td>
<td class="border px-2 py-2"> <td class="border px-2 py-2">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<button type="button" wire:click="abilitaAccessoFornitoreDaTicket({{ (int) $f['id'] }})" class="inline-flex items-center rounded-md bg-emerald-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-emerald-600">Abilita accesso</button>
@if((int) ($f['linked_user_id'] ?? 0) > 0)
<button type="button" wire:click="resetPasswordFornitoreUser({{ (int) $f['linked_user_id'] }})" class="inline-flex items-center rounded-md bg-amber-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-amber-600">Reset password</button>
@endif
<a href="{{ $this->getFornitoreOperativoUrl((int) $f['id']) }}" class="inline-flex items-center rounded-md bg-indigo-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-600">Scheda operativa</a> <a href="{{ $this->getFornitoreOperativoUrl((int) $f['id']) }}" class="inline-flex items-center rounded-md bg-indigo-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-600">Scheda operativa</a>
<a href="{{ $this->getFornitoreAnagraficaUrl((int) $f['id']) }}" class="inline-flex items-center rounded-md bg-gray-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-gray-600">Anagrafica</a> <a href="{{ $this->getFornitoreAnagraficaUrl((int) $f['id']) }}" class="inline-flex items-center rounded-md bg-gray-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-gray-600">Anagrafica</a>
</div> </div>
@ -142,7 +190,7 @@
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="6" class="border px-2 py-4 text-center text-gray-500">Nessun fornitore attivo su ticket aperti.</td> <td colspan="7" class="border px-2 py-4 text-center text-gray-500">Nessun fornitore attivo su ticket aperti.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@ -229,7 +277,7 @@
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm"> <div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2"> <div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
@if(str_starts_with((string) $a->mime_type, 'image/')) @if(str_starts_with((string) $a->mime_type, 'image/'))
<img src="{{ route('admin.tickets.attachments.view', ['attachment' => (int) $a->id]) }}" alt="{{ $a->original_file_name }}" class="h-full w-full rounded object-cover" /> <img src="{{ $this->getAttachmentPublicUrl($a) }}" alt="{{ $a->original_file_name }}" class="h-full w-full rounded object-cover" />
@else @else
<div class="flex h-full items-center justify-center text-center text-gray-500"> <div class="flex h-full items-center justify-center text-center text-gray-500">
<div> <div>

View File

@ -1,5 +1,59 @@
<x-filament-panels::page> <x-filament-panels::page wire:poll.8s="refreshLiveCallBanner">
<div class="space-y-4"> <div class="space-y-4">
@if($liveIncomingCall)
@php($rubricaUrl = $this->getLiveRubricaUrl())
@php($estrattoUrl = $this->getLiveEstrattoContoUrl())
<div class="rounded-xl border border-emerald-300 bg-emerald-50 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-emerald-900">Chiamata in arrivo in tempo reale</div>
<div class="mt-1 text-xs text-emerald-800">
Numero: <span class="font-semibold">{{ $liveIncomingCall['phone'] ?? '-' }}</span>
@if(!empty($liveIncomingCall['target_extension']))
· Interno: {{ $liveIncomingCall['target_extension'] }}
@endif
@if(!empty($liveIncomingCall['received_at']))
· Ricevuta: {{ $liveIncomingCall['received_at'] }}
@endif
</div>
@if(!empty($liveIncomingCall['rubrica_nome']))
<div class="mt-1 text-xs text-emerald-800">Contatto riconosciuto: {{ $liveIncomingCall['rubrica_nome'] }}</div>
@endif
@if(!empty($liveIncomingCall['line']))
<div class="mt-1 text-[11px] text-emerald-700">{{ $this->excerpt((string) $liveIncomingCall['line'], 180) }}</div>
@endif
</div>
<div class="flex flex-wrap gap-2">
<x-filament::button size="sm" color="success" wire:click="creaPostItDaChiamataInIngresso">
Crea Post-it (prima valutazione)
</x-filament::button>
<x-filament::button size="sm" color="gray" wire:click="usaChiamataInIngresso">
Precompila ticket (opzionale)
</x-filament::button>
@if($liveCallCanClickToCall)
<x-filament::button size="sm" color="warning" wire:click="richiediClickToCallDaInterno">
Chiama da interno assegnato
</x-filament::button>
@endif
@if($rubricaUrl)
<a href="{{ $rubricaUrl }}" class="inline-flex items-center rounded-md bg-indigo-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-600">
Apri scheda rubrica
</a>
@endif
@if($estrattoUrl)
<a href="{{ $estrattoUrl }}" class="inline-flex items-center rounded-md bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700" target="_blank" rel="noopener noreferrer">
Apri estratto conto
</a>
@endif
</div>
</div>
</div>
@endif
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<a href="{{ $this->getDashboardFilamentUrl() }}" class="inline-flex items-center rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Dashboard Filament</a> <a href="{{ $this->getDashboardFilamentUrl() }}" class="inline-flex items-center rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Dashboard Filament</a>

View File

@ -6,6 +6,7 @@
</x-slot> </x-slot>
<div class="space-y-4"> <div class="space-y-4">
@if(($showTechnicalBoxes ?? true) === true)
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm">
<div class="font-semibold text-gray-800">Account collegato</div> <div class="font-semibold text-gray-800">Account collegato</div>
<div class="text-gray-700">{{ $connectionLabel }}</div> <div class="text-gray-700">{{ $connectionLabel }}</div>
@ -33,8 +34,17 @@
</a> </a>
</div> </div>
</div> </div>
@else
<div class="rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm text-sky-900">
<div class="font-semibold">Scadenzario operativo</div>
<div class="mt-1 text-xs">Calendario collegato: <span class="font-mono">{{ $calendarId }}</span></div>
<div class="mt-2">
<a href="{{ $this->getCalendarioUrl() }}" class="inline-flex items-center rounded-md bg-sky-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-600">Apri calendario interno</a>
</div>
</div>
@endif
@if(filled($errorMessage)) @if(filled($errorMessage) && (($showTechnicalBoxes ?? true) === true))
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"> <div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
{{ $errorMessage }} {{ $errorMessage }}
</div> </div>
@ -93,16 +103,17 @@
@else @else
<div class="space-y-2"> <div class="space-y-2">
@foreach($openTickets as $ticket) @foreach($openTickets as $ticket)
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2"> <a href="{{ $this->getTicketGestioneUrl((int) $ticket['id']) }}" class="block rounded-md border border-gray-100 bg-gray-50 px-3 py-2 hover:bg-slate-100">
<div class="text-sm font-medium text-gray-800">#{{ $ticket['id'] }} - {{ $ticket['titolo'] }}</div> <div class="text-sm font-medium text-gray-800">#{{ $ticket['id'] }} - {{ $ticket['titolo'] }}</div>
<div class="text-xs text-gray-600">{{ $ticket['stabile'] }}</div> <div class="text-xs text-gray-600">{{ $ticket['stabile'] }}</div>
<div class="text-xs text-gray-500">Stato: {{ $ticket['stato'] }} · Priorita: {{ $ticket['priorita'] }}</div> <div class="text-xs text-gray-500">Stato: {{ $ticket['stato'] }} · Priorita: {{ $ticket['priorita'] }}</div>
</div> </a>
@endforeach @endforeach
</div> </div>
@endif @endif
</div> </div>
@if(($showTechnicalBoxes ?? true) === true)
<div class="rounded-lg border border-gray-200 p-3 lg:col-span-2"> <div class="rounded-lg border border-gray-200 p-3 lg:col-span-2">
<div class="mb-2 text-sm font-semibold text-gray-800">Template Cartelle Drive Condominio</div> <div class="mb-2 text-sm font-semibold text-gray-800">Template Cartelle Drive Condominio</div>
<div class="mb-2 text-xs text-gray-500">Struttura base replicabile per ogni stabile (allineata alla tua organizzazione in Drive).</div> <div class="mb-2 text-xs text-gray-500">Struttura base replicabile per ogni stabile (allineata alla tua organizzazione in Drive).</div>
@ -116,6 +127,7 @@
</div> </div>
@endif @endif
</div> </div>
@endif
</div> </div>
</div> </div>
</x-filament::section> </x-filament::section>

View File

@ -29,6 +29,10 @@
@include('profile.partials.update-password-form') @include('profile.partials.update-password-form')
</div> </div>
<div class="p-4 md:p-6 bg-white shadow rounded-lg">
@include('profile.partials.two-factor-authentication-form')
</div>
<div class="p-4 md:p-6 bg-white shadow rounded-lg"> <div class="p-4 md:p-6 bg-white shadow rounded-lg">
@include('profile.partials.delete-user-form') @include('profile.partials.delete-user-form')
</div> </div>

View File

@ -0,0 +1,58 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">Autenticazione a due fattori (App)</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">Usa Google Authenticator, Microsoft Authenticator o app compatibile TOTP.</p>
</header>
@if (session('status') === 'two-factor-enabled')
<p class="mt-3 text-sm text-green-700">2FA attivata correttamente.</p>
@endif
@if (session('status') === 'two-factor-disabled')
<p class="mt-3 text-sm text-yellow-700">2FA disattivata.</p>
@endif
@if (! $user->hasTwoFactorEnabled())
@if (!empty($twoFactorPendingSecret ?? null))
<div class="mt-4 p-4 border rounded bg-gray-50">
<p class="text-sm mb-2">1) Inserisci questa chiave nell'app Authenticator:</p>
<code class="block p-2 bg-white border rounded text-sm break-all">{{ $twoFactorPendingSecret }}</code>
@if(!empty($twoFactorPendingUri ?? null))
<p class="text-xs text-gray-600 mt-2">URI OTP:</p>
<code class="block p-2 bg-white border rounded text-xs break-all">{{ $twoFactorPendingUri }}</code>
@endif
<form method="POST" action="{{ route('profile.two-factor.confirm') }}" class="mt-4 space-y-3">
@csrf
<div>
<x-input-label for="code" :value="__('2) Inserisci il codice a 6 cifre')" />
<x-text-input id="code" name="code" type="text" maxlength="6" class="mt-1 block w-full" required />
<x-input-error class="mt-2" :messages="$errors->get('code')" />
</div>
<x-primary-button>Conferma e Attiva 2FA</x-primary-button>
</form>
</div>
@else
<form method="POST" action="{{ route('profile.two-factor.enable') }}" class="mt-4">
@csrf
<x-primary-button>Abilita 2FA con App</x-primary-button>
</form>
@endif
@else
<div class="mt-4 p-4 border rounded bg-green-50">
<p class="text-sm text-green-700">2FA attiva dal {{ optional($user->two_factor_confirmed_at)->format('d/m/Y H:i') }}.</p>
<p class="text-xs text-gray-600 mt-2">In login dovrai inserire il codice OTP della tua app.</p>
</div>
<form method="POST" action="{{ route('profile.two-factor.disable') }}" class="mt-4 space-y-3">
@csrf
<div>
<x-input-label for="two_factor_disable_password" :value="__('Password conferma disattivazione')" />
<x-text-input id="two_factor_disable_password" name="password" type="password" class="mt-1 block w-full" required />
<x-input-error class="mt-2" :messages="$errors->get('password')" />
</div>
<x-danger-button>Disabilita 2FA</x-danger-button>
</form>
@endif
</section>

View File

@ -0,0 +1,141 @@
@extends('admin.layouts.netgescon')
@section('title', 'Impostazioni Sistema')
@section('breadcrumb')
<a href="{{ route('superadmin.dashboard') }}">SuperAdmin</a>
<i class="fas fa-chevron-right text-xs"></i>
<span>Impostazioni Sistema</span>
@endsection
@section('content')
<div class="space-y-4">
<h2 class="text-xl font-semibold">Registrazione e Sicurezza</h2>
<div class="netgescon-card p-4">
<h3 class="font-semibold mb-2">Check Servizi Google</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
<div class="p-3 rounded border">
<div class="font-medium">Configurazione OAuth</div>
<div class="mt-1">Client ID: {{ ($googleStatus['client_id_configured'] ?? false) ? 'ok' : 'mancante' }}</div>
<div>Client Secret: {{ ($googleStatus['client_secret_configured'] ?? false) ? 'ok' : 'mancante' }}</div>
<div>Redirect URI: {{ ($googleStatus['redirect_configured'] ?? false) ? 'ok' : 'mancante' }}</div>
</div>
<div class="p-3 rounded border">
<div class="font-medium">Connessione Account</div>
<div class="mt-1">Stato: {{ ($googleStatus['oauth_connected'] ?? false) ? 'collegato' : 'non collegato' }}</div>
<div>Email: {{ $googleStatus['oauth_email'] ?? '-' }}</div>
</div>
<div class="p-3 rounded border">
<div class="font-medium">Servizi</div>
<div class="mt-1">Calendar: {{ ($googleStatus['calendar_enabled'] ?? false) ? 'ok' : 'ko' }}</div>
<div>Rubrica: {{ ($googleStatus['contacts_enabled'] ?? false) ? 'ok' : 'ko' }}</div>
<div>Drive: {{ ($googleStatus['drive_enabled'] ?? false) ? 'ok' : 'ko' }}</div>
</div>
</div>
</div>
<form method="POST" action="{{ route('superadmin.impostazioni.store') }}" class="space-y-4">
@csrf
<div class="netgescon-card p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600">Registrazione pubblica</label>
<select name="registration_enabled" class="netgescon-input w-full">
<option value="true" @selected(($settings['registration_enabled'] ?? 'true') === 'true')>Abilitata</option>
<option value="false" @selected(($settings['registration_enabled'] ?? 'true') === 'false')>Disabilitata</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600">Conferma email obbligatoria</label>
<select name="require_email_verification" class="netgescon-input w-full">
<option value="true" @selected(($settings['require_email_verification'] ?? 'true') === 'true')>Si</option>
<option value="false" @selected(($settings['require_email_verification'] ?? 'true') === 'false')>No</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600">Versione reCAPTCHA</label>
<select name="recaptcha_version" class="netgescon-input w-full">
<option value="none" @selected(($settings['recaptcha_version'] ?? 'none') === 'none')>Disabilitato</option>
<option value="v2" @selected(($settings['recaptcha_version'] ?? 'none') === 'v2')>v2 Checkbox</option>
<option value="v3" @selected(($settings['recaptcha_version'] ?? 'none') === 'v3')>v3 Score</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600">reCAPTCHA Site Key</label>
<input type="text" name="recaptcha_site_key" class="netgescon-input w-full" value="{{ old('recaptcha_site_key', $settings['recaptcha_site_key'] ?? '') }}">
</div>
<div class="md:col-span-2">
<label class="block text-sm text-gray-600">reCAPTCHA Secret Key</label>
<input type="text" name="recaptcha_secret_key" class="netgescon-input w-full" value="{{ old('recaptcha_secret_key', $settings['recaptcha_secret_key'] ?? '') }}">
</div>
</div>
<div class="netgescon-card p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<h3 class="font-semibold mb-2">Comunicazioni Omnicanale (Test)</h3>
<p class="text-xs text-gray-500">Puoi instradare i webhook su ticket o post-it usando query param: <code>?mode=ticket&ticket_id=123</code> oppure <code>?mode=auto_post_it</code>.</p>
</div>
<div>
<label class="block text-sm text-gray-600">Telegram Bot Token</label>
<input type="text" name="telegram_bot_token" class="netgescon-input w-full" value="{{ old('telegram_bot_token', $settings['telegram_bot_token'] ?? '') }}">
</div>
<div>
<label class="block text-sm text-gray-600">Telegram Webhook Token (sicurezza)</label>
<input type="text" name="telegram_webhook_token" class="netgescon-input w-full" value="{{ old('telegram_webhook_token', $settings['telegram_webhook_token'] ?? '') }}">
</div>
<div>
<label class="block text-sm text-gray-600">WhatsApp Phone Number ID</label>
<input type="text" name="whatsapp_phone_number_id" class="netgescon-input w-full" value="{{ old('whatsapp_phone_number_id', $settings['whatsapp_phone_number_id'] ?? '') }}">
</div>
<div>
<label class="block text-sm text-gray-600">WhatsApp Business Account ID</label>
<input type="text" name="whatsapp_business_account_id" class="netgescon-input w-full" value="{{ old('whatsapp_business_account_id', $settings['whatsapp_business_account_id'] ?? '') }}">
</div>
<div class="md:col-span-2">
<label class="block text-sm text-gray-600">WhatsApp Access Token (Meta Cloud API)</label>
<input type="text" name="whatsapp_access_token" class="netgescon-input w-full" value="{{ old('whatsapp_access_token', $settings['whatsapp_access_token'] ?? '') }}">
</div>
<div class="md:col-span-2">
<label class="block text-sm text-gray-600">WhatsApp Webhook Token (sicurezza)</label>
<input type="text" name="whatsapp_webhook_token" class="netgescon-input w-full" value="{{ old('whatsapp_webhook_token', $settings['whatsapp_webhook_token'] ?? '') }}">
</div>
<div class="md:col-span-2 text-xs text-gray-600">
<div>Webhook Telegram: <code>{{ url('/api/v1/communications/telegram/webhook') }}</code></div>
<div>Webhook WhatsApp: <code>{{ url('/api/v1/communications/whatsapp/webhook') }}</code></div>
</div>
</div>
<div class="netgescon-card p-4">
<h3 class="font-semibold mb-2">Tema Interfaccia Personale</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm text-gray-600">Sfondo</label>
<input type="color" name="bg_color" class="netgescon-input w-full h-10" value="{{ old('bg_color', $settings['bg_color'] ?? '#ffffff') }}">
</div>
<div>
<label class="block text-sm text-gray-600">Testo</label>
<input type="color" name="text_color" class="netgescon-input w-full h-10" value="{{ old('text_color', $settings['text_color'] ?? '#1e293b') }}">
</div>
<div>
<label class="block text-sm text-gray-600">Accento</label>
<input type="color" name="accent_color" class="netgescon-input w-full h-10" value="{{ old('accent_color', $settings['accent_color'] ?? '#6366f1') }}">
</div>
</div>
</div>
<div class="flex justify-end">
<button class="netgescon-btn netgescon-btn-primary">Salva Impostazioni</button>
</div>
</form>
</div>
@endsection

View File

@ -42,6 +42,15 @@
<label class="block text-sm text-gray-600">Scadenza Accesso (opzionale)</label> <label class="block text-sm text-gray-600">Scadenza Accesso (opzionale)</label>
<input type="date" name="expires_at" class="netgescon-input w-full"> <input type="date" name="expires_at" class="netgescon-input w-full">
</div> </div>
<div>
<label class="block text-sm text-gray-600">Piano Sottoscrizione</label>
<select name="subscription_plan_id" class="netgescon-input w-full">
<option value="">Nessuno</option>
@foreach($plans as $plan)
<option value="{{ $plan->id }}">{{ $plan->name }}</option>
@endforeach
</select>
</div>
</div> </div>
<div class="netgescon-card p-4"> <div class="netgescon-card p-4">
<h3 class="font-semibold mb-2">Permessi Modulo GESCON Import</h3> <h3 class="font-semibold mb-2">Permessi Modulo GESCON Import</h3>

View File

@ -34,6 +34,34 @@
<label class="block text-sm text-gray-600">Scadenza Accesso (opzionale)</label> <label class="block text-sm text-gray-600">Scadenza Accesso (opzionale)</label>
<input type="date" name="expires_at" class="netgescon-input w-full" value="{{ old('expires_at', optional($user->expires_at ?? null)->format('Y-m-d')) }}"> <input type="date" name="expires_at" class="netgescon-input w-full" value="{{ old('expires_at', optional($user->expires_at ?? null)->format('Y-m-d')) }}">
</div> </div>
<div>
<label class="block text-sm text-gray-600">Stato Registrazione</label>
<select name="registration_status" class="netgescon-input w-full" required>
<option value="pending" @selected(old('registration_status', $user->registration_status) === 'pending')>pending</option>
<option value="approved" @selected(old('registration_status', $user->registration_status) === 'approved')>approved</option>
<option value="rejected" @selected(old('registration_status', $user->registration_status) === 'rejected')>rejected</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600">Stato Account</label>
<label class="inline-flex items-center gap-2 mt-2 text-sm">
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $user->is_active))>
<span>Account attivo</span>
</label>
</div>
<div>
<label class="block text-sm text-gray-600">Piano Sottoscrizione</label>
<select name="subscription_plan_id" class="netgescon-input w-full">
<option value="">Nessuno</option>
@foreach($plans as $plan)
<option value="{{ $plan->id }}" @selected((int) old('subscription_plan_id', $user->subscription_plan_id) === $plan->id)>{{ $plan->name }}</option>
@endforeach
</select>
</div>
<div class="md:col-span-2">
<label class="block text-sm text-gray-600">Motivo Rifiuto (opzionale)</label>
<textarea name="rejection_reason" class="netgescon-input w-full" rows="2">{{ old('rejection_reason', $user->rejection_reason) }}</textarea>
</div>
</div> </div>
<div class="netgescon-card p-4"> <div class="netgescon-card p-4">

View File

@ -23,6 +23,9 @@
<th>Nome</th> <th>Nome</th>
<th>Email</th> <th>Email</th>
<th>Ruoli</th> <th>Ruoli</th>
<th>Stato</th>
<th>Attivo</th>
<th>Piano</th>
<th>Azioni</th> <th>Azioni</th>
</tr> </tr>
</thead> </thead>
@ -32,8 +35,47 @@
<td>{{ $u->name }}</td> <td>{{ $u->name }}</td>
<td>{{ $u->email }}</td> <td>{{ $u->email }}</td>
<td>{{ $u->roles->pluck('name')->join(', ') }}</td> <td>{{ $u->roles->pluck('name')->join(', ') }}</td>
<td>
<span class="text-xs px-2 py-1 rounded bg-gray-100 text-gray-700">{{ $u->registration_status ?? 'approved' }}</span>
</td>
<td>
@if($u->is_active)
<span class="text-xs px-2 py-1 rounded bg-green-100 text-green-700">si</span>
@else
<span class="text-xs px-2 py-1 rounded bg-red-100 text-red-700">no</span>
@endif
</td>
<td>
<form action="{{ route('superadmin.users.assign-plan', $u) }}" method="POST" class="flex items-center gap-2">
@csrf
<select name="subscription_plan_id" class="netgescon-input text-xs py-1">
<option value="">Nessuno</option>
@foreach($plans as $plan)
<option value="{{ $plan->id }}" @selected($u->subscription_plan_id === $plan->id)>{{ $plan->name }}</option>
@endforeach
</select>
<button class="netgescon-btn netgescon-btn-sm netgescon-btn-outline-secondary">Ok</button>
</form>
</td>
<td class="space-x-2"> <td class="space-x-2">
<a href="{{ route('superadmin.users.edit', $u) }}" class="netgescon-btn netgescon-btn-sm netgescon-btn-outline-secondary">Modifica</a> <a href="{{ route('superadmin.users.edit', $u) }}" class="netgescon-btn netgescon-btn-sm netgescon-btn-outline-secondary">Modifica</a>
@if(($u->registration_status ?? 'approved') === 'pending')
<form action="{{ route('superadmin.users.approve', $u) }}" method="POST" class="inline">
@csrf
<button class="netgescon-btn netgescon-btn-sm netgescon-btn-primary">Approva</button>
</form>
<form action="{{ route('superadmin.users.reject', $u) }}" method="POST" class="inline" onsubmit="return confirm('Rifiutare questa registrazione?')">
@csrf
<button class="netgescon-btn netgescon-btn-sm netgescon-btn-outline-dark">Rifiuta</button>
</form>
@endif
<form action="{{ route('superadmin.users.toggle-active', $u) }}" method="POST" class="inline">
@csrf
<button class="netgescon-btn netgescon-btn-sm netgescon-btn-outline-secondary">{{ $u->is_active ? 'Disattiva' : 'Attiva' }}</button>
</form>
@if(auth()->id() !== $u->id && auth()->user()?->hasRole('super-admin') && $u->canBeImpersonated())
<a href="{{ route('superadmin.users.impersonate', $u) }}" class="netgescon-btn netgescon-btn-sm netgescon-btn-outline-secondary">Impersona</a>
@endif
<form action="{{ route('superadmin.users.destroy', $u) }}" method="POST" class="inline" onsubmit="return confirm('Eliminare utente?')"> <form action="{{ route('superadmin.users.destroy', $u) }}" method="POST" class="inline" onsubmit="return confirm('Eliminare utente?')">
@csrf @method('DELETE') @csrf @method('DELETE')
<button class="netgescon-btn netgescon-btn-sm netgescon-btn-outline-dark">Elimina</button> <button class="netgescon-btn netgescon-btn-sm netgescon-btn-outline-dark">Elimina</button>

View File

@ -88,3 +88,20 @@
Route::get('/lookup', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'lookup']) Route::get('/lookup', [\App\Http\Controllers\Api\PanasonicCstaController::class, 'lookup'])
->name('api.cti.panasonic.lookup'); ->name('api.cti.panasonic.lookup');
}); });
// --- API Comunicazioni Omnicanale (Telegram / WhatsApp test) ---
Route::prefix('v1/communications')->group(function () {
Route::post('/telegram/webhook', [\App\Http\Controllers\Api\CommunicationWebhookController::class, 'telegram'])
->name('api.communications.telegram.webhook');
Route::post('/whatsapp/webhook', [\App\Http\Controllers\Api\CommunicationWebhookController::class, 'whatsapp'])
->name('api.communications.whatsapp.webhook');
});
Route::middleware('auth:sanctum')->prefix('v1/communications')->group(function () {
Route::post('/telegram/send-test', [\App\Http\Controllers\Api\CommunicationWebhookController::class, 'sendTelegramTest'])
->name('api.communications.telegram.send_test');
Route::post('/whatsapp/send-test', [\App\Http\Controllers\Api\CommunicationWebhookController::class, 'sendWhatsappTest'])
->name('api.communications.whatsapp.send_test');
});

View File

@ -84,6 +84,9 @@
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::post('/profile/two-factor/enable', [ProfileController::class, 'twoFactorEnable'])->name('profile.two-factor.enable');
Route::post('/profile/two-factor/confirm', [ProfileController::class, 'twoFactorConfirm'])->name('profile.two-factor.confirm');
Route::post('/profile/two-factor/disable', [ProfileController::class, 'twoFactorDisable'])->name('profile.two-factor.disable');
// --- SUPER-ADMIN PANEL --- // --- SUPER-ADMIN PANEL ---
Route::middleware(['role:super-admin'])->prefix('superadmin')->name('superadmin.')->group(function () { Route::middleware(['role:super-admin'])->prefix('superadmin')->name('superadmin.')->group(function () {
@ -95,6 +98,10 @@
Route::resource('users', SuperAdminUserController::class)->except(['show']); Route::resource('users', SuperAdminUserController::class)->except(['show']);
Route::patch('users/{user}/update-role', [SuperAdminUserController::class, 'updateRole'])->name('users.updateRole'); Route::patch('users/{user}/update-role', [SuperAdminUserController::class, 'updateRole'])->name('users.updateRole');
Route::get('users/{user}/impersonate', [SuperAdminUserController::class, 'impersonate'])->name('users.impersonate'); Route::get('users/{user}/impersonate', [SuperAdminUserController::class, 'impersonate'])->name('users.impersonate');
Route::post('users/{user}/approve', [SuperAdminUserController::class, 'approve'])->name('users.approve');
Route::post('users/{user}/reject', [SuperAdminUserController::class, 'reject'])->name('users.reject');
Route::post('users/{user}/toggle-active', [SuperAdminUserController::class, 'toggleActive'])->name('users.toggle-active');
Route::post('users/{user}/assign-plan', [SuperAdminUserController::class, 'assignPlan'])->name('users.assign-plan');
// Impostazioni Sistema // Impostazioni Sistema
Route::get('impostazioni', [\App\Http\Controllers\SuperAdmin\ImpostazioniController::class, 'index'])->name('impostazioni.index'); Route::get('impostazioni', [\App\Http\Controllers\SuperAdmin\ImpostazioniController::class, 'index'])->name('impostazioni.index');
@ -131,6 +138,7 @@
Route::resource('fornitori', FornitoreController::class); Route::resource('fornitori', FornitoreController::class);
Route::resource('tickets', TicketController::class); Route::resource('tickets', TicketController::class);
Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close'); Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close');
Route::post('tickets/{ticket}/insurance/open', [TicketController::class, 'openInsuranceClaim'])->name('tickets.insurance.open');
Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store'); Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store');
Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view'); Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view');
Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store'); Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store');
@ -301,6 +309,7 @@
Route::resource('fornitori', FornitoreController::class); Route::resource('fornitori', FornitoreController::class);
Route::resource('tickets', TicketController::class); Route::resource('tickets', TicketController::class);
Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close'); Route::post('tickets/{ticket}/close', [TicketController::class, 'close'])->name('tickets.close');
Route::post('tickets/{ticket}/insurance/open', [TicketController::class, 'openInsuranceClaim'])->name('tickets.insurance.open');
Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store'); Route::post('tickets/{ticket}/email-message', [TicketController::class, 'storeEmailMessage'])->name('tickets.email-message.store');
Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view'); Route::get('tickets/attachments/{attachment}/view', [TicketAttachmentViewController::class, 'show'])->name('tickets.attachments.view');
Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store'); Route::post('tickets/{ticket}/interventi', [TicketInterventoController::class, 'store'])->name('tickets.interventi.store');
@ -576,6 +585,9 @@
Route::get('affitti/ricevuta/{dovuto}', [\App\Http\Controllers\Filament\AffittiRicevuteController::class, 'show']) Route::get('affitti/ricevuta/{dovuto}', [\App\Http\Controllers\Filament\AffittiRicevuteController::class, 'show'])
->name('filament.affitti.ricevuta'); ->name('filament.affitti.ricevuta');
Route::get('supporto/ticket-attachments/{attachment}/view', [\App\Http\Controllers\Admin\TicketAttachmentViewController::class, 'show'])
->name('filament.tickets.attachments.view');
// Pagina di test/calibrazione per etichette DYMO (11354/99014) // Pagina di test/calibrazione per etichette DYMO (11354/99014)
Route::get('etichette/calibrazione', [\App\Http\Controllers\Filament\DymoCalibrationController::class, 'show']) Route::get('etichette/calibrazione', [\App\Http\Controllers\Filament\DymoCalibrationController::class, 'show'])
->name('filament.etichette.calibrazione'); ->name('filament.etichette.calibrazione');

View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
# Export recent online errors into a git-trackable NDJSON feed.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT_FILE="${ROOT_DIR}/docs/support/online-runtime-errors.ndjson"
TMP_FILE="${OUT_FILE}.tmp"
mkdir -p "$(dirname "$OUT_FILE")"
: > "$TMP_FILE"
append_tail() {
local src="$1"
local lines="$2"
if [[ -f "$src" ]]; then
tail -n "$lines" "$src" >> "$TMP_FILE" || true
fi
}
append_tail "${ROOT_DIR}/storage/logs/runtime-errors.ndjson" 300
append_tail "${ROOT_DIR}/storage/logs/support-events.ndjson" 300
if [[ ! -s "$TMP_FILE" ]]; then
echo '{"timestamp":"'"$(date -Iseconds)"'","type":"info","message":"Nessun errore disponibile da esportare","context":"export script","source":"online-export"}' > "$TMP_FILE"
fi
mv "$TMP_FILE" "$OUT_FILE"
echo "Export completato: ${OUT_FILE}"