feat(release): prepare staging 0.8.1 update rollout
This commit is contained in:
parent
e0e3c7d16d
commit
07c440fad4
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
456
app/Console/Commands/NetgesconPreupdateBackupCommand.php
Normal file
456
app/Console/Commands/NetgesconPreupdateBackupCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/Console/Commands/NetgesconRestoreRecordCommand.php
Normal file
138
app/Console/Commands/NetgesconRestoreRecordCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
495
app/Console/Commands/SmdrListenCommand.php
Normal file
495
app/Console/Commands/SmdrListenCommand.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, '')")
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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' => [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
23
app/Filament/Pages/Supporto/SupportoDashboard.php
Normal file
23
app/Filament/Pages/Supporto/SupportoDashboard.php
Normal 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';
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
7
app/Filament/Widgets/GoogleScadenziarioOverview.php
Normal file
7
app/Filament/Widgets/GoogleScadenziarioOverview.php
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
|
class GoogleScadenziarioOverview extends GoogleWorkspaceOverview
|
||||||
|
{
|
||||||
|
public bool $showTechnicalBoxes = false;
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
212
app/Http/Controllers/Api/CommunicationWebhookController.php
Normal file
212
app/Http/Controllers/Api/CommunicationWebhookController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
app/Http/Middleware/EnsureEmailIsVerifiedForNetgescon.php
Normal file
24
app/Http/Middleware/EnsureEmailIsVerifiedForNetgescon.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
app/Http/Responses/FortifyVerifyEmailViewResponse.php
Normal file
21
app/Http/Responses/FortifyVerifyEmailViewResponse.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Models/CommunicationMessage.php
Normal file
75
app/Models/CommunicationMessage.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Models/InsuranceClaim.php
Normal file
36
app/Models/InsuranceClaim.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Models/PaymentMethod.php
Normal file
22
app/Models/PaymentMethod.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
30
app/Models/PbxClickToCallRequest.php
Normal file
30
app/Models/PbxClickToCallRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
24
app/Models/SubscriptionPlan.php
Normal file
24
app/Models/SubscriptionPlan.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
40
app/Models/SystemSetting.php
Normal file
40
app/Models/SystemSetting.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
87
app/Support/Security/TotpService.php
Normal file
87
app/Support/Security/TotpService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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', ''),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
55
docs/CTI-NS1000-CHECKLIST.md
Normal file
55
docs/CTI-NS1000-CHECKLIST.md
Normal 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.
|
||||||
53
docs/SMDR-INTEGRAZIONE-OPERATIVA.md
Normal file
53
docs/SMDR-INTEGRAZIONE-OPERATIVA.md
Normal 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.
|
||||||
41
docs/support/ONLINE-ERROR-SYNC.md
Normal file
41
docs/support/ONLINE-ERROR-SYNC.md
Normal 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
|
||||||
|
```
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
10
resources/views/filament/pages/supporto/dashboard.blade.php
Normal file
10
resources/views/filament/pages/supporto/dashboard.blade.php
Normal 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>
|
||||||
|
|
@ -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 <tabella> <pk> --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 <tabella> <pk></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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
141
resources/views/superadmin/impostazioni/index.blade.php
Normal file
141
resources/views/superadmin/impostazioni/index.blade.php
Normal 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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
28
scripts/ops/netgescon-export-online-errors-to-git.sh
Executable file
28
scripts/ops/netgescon-export-online-errors-to-git.sh
Executable 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}"
|
||||||
Loading…
Reference in New Issue
Block a user