Checkpoint day0: sync staging fixes and operational workflows

This commit is contained in:
michele 2026-03-16 16:03:36 +00:00
parent c505104244
commit fe16ef481b
26 changed files with 2329 additions and 160 deletions

3
.gitignore vendored
View File

@ -106,6 +106,9 @@ storage/framework/cache/
storage/framework/sessions/ storage/framework/sessions/
storage/framework/views/ storage/framework/views/
storage/backups/ storage/backups/
storage/app/distribution/
storage/app/support/
storage/app/update-backups/
# Local personal workspace (keep only operational note) # Local personal workspace (keep only operational note)
Miki-Bug-workspace/** Miki-Bug-workspace/**

View File

@ -0,0 +1,258 @@
<?php
namespace App\Console\Commands;
use App\Models\Amministratore;
use App\Models\Scadenza;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
class GoogleSyncScadenzeCalendarCommand extends Command
{
protected $signature = 'google:sync-scadenze-calendar
{--admin-id=* : Limita la sincronizzazione a uno o piu ID amministratore}
{--calendar-id= : Sovrascrive il Calendar ID configurato}
{--limit=100 : Numero massimo di scadenze da sincronizzare per admin}
{--days=365 : Orizzonte massimo di giorni futuri da sincronizzare}
{--dry-run : Simula la sync senza scrivere su Google Calendar}';
protected $description = 'Sincronizza le scadenze legacy importate su Google Calendar.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$limit = max(1, (int) $this->option('limit'));
$days = max(1, (int) $this->option('days'));
$forcedCalendarId = trim((string) $this->option('calendar-id'));
$filterAdminIds = collect((array) $this->option('admin-id'))
->map(fn($value) => (int) $value)
->filter(fn(int $value) => $value > 0)
->values()
->all();
$admins = Amministratore::query()
->when($filterAdminIds !== [], fn($query) => $query->whereIn('id', $filterAdminIds))
->get();
if ($admins->isEmpty()) {
$this->warn('Nessun amministratore trovato per i filtri richiesti.');
return self::SUCCESS;
}
foreach ($admins as $admin) {
$google = Arr::get($admin->impostazioni ?? [], 'google', []);
$oauth = Arr::get($google, 'oauth', []);
if (! (bool) Arr::get($oauth, 'connected', false)) {
$this->line("Admin {$admin->id}: Google non collegato, salto.");
continue;
}
$calendarId = $forcedCalendarId !== ''
? $forcedCalendarId
: ((string) Arr::get($google, 'calendar_id', 'primary') ?: 'primary');
$token = $this->resolveAccessToken($admin, $google, $oauth, $dryRun);
if ($token === null) {
$this->warn("Admin {$admin->id}: token non disponibile, salto.");
continue;
}
$query = Scadenza::query()
->with('stabile:id,amministratore_id,denominazione')
->whereNotNull('data_scadenza')
->whereDate('data_scadenza', '<=', now()->addDays($days)->toDateString())
->orderBy('data_scadenza')
->orderBy('id');
$query->where(function ($sub) use ($admin): void {
$sub->whereHas('stabile', function ($stabileQuery) use ($admin): void {
$stabileQuery->where('amministratore_id', (int) $admin->id);
})->orWhereNull('stabile_id');
});
$scadenze = $query->limit($limit)->get();
$created = 0;
$updated = 0;
$errors = 0;
foreach ($scadenze as $scadenza) {
$payload = $this->buildEventPayload($scadenza);
$event = $this->findEventByScadenzaId($token, $calendarId, (int) $scadenza->id);
if ($event === null) {
if ($dryRun) {
$created++;
continue;
}
$response = Http::withToken($token)
->acceptJson()
->post('https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($calendarId) . '/events', $payload);
if (! $response->successful()) {
$errors++;
continue;
}
$scadenza->calendar_event_id = (string) ($response->json('id') ?? '');
$scadenza->calendar_synced_at = now();
$scadenza->save();
$created++;
continue;
}
if ($dryRun) {
$updated++;
continue;
}
$eventId = trim((string) Arr::get($event, 'id', ''));
if ($eventId === '') {
$errors++;
continue;
}
$response = Http::withToken($token)
->acceptJson()
->patch('https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($calendarId) . '/events/' . rawurlencode($eventId), $payload);
if (! $response->successful()) {
$errors++;
continue;
}
$scadenza->calendar_event_id = $eventId;
$scadenza->calendar_synced_at = now();
$scadenza->save();
$updated++;
}
$mode = $dryRun ? 'dry-run' : 'apply';
$this->info("Admin {$admin->id} ({$mode}): create {$created}, aggiornate {$updated}, errori {$errors}.");
}
return self::SUCCESS;
}
private function buildEventPayload(Scadenza $scadenza): array
{
$date = Carbon::parse($scadenza->data_scadenza);
$title = $scadenza->titolo_calendario;
$description = implode("\n", array_filter([
'Natura: ' . (string) ($scadenza->natura_label ?: $scadenza->natura ?: '-'),
'Gestione: ' . (string) ($scadenza->gestione_tipo ?: '-'),
'Stabile: ' . (string) ($scadenza->stabile?->denominazione ?: ($scadenza->codice_stabile_legacy ?: '-')),
$scadenza->anno ? ('Anno gestione: ' . $scadenza->anno) : null,
$scadenza->rata ? ('Rata: ' . $scadenza->rata) : null,
$scadenza->rif_f24 ? ('Rif. F24: ' . $scadenza->rif_f24) : null,
$scadenza->importo_f24 ? ('Importo F24: ' . number_format((float) $scadenza->importo_f24, 2, ',', '.')) : null,
]));
$payload = [
'summary' => $title,
'description' => $description,
'extendedProperties' => [
'private' => [
'netgescon_scadenza_id' => (string) $scadenza->id,
],
],
];
if ($scadenza->ora_scadenza) {
$start = Carbon::parse($scadenza->data_scadenza->format('Y-m-d') . ' ' . $scadenza->ora_scadenza, 'Europe/Rome');
$payload['start'] = ['dateTime' => $start->toIso8601String(), 'timeZone' => 'Europe/Rome'];
$payload['end'] = ['dateTime' => $start->copy()->addMinutes(30)->toIso8601String(), 'timeZone' => 'Europe/Rome'];
} else {
$payload['start'] = ['date' => $date->toDateString()];
$payload['end'] = ['date' => $date->copy()->addDay()->toDateString()];
}
return $payload;
}
private function findEventByScadenzaId(string $token, string $calendarId, int $scadenzaId): ?array
{
$response = Http::withToken($token)
->acceptJson()
->get('https://www.googleapis.com/calendar/v3/calendars/' . rawurlencode($calendarId) . '/events', [
'privateExtendedProperty' => 'netgescon_scadenza_id=' . $scadenzaId,
'maxResults' => 1,
]);
if (! $response->successful()) {
return null;
}
$items = $response->json('items');
if (! is_array($items) || $items === []) {
return null;
}
return is_array($items[0]) ? $items[0] : null;
}
private function resolveAccessToken(Amministratore $amministratore, array $google, array $oauth, bool $dryRun, bool $forceRefresh = false): ?string
{
$accessToken = trim((string) Arr::get($oauth, 'access_token', ''));
$refreshToken = trim((string) Arr::get($oauth, 'refresh_token', ''));
if (! $forceRefresh && $accessToken !== '') {
return $accessToken;
}
if ($refreshToken === '') {
return null;
}
$settingsClientId = trim((string) Arr::get($google, 'client_id', ''));
$settingsClientSecret = trim((string) Arr::get($google, 'client_secret', ''));
$configClientId = trim((string) config('services.google.client_id'));
$configClientSecret = trim((string) config('services.google.client_secret'));
$credentialPairs = [];
if ($settingsClientId !== '' && $settingsClientSecret !== '') {
$credentialPairs[] = [$settingsClientId, $settingsClientSecret];
}
if ($configClientId !== '' && $configClientSecret !== '' && ($configClientId !== $settingsClientId || $configClientSecret !== $settingsClientSecret)) {
$credentialPairs[] = [$configClientId, $configClientSecret];
}
foreach ($credentialPairs as [$clientId, $clientSecret]) {
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'refresh_token' => $refreshToken,
'grant_type' => 'refresh_token',
]);
if (! $response->successful()) {
continue;
}
$token = trim((string) Arr::get($response->json(), 'access_token', ''));
if ($token === '') {
continue;
}
if (! $dryRun) {
$settings = $amministratore->impostazioni ?? [];
$settingsGoogle = Arr::get($settings, 'google', []);
$settingsOauth = Arr::get($settingsGoogle, 'oauth', []);
$settingsOauth['access_token'] = $token;
$settingsOauth['expires_in'] = (int) Arr::get($response->json(), 'expires_in', 3600);
$settingsOauth['refreshed_at'] = now()->toDateTimeString();
$settingsGoogle['oauth'] = $settingsOauth;
$settings['google'] = $settingsGoogle;
$amministratore->impostazioni = $settings;
$amministratore->save();
}
return $token;
}
return null;
}
}

View File

@ -0,0 +1,260 @@
<?php
namespace App\Console\Commands;
use App\Models\Scadenza;
use App\Models\Stabile;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
class ImportScadenzeMdb extends Command
{
protected $signature = 'scadenze:import-mdb
{--mdb= : Percorso a parti_comuni.mdb}
{--stabile= : Codice stabile legacy per filtrare}
{--truncate : Svuota la tabella scadenze prima dell import}';
protected $description = 'Importa le scadenze legacy da parti_comuni.mdb evitando Descriz_lunga.';
public function handle(): int
{
$mdb = $this->option('mdb') ?: '/mnt/gescon-archives/gescon/parti_comuni.mdb';
$filterStabile = trim((string) $this->option('stabile'));
if (! is_file($mdb)) {
$this->error('Specifica --mdb con path valido al file parti_comuni.mdb');
return self::FAILURE;
}
if ((bool) $this->option('truncate')) {
Scadenza::query()->truncate();
}
$rows = $this->mdbExportRows($mdb, 'Scadenze');
if ($rows === []) {
$this->warn('Nessuna riga letta dalla tabella Scadenze.');
return self::SUCCESS;
}
$imported = 0;
foreach ($rows as $row) {
$legacyId = $this->toInt($row['id_scadenza'] ?? null);
if ($legacyId <= 0) {
continue;
}
$codStabile = $this->normalizeString($row['cod_stabile'] ?? null);
if ($filterStabile !== '' && $codStabile !== $filterStabile) {
continue;
}
$natura = strtoupper($this->normalizeString($row['Natura'] ?? null) ?? '');
Scadenza::query()->updateOrCreate(
['legacy_id' => $legacyId],
[
'stabile_id' => $this->resolveStabileId($codStabile),
'codice_stabile_legacy' => $codStabile,
'descrizione_sintetica' => $this->normalizeString($row['Descriz_sintetica'] ?? null),
'data_scadenza' => $this->toDate($row['Dt_scadenza'] ?? null),
'ora_scadenza' => $this->toTime($row['ora_scadenza'] ?? null),
'natura' => $natura !== '' ? $natura : null,
'natura_label' => $this->resolveNaturaLabel($natura),
'gestione_tipo' => $this->resolveGestioneTipo($row['Gest_ors'] ?? null),
'num_assemblea' => $this->toInt($row['Num_assemblea'] ?? null) ?: null,
'numero_straordinaria' => $this->toInt($row['N_stra'] ?? null) ?: null,
'anno' => $this->normalizeString($row['Anno'] ?? null),
'rata' => $this->toInt($row['Rata'] ?? null) ?: null,
'fatto' => $this->normalizeString($row['Fatto'] ?? null),
'tipo_riga' => $this->normalizeString($row['tipo_riga'] ?? null),
'rif_f24' => $this->toInt($row['Rif_f24'] ?? null) ?: null,
'importo_f24' => $this->toDecimal($row['Importo_f24'] ?? null),
'richiede_pop_up' => $this->toBoolean($row['Richiede_pop_up'] ?? null),
'giorni_anticipo' => $this->toInt($row['gg_anticipo'] ?? null) ?: null,
'popup_dal' => $this->toDate($row['Pop_Up_dal'] ?? null),
'legacy_payload' => [
'legacy_id' => $legacyId,
'cod_stabile' => $codStabile,
'descriz_sintetica' => $this->normalizeString($row['Descriz_sintetica'] ?? null),
'gest_ors' => $this->normalizeString($row['Gest_ors'] ?? null),
'natura' => $natura,
'tipo_riga' => $this->normalizeString($row['tipo_riga'] ?? null),
'num_assemblea' => $this->toInt($row['Num_assemblea'] ?? null) ?: null,
'numero_straordinaria'=> $this->toInt($row['N_stra'] ?? null) ?: null,
'anno' => $this->normalizeString($row['Anno'] ?? null),
'rata' => $this->toInt($row['Rata'] ?? null) ?: null,
],
]
);
$imported++;
}
$this->info('Scadenze importate/aggiornate: ' . $imported);
return self::SUCCESS;
}
private function mdbExportRows(string $mdb, string $table): array
{
$bin = trim((string) shell_exec('command -v mdb-export')) ?: '/usr/bin/mdb-export';
$process = new Process([$bin, $mdb, $table]);
$process->setTimeout(120);
$process->run();
if (! $process->isSuccessful()) {
$this->warn("mdb-export fallito per {$table}: " . $process->getErrorOutput());
return [];
}
$csv = $process->getOutput();
if ($csv === '') {
return [];
}
$fh = fopen('php://temp', 'r+');
fwrite($fh, $csv);
rewind($fh);
$header = fgetcsv($fh);
if (! is_array($header)) {
return [];
}
$header = array_map(static fn($value) => trim((string) $value), $header);
$rows = [];
while (($row = fgetcsv($fh)) !== false) {
if ($row === [null]) {
continue;
}
$assoc = [];
foreach ($header as $index => $column) {
$assoc[$column] = $row[$index] ?? null;
}
$rows[] = $assoc;
}
fclose($fh);
return $rows;
}
private function resolveStabileId(?string $cod): ?int
{
if ($cod === null || $cod === '') {
return null;
}
$query = Stabile::query();
$variants = array_values(array_unique(array_filter([
$cod,
ltrim($cod, '0'),
str_pad(ltrim($cod, '0') !== '' ? ltrim($cod, '0') : $cod, 4, '0', STR_PAD_LEFT),
], static fn($value) => $value !== '')));
foreach ($variants as $variant) {
$id = $query->clone()->where('codice_stabile', $variant)->value('id');
if ($id) {
return (int) $id;
}
$id = $query->clone()->where('cod_stabile', $variant)->value('id');
if ($id) {
return (int) $id;
}
}
return null;
}
private function normalizeString(mixed $value): ?string
{
$normalized = trim((string) $value);
return $normalized !== '' ? $normalized : null;
}
private function toInt(mixed $value): int
{
$normalized = $this->normalizeString($value);
return $normalized !== null ? (int) preg_replace('/[^0-9-]+/', '', $normalized) : 0;
}
private function toDecimal(mixed $value): ?float
{
$normalized = $this->normalizeString($value);
if ($normalized === null) {
return null;
}
if (str_contains($normalized, ',') && str_contains($normalized, '.')) {
$normalized = str_replace('.', '', $normalized);
$normalized = str_replace(',', '.', $normalized);
} elseif (str_contains($normalized, ',')) {
$normalized = str_replace(',', '.', $normalized);
}
return is_numeric($normalized) ? round((float) $normalized, 2) : null;
}
private function toBoolean(mixed $value): bool
{
$normalized = strtolower($this->normalizeString($value) ?? '');
return in_array($normalized, ['1', 'si', 'sì', 'true', 'yes', 'y'], true);
}
private function toDate(mixed $value): ?string
{
$normalized = $this->normalizeString($value);
if ($normalized === null) {
return null;
}
$formats = ['m/d/y H:i:s', 'm/d/Y H:i:s', 'm/d/y', 'm/d/Y'];
foreach ($formats as $format) {
$date = \DateTimeImmutable::createFromFormat($format, $normalized);
if ($date instanceof \DateTimeImmutable) {
return $date->format('Y-m-d');
}
}
return null;
}
private function toTime(mixed $value): ?string
{
$normalized = $this->normalizeString($value);
if ($normalized === null) {
return null;
}
$formats = ['H:i:s', 'H:i'];
foreach ($formats as $format) {
$date = \DateTimeImmutable::createFromFormat($format, $normalized);
if ($date instanceof \DateTimeImmutable) {
return $date->format('H:i:s');
}
}
return null;
}
private function resolveGestioneTipo(mixed $value): ?string
{
return match (strtoupper($this->normalizeString($value) ?? '')) {
'O' => 'ordinaria',
'S' => 'straordinaria',
default => $this->normalizeString($value),
};
}
private function resolveNaturaLabel(?string $value): ?string
{
return match (strtoupper($value ?? '')) {
'F' => 'Versamento F24',
'R' => 'Rata / emissione rata',
default => $this->normalizeString($value),
};
}
}

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Amministratore; use App\Models\Amministratore;
@ -17,6 +16,7 @@ class NetgesconPreupdateBackupCommand extends Command
{--admin-id= : ID amministratore per backup su Google Drive} {--admin-id= : ID amministratore per backup su Google Drive}
{--differential : Copia solo file cambiati rispetto all ultimo snapshot} {--differential : Copia solo file cambiati rispetto all ultimo snapshot}
{--drive : Carica backup anche su Google Drive} {--drive : Carica backup anche su Google Drive}
{--require-drive : Fallisce se il backup Google Drive non viene completato}
{--tag=auto : Etichetta operazione (es. auto/manual)} {--tag=auto : Etichetta operazione (es. auto/manual)}
{--tables=users,amministratori,stabili,fornitori,tickets,ticket_messages,ticket_attachments,rubrica_universale,fornitore_dipendenti : Tabelle da esportare}'; {--tables=users,amministratori,stabili,fornitori,tickets,ticket_messages,ticket_attachments,rubrica_universale,fornitore_dipendenti : Tabelle da esportare}';
@ -88,6 +88,8 @@ public function handle(): int
'snapshot_dir' => $snapshotDir, 'snapshot_dir' => $snapshotDir,
'zip_path' => $zipPath, 'zip_path' => $zipPath,
'created_at' => now()->toIso8601String(), 'created_at' => now()->toIso8601String(),
'copied_files' => $copiedCount,
'tables' => $dbSummary,
'files_manifest' => $manifest, 'files_manifest' => $manifest,
]; ];
file_put_contents($latestPath, json_encode($latestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); file_put_contents($latestPath, json_encode($latestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
@ -95,19 +97,29 @@ public function handle(): int
$this->info('Backup locale creato: ' . $zipPath); $this->info('Backup locale creato: ' . $zipPath);
$driveInfo = null; $driveInfo = null;
$requireDrive = (bool) $this->option('require-drive');
if ((bool) $this->option('drive')) { if ((bool) $this->option('drive')) {
$adminId = (int) $this->option('admin-id'); $adminId = (int) $this->option('admin-id');
if ($adminId <= 0) { if ($adminId <= 0) {
$this->warn('Drive upload saltato: --admin-id mancante.'); $this->warn('Drive upload saltato: --admin-id mancante.');
if ($requireDrive) {
return self::FAILURE;
}
} else { } else {
$driveInfo = $this->uploadBackupToDrive($adminId, $zipPath, $snapshotId); $driveInfo = $this->uploadBackupToDrive($adminId, $zipPath, $snapshotId);
if (is_array($driveInfo)) { if (is_array($driveInfo)) {
$this->info('Backup caricato su Drive: ' . (string) ($driveInfo['webViewLink'] ?? $driveInfo['id'] ?? 'ok')); $this->info('Backup caricato su Drive: ' . (string) ($driveInfo['webViewLink'] ?? $driveInfo['id'] ?? 'ok'));
} else { } else {
$this->warn('Upload Drive non riuscito.'); $this->warn('Upload Drive non riuscito.');
if ($requireDrive) {
return self::FAILURE;
} }
} }
} }
}
$latestPayload['drive'] = $driveInfo;
file_put_contents($latestPath, json_encode($latestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$result = [ $result = [
'snapshot_id' => $snapshotId, 'snapshot_id' => $snapshotId,
@ -381,10 +393,8 @@ private function uploadDriveFile(string $token, string $parentId, string $name,
$content .= "--{$boundary}--"; $content .= "--{$boundary}--";
$response = Http::withToken($token) $response = Http::withToken($token)
->withHeaders([ ->withBody($content, 'multipart/related; boundary=' . $boundary)
'Content-Type' => 'multipart/related; boundary=' . $boundary, ->send('POST', 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,name,webViewLink');
])
->post('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,name,webViewLink', $content);
if (! $response->successful()) { if (! $response->successful()) {
$this->warn('Upload Drive fallito: HTTP ' . $response->status()); $this->warn('Upload Drive fallito: HTTP ' . $response->status());
@ -398,11 +408,11 @@ private function uploadDriveFile(string $token, string $parentId, string $name,
private function resolveGoogleAccessToken(Amministratore $admin, array $google, array $oauth): ?string private function resolveGoogleAccessToken(Amministratore $admin, array $google, array $oauth): ?string
{ {
$accessToken = trim((string) Arr::get($oauth, 'access_token', '')); $accessToken = trim((string) Arr::get($oauth, 'access_token', ''));
if ($accessToken !== '') { $refreshToken = trim((string) Arr::get($oauth, 'refresh_token', ''));
if ($refreshToken === '' && $accessToken !== '') {
return $accessToken; return $accessToken;
} }
$refreshToken = trim((string) Arr::get($oauth, 'refresh_token', ''));
if ($refreshToken === '') { if ($refreshToken === '') {
return null; return null;
} }

View File

@ -3,6 +3,7 @@
use App\Filament\Pages\Contabilita\FatturaElettronicaScheda; use App\Filament\Pages\Contabilita\FatturaElettronicaScheda;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\Stabile;
use App\Models\StabileServizio; use App\Models\StabileServizio;
use App\Models\StabileServizioLettura; use App\Models\StabileServizioLettura;
use App\Models\UnitaImmobiliare; use App\Models\UnitaImmobiliare;
@ -30,6 +31,7 @@ class LettureServiziArchivio extends Page implements HasTable
use InteractsWithTable; use InteractsWithTable;
public ?int $servizioFilter = null; public ?int $servizioFilter = null;
public string $archivioScope = 'active';
protected static ?string $navigationLabel = 'Letture servizi'; protected static ?string $navigationLabel = 'Letture servizi';
@ -58,6 +60,8 @@ public function mount(): void
{ {
$reqServizio = request()->integer('servizio'); $reqServizio = request()->integer('servizio');
$this->servizioFilter = $reqServizio > 0 ? $reqServizio : null; $this->servizioFilter = $reqServizio > 0 ? $reqServizio : null;
$scope = strtolower(trim((string) request()->query('scope', 'active')));
$this->archivioScope = in_array($scope, ['active', 'all'], true) ? $scope : 'active';
$this->mountInteractsWithTable(); $this->mountInteractsWithTable();
} }
@ -69,15 +73,30 @@ protected function getTableQuery(): Builder
return StabileServizioLettura::query()->whereRaw('1 = 0'); return StabileServizioLettura::query()->whereRaw('1 = 0');
} }
$stabileId = StabileContext::resolveActiveStabileId($user); $stabiliIds = StabileContext::accessibleStabili($user)
if (! $stabileId) { ->pluck('id')
->map(fn($id) => (int) $id)
->filter(fn(int $id): bool => $id > 0)
->values();
if ($stabiliIds->isEmpty()) {
return StabileServizioLettura::query()->whereRaw('1 = 0');
}
$activeStabileId = StabileContext::resolveActiveStabileId($user);
if ($this->archivioScope !== 'all' && ! $activeStabileId) {
return StabileServizioLettura::query()->whereRaw('1 = 0'); return StabileServizioLettura::query()->whereRaw('1 = 0');
} }
return StabileServizioLettura::query() return StabileServizioLettura::query()
->where('stabile_id', (int) $stabileId) ->when(
$this->archivioScope === 'all',
fn(Builder $q) => $q->whereIn('stabile_id', $stabiliIds->all()),
fn(Builder $q) => $q->where('stabile_id', (int) $activeStabileId)
)
->when($this->servizioFilter, fn(Builder $q) => $q->where('stabile_servizio_id', (int) $this->servizioFilter)) ->when($this->servizioFilter, fn(Builder $q) => $q->where('stabile_servizio_id', (int) $this->servizioFilter))
->with([ ->with([
'stabile:id,codice_stabile,denominazione',
'servizio:id,stabile_id,tipo,nome,contatore_matricola', 'servizio:id,stabile_id,tipo,nome,contatore_matricola',
'fornitore:id,ragione_sociale', 'fornitore:id,ragione_sociale',
'voceSpesa:id,descrizione,tipo_gestione', 'voceSpesa:id,descrizione,tipo_gestione',
@ -130,6 +149,19 @@ public function table(Table $table): Table
return $query->whereHas('servizio', fn(Builder $q) => $q->where('tipo', $value)); return $query->whereHas('servizio', fn(Builder $q) => $q->where('tipo', $value));
}), }),
SelectFilter::make('stabile_id')
->label('Stabile')
->options(fn() => $this->getStabiliOptions())
->searchable()
->query(function (Builder $query, array $data): Builder {
$value = (int) ($data['value'] ?? 0);
if ($value <= 0) {
return $query;
}
return $query->where('stabile_id', $value);
}),
SelectFilter::make('tipo_gestione') SelectFilter::make('tipo_gestione')
->label('Gestione (O/R/S)') ->label('Gestione (O/R/S)')
->options($gestioneOptions) ->options($gestioneOptions)
@ -146,6 +178,22 @@ public function table(Table $table): Table
TextColumn::make('periodo_dal')->label('Dal')->date('d/m/Y')->sortable()->toggleable(), TextColumn::make('periodo_dal')->label('Dal')->date('d/m/Y')->sortable()->toggleable(),
TextColumn::make('periodo_al')->label('Al')->date('d/m/Y')->sortable(), TextColumn::make('periodo_al')->label('Al')->date('d/m/Y')->sortable(),
TextColumn::make('stabile.denominazione')
->label('Stabile')
->formatStateUsing(function ($state, StabileServizioLettura $record): string {
$stabile = $record->stabile;
if (! $stabile instanceof Stabile) {
return '—';
}
$codice = trim((string) ($stabile->codice_stabile ?? ''));
$nome = trim((string) ($stabile->denominazione ?? ''));
return trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id);
})
->wrap()
->toggleable(isToggledHiddenByDefault: $this->archivioScope !== 'all'),
TextColumn::make('servizio.nome') TextColumn::make('servizio.nome')
->label('Servizio') ->label('Servizio')
->formatStateUsing(function ($state, StabileServizioLettura $record): string { ->formatStateUsing(function ($state, StabileServizioLettura $record): string {
@ -217,8 +265,38 @@ public function table(Table $table): Table
}) })
->toggleable(), ->toggleable(),
TextColumn::make('workflow_stato')
->label('Workflow')
->formatStateUsing(fn($state): string => trim((string) $state) !== '' ? (string) $state : 'Acquisita')
->badge()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('protocollo_numero')
->label('Protocollo')
->formatStateUsing(function ($state, StabileServizioLettura $record): string {
$numero = trim((string) ($state ?? ''));
$categoria = trim((string) ($record->protocollo_categoria ?? ''));
if ($numero === '' && $categoria === '') {
return '—';
}
return trim($categoria . ($numero !== '' ? (' #' . $numero) : ''));
})
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('prossima_lettura_scadenza_at')
->label('Scadenza prossima lettura')
->dateTime('d/m/Y H:i')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('riferimento_acquisizione')->label('Riferimento')->toggleable(isToggledHiddenByDefault: true), TextColumn::make('riferimento_acquisizione')->label('Riferimento')->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('archivio_documentale_path')
->label('Archivio documentale')
->wrap()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('fatturaElettronica.numero_fattura') TextColumn::make('fatturaElettronica.numero_fattura')
->label('Fattura FE') ->label('Fattura FE')
->url(fn(StabileServizioLettura $record): ?string => $record->fattura_elettronica_id ->url(fn(StabileServizioLettura $record): ?string => $record->fattura_elettronica_id
@ -238,6 +316,14 @@ public function table(Table $table): Table
TextColumn::make('created_at')->label('Creato')->dateTime('d/m/Y H:i')->toggleable(isToggledHiddenByDefault: true), TextColumn::make('created_at')->label('Creato')->dateTime('d/m/Y H:i')->toggleable(isToggledHiddenByDefault: true),
]) ])
->headerActions([ ->headerActions([
Action::make('toggleScope')
->label(fn(): string => $this->archivioScope === 'all' ? 'Vista: tutti gli stabili' : 'Vista: stabile attivo')
->icon('heroicon-o-building-office-2')
->color(fn(): string => $this->archivioScope === 'all' ? 'primary' : 'gray')
->action(function (): void {
$this->archivioScope = $this->archivioScope === 'all' ? 'active' : 'all';
}),
Action::make('create') Action::make('create')
->label('Nuova') ->label('Nuova')
->icon('heroicon-o-plus') ->icon('heroicon-o-plus')
@ -274,7 +360,23 @@ public function table(Table $table): Table
'm_bus' => 'Contatore elettronico', 'm_bus' => 'Contatore elettronico',
'pdf_ocr' => 'PDF/OCR', 'pdf_ocr' => 'PDF/OCR',
]), ]),
Select::make('workflow_stato')
->label('Workflow')
->options([
'acquisita' => 'Acquisita',
'da_richiedere' => 'Da richiedere',
'richiesta_inviata' => 'Richiesta inviata',
'ricevuta' => 'Ricevuta',
'protocollata' => 'Protocollata',
'archiviata' => 'Archiviata',
])
->default('acquisita'),
TextInput::make('protocollo_categoria')->label('Categoria protocollo')->maxLength(40),
TextInput::make('protocollo_numero')->label('Numero protocollo')->maxLength(50),
TextInput::make('riferimento_acquisizione')->label('Riferimento acquisizione')->maxLength(191), TextInput::make('riferimento_acquisizione')->label('Riferimento acquisizione')->maxLength(191),
DatePicker::make('richiesta_lettura_inviata_at')->label('Richiesta inviata il')->native(false),
DatePicker::make('prossima_lettura_scadenza_at')->label('Prossima scadenza lettura')->native(false),
TextInput::make('archivio_documentale_path')->label('Path archivio documentale')->maxLength(500),
TextInput::make('lettura_inizio')->label('Lettura inizio')->numeric(), TextInput::make('lettura_inizio')->label('Lettura inizio')->numeric(),
TextInput::make('lettura_fine')->label('Lettura fine')->numeric(), TextInput::make('lettura_fine')->label('Lettura fine')->numeric(),
TextInput::make('consumo_valore')->label('Consumo valore')->numeric(), TextInput::make('consumo_valore')->label('Consumo valore')->numeric(),
@ -302,7 +404,13 @@ public function table(Table $table): Table
'periodo_al' => $data['periodo_al'] ?? null, 'periodo_al' => $data['periodo_al'] ?? null,
'tipologia_lettura' => $data['tipologia_lettura'] ?? null, 'tipologia_lettura' => $data['tipologia_lettura'] ?? null,
'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: 'manuale', 'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: 'manuale',
'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: 'acquisita',
'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null,
'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null,
'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null, 'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null,
'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null,
'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null,
'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null,
'lettura_inizio' => $data['lettura_inizio'] ?? null, 'lettura_inizio' => $data['lettura_inizio'] ?? null,
'lettura_fine' => $data['lettura_fine'] ?? null, 'lettura_fine' => $data['lettura_fine'] ?? null,
'consumo_valore' => $data['consumo_valore'] ?? null, 'consumo_valore' => $data['consumo_valore'] ?? null,
@ -365,7 +473,13 @@ public function table(Table $table): Table
'periodo_al' => $record->periodo_al, 'periodo_al' => $record->periodo_al,
'tipologia_lettura' => (string) ($record->tipologia_lettura ?? ''), 'tipologia_lettura' => (string) ($record->tipologia_lettura ?? ''),
'canale_acquisizione' => (string) ($record->canale_acquisizione ?? ''), 'canale_acquisizione' => (string) ($record->canale_acquisizione ?? ''),
'workflow_stato' => (string) ($record->workflow_stato ?? ''),
'protocollo_categoria' => (string) ($record->protocollo_categoria ?? ''),
'protocollo_numero' => (string) ($record->protocollo_numero ?? ''),
'riferimento_acquisizione' => (string) ($record->riferimento_acquisizione ?? ''), 'riferimento_acquisizione' => (string) ($record->riferimento_acquisizione ?? ''),
'richiesta_lettura_inviata_at' => $record->richiesta_lettura_inviata_at,
'prossima_lettura_scadenza_at' => $record->prossima_lettura_scadenza_at,
'archivio_documentale_path' => (string) ($record->archivio_documentale_path ?? ''),
'lettura_inizio' => $record->lettura_inizio, 'lettura_inizio' => $record->lettura_inizio,
'lettura_fine' => $record->lettura_fine, 'lettura_fine' => $record->lettura_fine,
'consumo_valore' => $record->consumo_valore, 'consumo_valore' => $record->consumo_valore,
@ -382,7 +496,13 @@ public function table(Table $table): Table
'periodo_al' => $data['periodo_al'] ?? null, 'periodo_al' => $data['periodo_al'] ?? null,
'tipologia_lettura' => $data['tipologia_lettura'] ?? null, 'tipologia_lettura' => $data['tipologia_lettura'] ?? null,
'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: null, 'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: null,
'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: null,
'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null,
'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null,
'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null, 'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null,
'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null,
'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null,
'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null,
'lettura_inizio' => $data['lettura_inizio'] ?? null, 'lettura_inizio' => $data['lettura_inizio'] ?? null,
'lettura_fine' => $data['lettura_fine'] ?? null, 'lettura_fine' => $data['lettura_fine'] ?? null,
'consumo_valore' => $data['consumo_valore'] ?? null, 'consumo_valore' => $data['consumo_valore'] ?? null,
@ -436,6 +556,27 @@ private function getServiziOptions(): array
->all(); ->all();
} }
/**
* @return array<int, string>
*/
private function getStabiliOptions(): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
return StabileContext::accessibleStabili($user)
->mapWithKeys(function (Stabile $stabile): array {
$codice = trim((string) ($stabile->codice_stabile ?? ''));
$nome = trim((string) ($stabile->denominazione ?? ''));
$label = trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id);
return [(int) $stabile->id => $label];
})
->all();
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */

View File

@ -24,6 +24,9 @@ class FornitoriArchivio extends Page
public string $activeTab = 'elenco'; public string $activeTab = 'elenco';
public ?int $selectedFornitoreId = null; public ?int $selectedFornitoreId = null;
/** @var Collection<int, Fornitore> */
public $fornitoreMatches;
/** @var array<string, string> */ /** @var array<string, string> */
public array $automazioni = [ public array $automazioni = [
'fornitore_acqua' => '0', 'fornitore_acqua' => '0',
@ -64,6 +67,9 @@ class FornitoriArchivio extends Page
public string $newDipendenteEmail = ''; public string $newDipendenteEmail = '';
public string $newDipendenteTelefono = ''; public string $newDipendenteTelefono = '';
/** @var array<int, array<string, mixed>> */
public array $dipendenteInline = [];
protected static ?string $navigationLabel = 'Fornitori'; protected static ?string $navigationLabel = 'Fornitori';
protected static ?string $title = 'Fornitori'; protected static ?string $title = 'Fornitori';
@ -93,7 +99,9 @@ public function mount(): void
$this->fornitoriCount = 0; $this->fornitoriCount = 0;
} }
$this->fornitoreMatches = new Collection();
$this->searchInput = $this->fornitoriSearch; $this->searchInput = $this->fornitoriSearch;
$this->searchFornitoreMatches();
} }
protected function getTableQuery(): Builder protected function getTableQuery(): Builder
@ -131,31 +139,22 @@ public function getFornitoriRowsProperty(): Collection
->orderBy('cognome') ->orderBy('cognome')
->orderBy('nome'); ->orderBy('nome');
$term = trim($this->fornitoriSearch); $this->applyFornitoreSearch($query, trim($this->fornitoriSearch));
if ($term !== '') {
$hasTags = Schema::hasColumn('fornitori', 'tags');
$like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%';
$query->where(function (Builder $sub) use ($like, $hasTags): void {
$sub->where('ragione_sociale', 'like', $like)
->orWhere('nome', 'like', $like)
->orWhere('cognome', 'like', $like)
->orWhere('partita_iva', 'like', $like)
->orWhere('codice_fiscale', 'like', $like)
->orWhere('email', 'like', $like)
->orWhere('telefono', 'like', $like);
if ($hasTags) {
$sub->orWhere('tags', 'like', $like);
}
});
}
return $query->limit(400)->get(); return $query->limit(400)->get();
} }
public function updatedFornitoriSearch(): void public function updatedFornitoriSearch(): void
{} {
$this->searchInput = $this->fornitoriSearch;
$this->searchFornitoreMatches();
}
public function updatedSearchInput(): void
{
$this->fornitoriSearch = trim($this->searchInput);
$this->searchFornitoreMatches();
}
public function getFornitoreLabel(Fornitore $fornitore): string public function getFornitoreLabel(Fornitore $fornitore): string
{ {
@ -277,6 +276,12 @@ public function apriElenco(): void
$this->activeTab = 'elenco'; $this->activeTab = 'elenco';
} }
public function apriNuovoFornitore(): void
{
$this->selectedFornitoreId = null;
$this->activeTab = 'scheda';
}
public function apriTabScheda(): void public function apriTabScheda(): void
{ {
if ((int) ($this->selectedFornitoreId ?? 0) <= 0) { if ((int) ($this->selectedFornitoreId ?? 0) <= 0) {
@ -310,6 +315,7 @@ public function apriTabDipendenti(): void
public function applicaRicerca(): void public function applicaRicerca(): void
{ {
$this->fornitoriSearch = trim($this->searchInput); $this->fornitoriSearch = trim($this->searchInput);
$this->searchFornitoreMatches();
$this->activeTab = 'elenco'; $this->activeTab = 'elenco';
} }
@ -317,6 +323,7 @@ public function pulisciRicerca(): void
{ {
$this->fornitoriSearch = ''; $this->fornitoriSearch = '';
$this->searchInput = ''; $this->searchInput = '';
$this->searchFornitoreMatches();
$this->activeTab = 'elenco'; $this->activeTab = 'elenco';
} }
@ -329,6 +336,7 @@ public function removeSearchFilter(string $token): void
$this->fornitoriSearch = implode(' e ', $tokens); $this->fornitoriSearch = implode(' e ', $tokens);
$this->searchInput = $this->fornitoriSearch; $this->searchInput = $this->fornitoriSearch;
$this->searchFornitoreMatches();
$this->activeTab = 'elenco'; $this->activeTab = 'elenco';
} }
@ -343,12 +351,15 @@ public function apriScheda(int $fornitoreId): void
$this->selectedFornitoreId = (int) $fornitore->id; $this->selectedFornitoreId = (int) $fornitore->id;
$this->activeTab = 'scheda'; $this->activeTab = 'scheda';
$this->loadSchedaState($fornitore); $this->loadSchedaState($fornitore);
$this->loadDipendentiInlineState();
} }
public function updatedSelectedFornitoreId(): void public function updatedSelectedFornitoreId(): void
{ {
if (in_array($this->activeTab, ['scheda', 'dipendenti'], true)) {
$this->hydrateSchedaFromSelected(); $this->hydrateSchedaFromSelected();
} }
}
public function createFornitoreRapido(): void public function createFornitoreRapido(): void
{ {
@ -402,6 +413,9 @@ public function createFornitoreRapido(): void
$this->fornitoriCount = (int) $this->getTableQuery()->count(); $this->fornitoriCount = (int) $this->getTableQuery()->count();
$this->apriScheda((int) $fornitore->id); $this->apriScheda((int) $fornitore->id);
$this->searchInput = $this->getFornitoreLabel($fornitore);
$this->fornitoriSearch = $this->searchInput;
$this->searchFornitoreMatches();
Notification::make()->title('Fornitore creato')->success()->send(); Notification::make()->title('Fornitore creato')->success()->send();
} }
@ -462,6 +476,7 @@ public function saveSchedaAnagrafica(): void
$fornitore->save(); $fornitore->save();
$this->loadSchedaState($fornitore->fresh()); $this->loadSchedaState($fornitore->fresh());
$this->searchFornitoreMatches();
Notification::make()->title('Anagrafica fornitore aggiornata')->success()->send(); Notification::make()->title('Anagrafica fornitore aggiornata')->success()->send();
} }
@ -506,10 +521,43 @@ public function addDipendenteManuale(): void
$this->newDipendenteCognome = ''; $this->newDipendenteCognome = '';
$this->newDipendenteEmail = ''; $this->newDipendenteEmail = '';
$this->newDipendenteTelefono = ''; $this->newDipendenteTelefono = '';
$this->loadDipendentiInlineState();
Notification::make()->title('Dipendente aggiunto')->success()->send(); Notification::make()->title('Dipendente aggiunto')->success()->send();
} }
public function createDipendenteRubricaAndLink(): void
{
$fornitore = $this->selectedFornitore;
if (! $fornitore instanceof Fornitore) {
Notification::make()->title('Seleziona prima un fornitore')->warning()->send();
return;
}
$nome = trim($this->newDipendenteNome);
if ($nome === '') {
Notification::make()->title('Nome nominativo obbligatorio')->warning()->send();
return;
}
$rubrica = RubricaUniversale::query()->create([
'nome' => $nome,
'cognome' => $this->cleanNullable($this->newDipendenteCognome),
'email' => $this->cleanNullable($this->newDipendenteEmail),
'telefono_cellulare' => $this->cleanNullable($this->newDipendenteTelefono),
'tipo_contatto' => 'persona_fisica',
'categoria' => 'fornitore',
'stato' => 'attivo',
'note' => 'Creato da Fornitori > Dipendenti per il fornitore #' . (int) $fornitore->id,
'data_inserimento' => now()->toDateString(),
'data_ultima_modifica' => now()->toDateString(),
'creato_da' => Auth::id(),
'modificato_da' => Auth::id(),
]);
$this->addDipendenteFromRubrica((int) $rubrica->id);
}
public function addDipendenteFromRubrica(int $rubricaId): void public function addDipendenteFromRubrica(int $rubricaId): void
{ {
$fornitore = $this->selectedFornitore; $fornitore = $this->selectedFornitore;
@ -581,10 +629,51 @@ public function addDipendenteFromRubrica(int $rubricaId): void
$dipendente->note = Str::limit(trim($linkNote . ($existingNote !== '' ? (' | ' . $existingNote) : '')), 2000, ''); $dipendente->note = Str::limit(trim($linkNote . ($existingNote !== '' ? (' | ' . $existingNote) : '')), 2000, '');
$dipendente->save(); $dipendente->save();
$this->loadDipendentiInlineState();
Notification::make()->title('Dipendente collegato al fornitore')->success()->send(); Notification::make()->title('Dipendente collegato al fornitore')->success()->send();
$this->activeTab = 'dipendenti'; $this->activeTab = 'dipendenti';
} }
public function saveDipendenteInline(int $dipendenteId): void
{
$fornitore = $this->selectedFornitore;
if (! $fornitore instanceof Fornitore) {
Notification::make()->title('Seleziona prima un fornitore')->warning()->send();
return;
}
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $fornitore->id)
->find($dipendenteId);
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Dipendente non trovato')->danger()->send();
return;
}
$state = $this->dipendenteInline[$dipendenteId] ?? [];
$data = Validator::make($state, [
'nome' => ['required', 'string', 'max:255'],
'cognome' => ['nullable', 'string', 'max:255'],
'email' => ['nullable', 'email', 'max:255'],
'telefono' => ['nullable', 'string', 'max:64'],
'attivo' => ['nullable', 'boolean'],
])->validate();
$dipendente->nome = trim((string) ($data['nome'] ?? ''));
$dipendente->cognome = $this->cleanNullable($data['cognome'] ?? null);
$dipendente->email = $this->cleanNullable($data['email'] ?? null);
$dipendente->telefono = $this->cleanNullable($data['telefono'] ?? null);
$dipendente->attivo = (bool) ($data['attivo'] ?? false);
$dipendente->updated_by_user_id = Auth::id();
$dipendente->save();
$this->loadDipendentiInlineState();
Notification::make()->title('Dipendente aggiornato')->success()->send();
}
public function saveSchedaAutomazioni(): void public function saveSchedaAutomazioni(): void
{ {
$fornitore = $this->selectedFornitore; $fornitore = $this->selectedFornitore;
@ -654,6 +743,23 @@ private function loadSchedaState(Fornitore $fornitore): void
$this->editNote = (string) ($fornitore->note ?? ''); $this->editNote = (string) ($fornitore->note ?? '');
} }
private function loadDipendentiInlineState(): void
{
$rows = $this->getDipendentiRowsProperty();
$this->dipendenteInline = $rows->mapWithKeys(function (FornitoreDipendente $dipendente): array {
return [
(int) $dipendente->id => [
'nome' => (string) ($dipendente->nome ?? ''),
'cognome' => (string) ($dipendente->cognome ?? ''),
'email' => (string) ($dipendente->email ?? ''),
'telefono' => (string) ($dipendente->telefono ?? ''),
'attivo' => (bool) ($dipendente->attivo ?? false),
],
];
})->all();
}
private function resolveAmministratoreId(): int private function resolveAmministratoreId(): int
{ {
$user = Auth::user(); $user = Auth::user();
@ -670,7 +776,7 @@ private function resolveAmministratoreId(): int
return (int) ($stabile?->amministratore_id ?? 0); return (int) ($stabile?->amministratore_id ?? 0);
} }
private function hydrateSchedaFromSelected(): void public function hydrateSchedaFromSelected(): void
{ {
if ((int) ($this->selectedFornitoreId ?? 0) <= 0) { if ((int) ($this->selectedFornitoreId ?? 0) <= 0) {
return; return;
@ -682,6 +788,67 @@ private function hydrateSchedaFromSelected(): void
} }
$this->loadSchedaState($fornitore); $this->loadSchedaState($fornitore);
$this->loadDipendentiInlineState();
}
private function applyFornitoreSearch(Builder $query, string $raw): Builder
{
$tokens = $this->extractSearchTokens($raw);
if ($tokens === []) {
return $query;
}
$hasTags = Schema::hasColumn('fornitori', 'tags');
foreach ($tokens as $token) {
$term = trim($token);
if ($term === '') {
continue;
}
$digits = preg_replace('/\D+/', '', $term) ?: '';
$needleText = '%' . mb_strtolower($term) . '%';
$needle = '%' . $digits . '%';
$query->where(function (Builder $sub) use ($needleText, $needle, $digits, $hasTags): void {
$sub->whereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(partita_iva, '')) LIKE ?", [$needleText])
->orWhereRaw("LOWER(COALESCE(codice_fiscale, '')) LIKE ?", [$needleText]);
if ($hasTags) {
$sub->orWhereRaw("LOWER(COALESCE(tags, '')) LIKE ?", [$needleText]);
}
if ($digits !== '') {
$sub->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]);
}
});
}
return $query;
}
private function searchFornitoreMatches(): void
{
$raw = trim($this->searchInput !== '' ? $this->searchInput : $this->fornitoriSearch);
if ($raw === '') {
$this->fornitoreMatches = new Collection();
return;
}
$query = $this->getTableQuery()
->withCount('dipendenti')
->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedFornitoreId ?? 0)])
->orderBy('ragione_sociale')
->orderBy('cognome')
->orderBy('nome');
$this->fornitoreMatches = $this->applyFornitoreSearch($query, $raw)
->limit(12)
->get();
} }
private function cleanNullable(?string $value): ?string private function cleanNullable(?string $value): ?string

View File

@ -133,14 +133,39 @@ public function table(Table $table): Table
return $query; return $query;
} }
$like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search) . '%'; $tokens = preg_split('/(?:[,;|+]|
\s(?:e|ed|and)\s|
\s+
)+/ux', $search) ?: [];
$tokens = array_values(array_filter(array_map(static fn(string $value): string => trim($value), $tokens), static fn(string $value): bool => $value !== ''));
return $query->where(function (Builder $q) use ($like) { if ($tokens === []) {
$q->where('ragione_sociale', 'like', $like) $tokens = [$search];
->orWhere('nome', 'like', $like) }
->orWhere('cognome', 'like', $like)
->orWhereRaw("TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, ''))) LIKE ?", [$like]); foreach ($tokens as $token) {
$like = '%' . mb_strtolower($token) . '%';
$digits = preg_replace('/\D+/', '', $token) ?: '';
$query->where(function (Builder $q) use ($like, $digits) {
$q->whereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$like])
->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$like])
->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$like])
->orWhereRaw("LOWER(TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, '')))) LIKE ?", [$like])
->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$like])
->orWhereRaw("LOWER(COALESCE(pec, '')) LIKE ?", [$like])
->orWhereRaw("LOWER(COALESCE(codice_fiscale, '')) LIKE ?", [$like])
->orWhereRaw("LOWER(COALESCE(partita_iva, '')) LIKE ?", [$like]);
if ($digits !== '') {
$digitLike = '%' . $digits . '%';
$q->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$digitLike])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$digitLike])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$digitLike]);
}
}); });
}
return $query;
}), }),
TextColumn::make('categoria')->label('Categoria')->sortable()->toggleable(), TextColumn::make('categoria')->label('Categoria')->sortable()->toggleable(),
TextColumn::make('partita_iva')->label('P.IVA')->searchable()->toggleable(isToggledHiddenByDefault: true), TextColumn::make('partita_iva')->label('P.IVA')->searchable()->toggleable(isToggledHiddenByDefault: true),

View File

@ -3,6 +3,8 @@
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\FornitoreDipendente; use App\Models\FornitoreDipendente;
use App\Models\Persona;
use App\Models\PersonaEmailMultipla;
use App\Models\RubricaContattoCanale; use App\Models\RubricaContattoCanale;
use App\Models\RubricaRuolo; use App\Models\RubricaRuolo;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
@ -20,6 +22,7 @@
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilderContract;
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;
@ -91,6 +94,9 @@ class RubricaUniversaleScheda extends Page
/** @var array<string,mixed> */ /** @var array<string,mixed> */
public array $inlineForm = []; public array $inlineForm = [];
/** @var array<int, array<string, mixed>> */
public array $emailMultiple = [];
public function mount(int | string $record): void public function mount(int | string $record): void
{ {
$user = Auth::user(); $user = Auth::user();
@ -259,6 +265,7 @@ public function mount(int | string $record): void
]) ])
->all(); ->all();
$this->hydrateEmailMultiple();
$this->fillInlineForm(); $this->fillInlineForm();
$this->hydrateFornitoriWorkspace(); $this->hydrateFornitoriWorkspace();
} }
@ -277,6 +284,9 @@ public function cancelInlineEdit(): void
public function saveInlineEdit(): void public function saveInlineEdit(): void
{ {
$legacyIdentity = $this->extractLegacyIdentityFromNote($this->inlineForm['note'] ?? null);
$resolvedAddress = $this->parseAddressComponents($this->inlineForm['indirizzo_completo'] ?? null);
$data = validator($this->inlineForm, [ $data = validator($this->inlineForm, [
'nome' => ['nullable', 'string', 'max:255'], 'nome' => ['nullable', 'string', 'max:255'],
'cognome' => ['nullable', 'string', 'max:255'], 'cognome' => ['nullable', 'string', 'max:255'],
@ -285,11 +295,21 @@ public function saveInlineEdit(): void
'categoria' => ['nullable', 'string', 'max:120'], 'categoria' => ['nullable', 'string', 'max:120'],
'codice_fiscale' => ['nullable', 'string', 'max:32'], 'codice_fiscale' => ['nullable', 'string', 'max:32'],
'partita_iva' => ['nullable', 'string', 'max:32'], 'partita_iva' => ['nullable', 'string', 'max:32'],
'sesso' => ['nullable', 'string', 'max:1'],
'data_nascita' => ['nullable', 'date'],
'luogo_nascita' => ['nullable', 'string', 'max:100'],
'provincia_nascita' => ['nullable', 'string', 'max:2'],
'email' => ['nullable', 'string', 'max:255'], 'email' => ['nullable', 'string', 'max:255'],
'pec' => ['nullable', 'string', 'max:255'], 'pec' => ['nullable', 'string', 'max:255'],
'telefono_ufficio' => ['nullable', 'string', 'max:64'], 'telefono_ufficio' => ['nullable', 'string', 'max:64'],
'telefono_cellulare' => ['nullable', 'string', 'max:64'], 'telefono_cellulare' => ['nullable', 'string', 'max:64'],
'telefono_casa' => ['nullable', 'string', 'max:64'], 'telefono_casa' => ['nullable', 'string', 'max:64'],
'indirizzo' => ['nullable', 'string', 'max:255'],
'civico' => ['nullable', 'string', 'max:32'],
'cap' => ['nullable', 'string', 'max:5'],
'citta' => ['nullable', 'string', 'max:100'],
'provincia' => ['nullable', 'string', 'max:2'],
'nazione' => ['nullable', 'string', 'max:50'],
'indirizzo_completo' => ['nullable', 'string', 'max:255'], 'indirizzo_completo' => ['nullable', 'string', 'max:255'],
'tipo_utenza_call' => ['nullable', 'string', 'max:120'], 'tipo_utenza_call' => ['nullable', 'string', 'max:120'],
'riferimento_stabile' => ['nullable', 'string', 'max:255'], 'riferimento_stabile' => ['nullable', 'string', 'max:255'],
@ -298,6 +318,17 @@ public function saveInlineEdit(): void
'note_segreteria' => ['nullable', 'string', 'max:5000'], 'note_segreteria' => ['nullable', 'string', 'max:5000'],
])->validate(); ])->validate();
$normalizedBirthDate = $this->normalizeLegacyDateValue($data['data_nascita'] ?? $legacyIdentity['data_nascita'] ?? null);
$cleanNote = $this->cleanNullable($legacyIdentity['note_clean'] ?? ($data['note'] ?? null));
$sesso = $this->normalizeSessoValue($data['sesso'] ?? $legacyIdentity['sesso'] ?? null);
$provinciaNascita = $this->normalizeProvince($data['provincia_nascita'] ?? $legacyIdentity['provincia_nascita'] ?? null);
$indirizzo = $this->cleanNullable($data['indirizzo'] ?? null) ?: ($resolvedAddress['indirizzo'] ?? null);
$civico = $this->cleanNullable($data['civico'] ?? null) ?: ($resolvedAddress['civico'] ?? null);
$cap = $this->cleanNullable($data['cap'] ?? null) ?: ($resolvedAddress['cap'] ?? null);
$citta = $this->cleanNullable($data['citta'] ?? null) ?: ($resolvedAddress['citta'] ?? null);
$provincia = $this->normalizeProvince($data['provincia'] ?? null) ?: ($resolvedAddress['provincia'] ?? null);
$this->rubrica->fill([ $this->rubrica->fill([
'nome' => $this->cleanNullable($data['nome'] ?? null), 'nome' => $this->cleanNullable($data['nome'] ?? null),
'cognome' => $this->cleanNullable($data['cognome'] ?? null), 'cognome' => $this->cleanNullable($data['cognome'] ?? null),
@ -306,21 +337,34 @@ public function saveInlineEdit(): void
'categoria' => $this->cleanNullable($data['categoria'] ?? null), 'categoria' => $this->cleanNullable($data['categoria'] ?? null),
'codice_fiscale' => $this->cleanNullable($data['codice_fiscale'] ?? null), 'codice_fiscale' => $this->cleanNullable($data['codice_fiscale'] ?? null),
'partita_iva' => $this->cleanNullable($data['partita_iva'] ?? null), 'partita_iva' => $this->cleanNullable($data['partita_iva'] ?? null),
'sesso' => $sesso,
'data_nascita' => $normalizedBirthDate,
'luogo_nascita' => $this->cleanNullable($data['luogo_nascita'] ?? $legacyIdentity['luogo_nascita'] ?? null),
'provincia_nascita' => $provinciaNascita,
'email' => $this->cleanNullable($data['email'] ?? null), 'email' => $this->cleanNullable($data['email'] ?? null),
'pec' => $this->cleanNullable($data['pec'] ?? null), 'pec' => $this->cleanNullable($data['pec'] ?? null),
'telefono_ufficio' => $this->cleanNullable($data['telefono_ufficio'] ?? null), 'telefono_ufficio' => $this->cleanNullable($data['telefono_ufficio'] ?? null),
'telefono_cellulare' => $this->cleanNullable($data['telefono_cellulare'] ?? null), 'telefono_cellulare' => $this->cleanNullable($data['telefono_cellulare'] ?? null),
'telefono_casa' => $this->cleanNullable($data['telefono_casa'] ?? null), 'telefono_casa' => $this->cleanNullable($data['telefono_casa'] ?? null),
'indirizzo_completo' => $this->cleanNullable($data['indirizzo_completo'] ?? null), 'indirizzo' => $indirizzo,
'civico' => $civico,
'cap' => $cap,
'citta' => $citta,
'provincia' => $provincia,
'nazione' => $this->cleanNullable($data['nazione'] ?? null) ?: $this->rubrica->nazione,
'tipo_utenza_call' => $this->cleanNullable($data['tipo_utenza_call'] ?? null), 'tipo_utenza_call' => $this->cleanNullable($data['tipo_utenza_call'] ?? null),
'riferimento_stabile' => $this->cleanNullable($data['riferimento_stabile'] ?? null), 'riferimento_stabile' => $this->cleanNullable($data['riferimento_stabile'] ?? null),
'riferimento_unita' => $this->cleanNullable($data['riferimento_unita'] ?? null), 'riferimento_unita' => $this->cleanNullable($data['riferimento_unita'] ?? null),
'note' => $this->cleanNullable($data['note'] ?? null), 'note' => $cleanNote,
'note_segreteria' => $this->cleanNullable($data['note_segreteria'] ?? null), 'note_segreteria' => $this->cleanNullable($data['note_segreteria'] ?? null),
'data_ultima_modifica' => now()->toDateString(), 'data_ultima_modifica' => now()->toDateString(),
'modificato_da' => Auth::id(), 'modificato_da' => Auth::id(),
]); ]);
if (! $this->rubrica->titolo_id && ! empty($legacyIdentity['titolo_id'])) {
$this->rubrica->titolo_id = (int) $legacyIdentity['titolo_id'];
}
$this->rubrica->save(); $this->rubrica->save();
$this->mount((int) $this->rubrica->id); $this->mount((int) $this->rubrica->id);
@ -547,6 +591,53 @@ protected function getHeaderActions(): array
$this->startInlineEdit(); $this->startInlineEdit();
}), }),
Action::make('email_multiple')
->label('Email aggiuntive')
->icon('heroicon-o-envelope')
->modalWidth('4xl')
->form([
Repeater::make('emails')
->label('Email aggiuntive')
->defaultItems(0)
->reorderable(true)
->schema([
Hidden::make('id'),
TextInput::make('email')
->label('Email')
->email()
->required()
->maxLength(255)
->columnSpan(6),
Select::make('tipo_email')
->label('Tipo')
->native(false)
->options([
'secondaria' => 'Secondaria',
'lavoro' => 'Lavoro',
'personale' => 'Personale',
'pec' => 'PEC',
'altro' => 'Altro',
])
->default('secondaria')
->columnSpan(4),
Toggle::make('attiva')
->label('Attiva')
->default(true)
->columnSpan(2),
])
->columns(12),
])
->fillForm(fn(): array=> ['emails' => $this->getAdditionalEmailsFormRows()])
->action(function (array $data): void {
$this->saveAdditionalEmails($data['emails'] ?? []);
$this->mount((int) $this->rubrica->id);
Notification::make()
->title('Email aggiuntive aggiornate')
->success()
->send();
}),
Action::make('contatti_avanzati') Action::make('contatti_avanzati')
->label('Contatti avanzati') ->label('Contatti avanzati')
->icon('heroicon-o-rectangle-stack') ->icon('heroicon-o-rectangle-stack')
@ -906,6 +997,9 @@ protected function getHeaderActions(): array
private function fillInlineForm(): void private function fillInlineForm(): void
{ {
$legacyIdentity = $this->extractLegacyIdentityFromNote($this->rubrica->note ?? null);
$resolvedAddress = $this->parseAddressComponents($this->rubrica->indirizzo_completo ?? null);
$this->inlineForm = [ $this->inlineForm = [
'nome' => (string) ($this->rubrica->nome ?? ''), 'nome' => (string) ($this->rubrica->nome ?? ''),
'cognome' => (string) ($this->rubrica->cognome ?? ''), 'cognome' => (string) ($this->rubrica->cognome ?? ''),
@ -914,16 +1008,26 @@ private function fillInlineForm(): void
'categoria' => (string) ($this->rubrica->categoria ?? ''), 'categoria' => (string) ($this->rubrica->categoria ?? ''),
'codice_fiscale' => (string) ($this->rubrica->codice_fiscale ?? ''), 'codice_fiscale' => (string) ($this->rubrica->codice_fiscale ?? ''),
'partita_iva' => (string) ($this->rubrica->partita_iva ?? ''), 'partita_iva' => (string) ($this->rubrica->partita_iva ?? ''),
'sesso' => (string) ($this->rubrica->sesso ?? $legacyIdentity['sesso'] ?? ''),
'data_nascita' => (string) (($this->rubrica->data_nascita?->format('Y-m-d')) ?? $legacyIdentity['data_nascita'] ?? ''),
'luogo_nascita' => (string) ($this->rubrica->luogo_nascita ?? $legacyIdentity['luogo_nascita'] ?? ''),
'provincia_nascita' => (string) ($this->rubrica->provincia_nascita ?? $legacyIdentity['provincia_nascita'] ?? ''),
'email' => (string) ($this->rubrica->email ?? ''), 'email' => (string) ($this->rubrica->email ?? ''),
'pec' => (string) ($this->rubrica->pec ?? ''), 'pec' => (string) ($this->rubrica->pec ?? ''),
'telefono_ufficio' => (string) ($this->rubrica->telefono_ufficio ?? ''), 'telefono_ufficio' => (string) ($this->rubrica->telefono_ufficio ?? ''),
'telefono_cellulare' => (string) ($this->rubrica->telefono_cellulare ?? ''), 'telefono_cellulare' => (string) ($this->rubrica->telefono_cellulare ?? ''),
'telefono_casa' => (string) ($this->rubrica->telefono_casa ?? ''), 'telefono_casa' => (string) ($this->rubrica->telefono_casa ?? ''),
'indirizzo' => (string) ($this->rubrica->indirizzo ?? $resolvedAddress['indirizzo'] ?? ''),
'civico' => (string) ($this->rubrica->civico ?? $resolvedAddress['civico'] ?? ''),
'cap' => (string) ($this->rubrica->cap ?? $resolvedAddress['cap'] ?? ''),
'citta' => (string) ($this->rubrica->citta ?? $resolvedAddress['citta'] ?? ''),
'provincia' => (string) ($this->rubrica->provincia ?? $resolvedAddress['provincia'] ?? ''),
'nazione' => (string) ($this->rubrica->nazione ?? 'Italia'),
'indirizzo_completo' => (string) ($this->rubrica->indirizzo_completo ?? ''), 'indirizzo_completo' => (string) ($this->rubrica->indirizzo_completo ?? ''),
'tipo_utenza_call' => (string) ($this->rubrica->tipo_utenza_call ?? ''), 'tipo_utenza_call' => (string) ($this->rubrica->tipo_utenza_call ?? ''),
'riferimento_stabile' => (string) ($this->rubrica->riferimento_stabile ?? ''), 'riferimento_stabile' => (string) ($this->rubrica->riferimento_stabile ?? ''),
'riferimento_unita' => (string) ($this->rubrica->riferimento_unita ?? ''), 'riferimento_unita' => (string) ($this->rubrica->riferimento_unita ?? ''),
'note' => (string) ($this->rubrica->note ?? ''), 'note' => (string) ($legacyIdentity['note_clean'] ?? $this->rubrica->note ?? ''),
'note_segreteria' => (string) ($this->rubrica->note_segreteria ?? ''), 'note_segreteria' => (string) ($this->rubrica->note_segreteria ?? ''),
]; ];
} }
@ -939,6 +1043,395 @@ private function cleanNullable(mixed $value): mixed
return $trim === '' ? null : $trim; return $trim === '' ? null : $trim;
} }
/** @return array<string, mixed> */
private function extractLegacyIdentityFromNote(mixed $note): array
{
$raw = trim((string) ($note ?? ''));
if ($raw === '') {
return [
'note_clean' => null,
'titolo_id' => null,
'sesso' => null,
'data_nascita' => null,
'luogo_nascita' => null,
'provincia_nascita' => null,
];
}
$parsed = [
'titolo_id' => null,
'sesso' => null,
'data_nascita' => null,
'luogo_nascita' => null,
'provincia_nascita' => null,
];
$cleanLines = [];
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
$trimmed = trim($line);
if (! str_starts_with($trimmed, 'Dati anagrafici legacy:')) {
$cleanLines[] = $line;
continue;
}
$payload = trim(substr($trimmed, strlen('Dati anagrafici legacy:')));
$bits = preg_split('/\s*\|\s*/', $payload) ?: [];
foreach ($bits as $bit) {
[$key, $value] = array_pad(explode(':', $bit, 2), 2, null);
$key = mb_strtolower(trim((string) $key));
$value = trim((string) $value);
if ($value === '') {
continue;
}
if ($key === 'titolo') {
$parsed['titolo_id'] = $this->resolveTitoloId($value);
continue;
}
if ($key === 'sesso') {
$parsed['sesso'] = $this->normalizeSessoValue($value);
continue;
}
if ($key === 'data nascita') {
$parsed['data_nascita'] = $this->normalizeLegacyDateValue($value);
continue;
}
if ($key === 'luogo nascita') {
$parsed['luogo_nascita'] = $value;
continue;
}
if ($key === 'provincia nascita') {
$parsed['provincia_nascita'] = $this->normalizeProvince($value);
}
}
}
$cleanLines = array_values(array_filter($cleanLines, fn($line) => trim((string) $line) !== ''));
return $parsed + [
'note_clean' => $cleanLines === [] ? null : implode("\n", $cleanLines),
];
}
/** @return array<string, ?string> */
private function parseAddressComponents(mixed $value): array
{
$full = trim((string) ($value ?? ''));
if ($full === '') {
return [
'indirizzo' => null,
'civico' => null,
'cap' => null,
'citta' => null,
'provincia' => null,
];
}
$parts = [
'indirizzo' => $full,
'civico' => null,
'cap' => null,
'citta' => null,
'provincia' => null,
];
if (preg_match('/\((?<provincia>[A-Za-z]{2})\)\s*$/', $full, $provinceMatch) === 1) {
$parts['provincia'] = strtoupper($provinceMatch['provincia']);
$full = trim(substr($full, 0, -strlen($provinceMatch[0])));
}
if (preg_match('/\b(?<cap>\d{5})\b/', $full, $capMatch, PREG_OFFSET_CAPTURE) === 1) {
$parts['cap'] = $capMatch['cap'][0];
$capPos = $capMatch['cap'][1];
$beforeCap = trim(substr($full, 0, $capPos));
$afterCap = trim(substr($full, $capPos + 5));
if ($afterCap !== '') {
$parts['citta'] = $afterCap;
}
$full = $beforeCap;
}
if (preg_match('/^(?<indirizzo>.*?)(?:,\s*|\s+)(?<civico>\d+[A-Za-z\/-]*)$/', $full, $streetMatch) === 1) {
$parts['indirizzo'] = trim($streetMatch['indirizzo']) ?: null;
$parts['civico'] = trim($streetMatch['civico']) ?: null;
} else {
$parts['indirizzo'] = $full;
}
return $parts;
}
private function normalizeLegacyDateValue(mixed $value): ?string
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d');
}
$raw = trim((string) ($value ?? ''));
if ($raw === '') {
return null;
}
foreach (['Y-m-d', 'd/m/Y', 'd-m-Y', 'd.m.Y', 'd/m/y', 'd-m-y', 'd.m.y', 'm/d/Y', 'm/d/y'] as $format) {
$dt = \DateTime::createFromFormat($format, $raw);
if ($dt instanceof \DateTime) {
return $this->normalizeCenturyDrift($dt)->format('Y-m-d');
}
}
$ts = strtotime($raw);
if ($ts === false) {
return null;
}
return $this->normalizeCenturyDrift((new \DateTime())->setTimestamp($ts))->format('Y-m-d');
}
private function normalizeCenturyDrift(\DateTime $date): \DateTime
{
$thresholdYear = (int) date('Y') + 1;
if ((int) $date->format('Y') > $thresholdYear) {
$date->modify('-100 years');
}
return $date;
}
private function normalizeSessoValue(mixed $value): ?string
{
$raw = strtoupper(trim((string) ($value ?? '')));
if ($raw === '') {
return null;
}
if ($raw === 'M' || str_starts_with($raw, 'MAS')) {
return 'M';
}
if ($raw === 'F' || str_starts_with($raw, 'FEM')) {
return 'F';
}
return null;
}
private function normalizeProvince(mixed $value): ?string
{
$raw = strtoupper(trim((string) ($value ?? '')));
if ($raw === '') {
return null;
}
return substr($raw, 0, 2);
}
private function resolveTitoloId(?string $raw): ?int
{
$raw = trim((string) ($raw ?? ''));
if ($raw === '' || ! Schema::hasTable('titoli')) {
return null;
}
$key = mb_strtolower($raw);
$title = Titolo::query()
->whereRaw('LOWER(sigla) = ?', [$key])
->orWhereRaw('LOWER(nome) = ?', [$key])
->first();
return $title?->id ? (int) $title->id : null;
}
private function hydrateEmailMultiple(): void
{
$persona = $this->resolvePersonaForRubrica(false);
if (! $persona instanceof Persona) {
$this->emailMultiple = [];
return;
}
$this->emailMultiple = $persona->emailMultiple()
->orderByDesc('attiva')
->orderBy('tipo_email')
->orderBy('email')
->get()
->map(fn(PersonaEmailMultipla $row): array=> [
'id' => (int) $row->id,
'email' => (string) $row->email,
'tipo_email' => (string) ($row->tipo_email ?? 'secondaria'),
'attiva' => (bool) $row->attiva,
])
->all();
}
/** @return array<int, array<string, mixed>> */
private function getAdditionalEmailsFormRows(): array
{
return $this->emailMultiple;
}
/** @param array<int, array<string, mixed>> $rows */
private function saveAdditionalEmails(array $rows): void
{
$persona = $this->resolvePersonaForRubrica(true);
if (! $persona instanceof Persona) {
throw new \RuntimeException('Impossibile associare la rubrica a una persona.');
}
$this->syncPersonaFromRubrica($persona);
DB::transaction(function () use ($rows, $persona): void {
$keepIds = [];
foreach (array_values($rows) as $row) {
$email = mb_strtolower(trim((string) ($row['email'] ?? '')));
if ($email === '') {
continue;
}
$attrs = [
'email' => $email,
'tipo_email' => trim((string) ($row['tipo_email'] ?? 'secondaria')) ?: 'secondaria',
'attiva' => (bool) ($row['attiva'] ?? true),
];
$id = (int) ($row['id'] ?? 0);
if ($id > 0) {
$existing = PersonaEmailMultipla::query()
->where('persona_id', (int) $persona->id)
->where('id', $id)
->first();
if ($existing instanceof PersonaEmailMultipla) {
$existing->fill($attrs);
$existing->save();
$keepIds[] = (int) $existing->id;
continue;
}
}
$created = PersonaEmailMultipla::query()->create([
'persona_id' => (int) $persona->id,
...$attrs,
]);
$keepIds[] = (int) $created->id;
}
$deleteQuery = PersonaEmailMultipla::query()->where('persona_id', (int) $persona->id);
if ($keepIds !== []) {
$deleteQuery->whereNotIn('id', $keepIds);
}
$deleteQuery->delete();
});
$this->hydrateEmailMultiple();
}
private function resolvePersonaForRubrica(bool $createIfMissing): ?Persona
{
if (! Schema::hasTable('persone')) {
return null;
}
$cf = trim((string) ($this->rubrica->codice_fiscale ?? ''));
if ($cf !== '') {
$found = Persona::query()->where('codice_fiscale', $cf)->first();
if ($found instanceof Persona) {
return $found;
}
}
$email = mb_strtolower(trim((string) ($this->rubrica->email ?? '')));
if ($email !== '') {
$found = Persona::query()->whereRaw('LOWER(email_principale) = ?', [$email])->first();
if ($found instanceof Persona) {
return $found;
}
if (Schema::hasTable('persone_email_multiple')) {
$match = Persona::query()
->whereHas('emailMultiple', fn(EloquentBuilderContract $query) => $query->where('email', $email))
->first();
if ($match instanceof Persona) {
return $match;
}
}
}
$phone = trim((string) ($this->rubrica->telefono_cellulare ?: $this->rubrica->telefono_ufficio ?: ''));
if ($phone !== '') {
$found = Persona::query()->where('telefono_principale', $phone)->first();
if ($found instanceof Persona) {
return $found;
}
}
$nome = trim((string) ($this->rubrica->nome ?? ''));
$cognome = trim((string) ($this->rubrica->cognome ?? ''));
if ($nome !== '' && $cognome !== '') {
$found = Persona::query()->where('nome', $nome)->where('cognome', $cognome)->first();
if ($found instanceof Persona) {
return $found;
}
}
if (! $createIfMissing) {
return null;
}
if ($nome === '' && $cognome === '' && trim((string) ($this->rubrica->ragione_sociale ?? '')) === '') {
return null;
}
$persona = Persona::query()->create([
'tipologia' => $this->rubrica->tipo_contatto === 'persona_giuridica' ? 'giuridica' : 'fisica',
'nome' => $nome !== '' ? $nome : (trim((string) ($this->rubrica->ragione_sociale ?? '')) !== '' ? 'Impresa' : 'N/D'),
'cognome' => $cognome !== '' ? $cognome : (trim((string) ($this->rubrica->ragione_sociale ?? '')) !== '' ? trim((string) $this->rubrica->ragione_sociale) : 'N/D'),
'ragione_sociale' => $this->rubrica->ragione_sociale,
'codice_fiscale' => $cf !== '' ? $cf : null,
'partita_iva' => $this->rubrica->partita_iva,
'data_nascita' => $this->rubrica->data_nascita,
'residenza_via' => $this->rubrica->indirizzo_completo,
'telefono_principale' => $phone !== '' ? $phone : null,
'telefono_secondario' => $this->rubrica->telefono_casa,
'email_principale' => $email !== '' ? $email : null,
'email_pec' => $this->rubrica->pec,
'note' => $this->rubrica->note,
'attivo' => $this->rubrica->stato !== 'inattivo',
]);
return $persona;
}
private function syncPersonaFromRubrica(Persona $persona): void
{
$updates = [];
if (! $persona->email_principale && $this->rubrica->email) {
$updates['email_principale'] = mb_strtolower(trim((string) $this->rubrica->email));
}
if (! $persona->email_pec && $this->rubrica->pec) {
$updates['email_pec'] = $this->rubrica->pec;
}
if (! $persona->telefono_principale && ($this->rubrica->telefono_cellulare || $this->rubrica->telefono_ufficio)) {
$updates['telefono_principale'] = $this->rubrica->telefono_cellulare ?: $this->rubrica->telefono_ufficio;
}
if (! $persona->residenza_via && $this->rubrica->indirizzo_completo) {
$updates['residenza_via'] = $this->rubrica->indirizzo_completo;
}
if (! $persona->data_nascita && $this->rubrica->data_nascita) {
$updates['data_nascita'] = $this->rubrica->data_nascita;
}
if (! $persona->codice_fiscale && $this->rubrica->codice_fiscale) {
$updates['codice_fiscale'] = $this->rubrica->codice_fiscale;
}
if (! $persona->partita_iva && $this->rubrica->partita_iva) {
$updates['partita_iva'] = $this->rubrica->partita_iva;
}
if ($updates !== []) {
$persona->fill($updates);
$persona->save();
}
}
public function getUrlUnita(?int $unitaId): ?string public function getUrlUnita(?int $unitaId): ?string
{ {
if (! $unitaId) { if (! $unitaId) {

View File

@ -10,6 +10,7 @@
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Throwable; use Throwable;
@ -51,6 +52,12 @@ class Modifiche extends Page
public ?string $lastConnectivityCheckOutput = null; public ?string $lastConnectivityCheckOutput = null;
/** @var array<string,mixed>|null */
public ?array $lastManifestPreview = null;
/** @var array<string,mixed>|null */
public ?array $latestBackupSummary = null;
public ?string $lastPlannedUpdateSummary = null; public ?string $lastPlannedUpdateSummary = null;
public bool $updateInProgress = false; public bool $updateInProgress = false;
@ -135,6 +142,7 @@ public function reloadDashboardData(): void
$this->loadRuntimeErrors(); $this->loadRuntimeErrors();
$this->loadRecentFilamentPages(); $this->loadRecentFilamentPages();
$this->loadFunctionalHighlights(); $this->loadFunctionalHighlights();
$this->loadLatestBackupSummary();
if ($this->selectedCommitHash === null && isset($this->latestCommits[0]['hash'])) { if ($this->selectedCommitHash === null && isset($this->latestCommits[0]['hash'])) {
$this->selectedCommitHash = (string) $this->latestCommits[0]['hash']; $this->selectedCommitHash = (string) $this->latestCommits[0]['hash'];
@ -279,7 +287,7 @@ public function getUpdatePlannedStepsProperty(): array
$steps = [ $steps = [
'Verifica canale update: ' . $this->updateChannel, 'Verifica canale update: ' . $this->updateChannel,
'Backup pre-update automatico (snapshot differenziale + indice record)', 'Backup pre-update automatico (snapshot differenziale + indice record)',
'Upload backup su Google Drive se account amministratore disponibile', 'Upload backup su Google Drive obbligatorio prima dell update',
'Esecuzione in modalita ' . ($this->updateDryRun ? 'dry-run (nessuna scrittura)' : 'apply (aggiornamento reale)'), 'Esecuzione in modalita ' . ($this->updateDryRun ? 'dry-run (nessuna scrittura)' : 'apply (aggiornamento reale)'),
'Flag force: ' . ($this->updateForce ? 'abilitato' : 'disabilitato'), 'Flag force: ' . ($this->updateForce ? 'abilitato' : 'disabilitato'),
'Check endpoint update consigliato prima del lancio', 'Check endpoint update consigliato prima del lancio',
@ -320,6 +328,7 @@ public function runUpdateConnectivityCheck(): void
$out[] = trim($headResult->output() !== '' ? $headResult->output() : $headResult->errorOutput()); $out[] = trim($headResult->output() !== '' ? $headResult->output() : $headResult->errorOutput());
$this->lastConnectivityCheckOutput = trim(implode(PHP_EOL, $out)); $this->lastConnectivityCheckOutput = trim(implode(PHP_EOL, $out));
$this->lastManifestPreview = null;
$combined = Str::lower($this->lastConnectivityCheckOutput); $combined = Str::lower($this->lastConnectivityCheckOutput);
if (str_contains($combined, 'timed out') || str_contains($combined, 'curl: (28)')) { if (str_contains($combined, 'timed out') || str_contains($combined, 'curl: (28)')) {
@ -332,6 +341,29 @@ public function runUpdateConnectivityCheck(): void
return; return;
} }
try {
$manifestUrl = rtrim((string) config('distribution.update_url', 'https://updates.netgescon.it'), '/') . '/api/v1/distribution/updates/manifest';
$response = Http::timeout((int) config('distribution.http_timeout_seconds', 60))
->withOptions($this->buildDistributionHttpOptions())
->acceptJson()
->get($manifestUrl, ['channel' => $this->updateChannel]);
if ($response->successful()) {
$manifest = $response->json();
if (is_array($manifest)) {
$this->lastManifestPreview = [
'version' => (string) ($manifest['version'] ?? '-'),
'package_url' => (string) ($manifest['package_url'] ?? '-'),
'sha256' => (string) ($manifest['sha256'] ?? '-'),
'migrate_required' => (bool) ($manifest['migrate_required'] ?? false),
'release_notes' => (string) ($manifest['release_notes'] ?? $manifest['notes'] ?? ''),
'security' => $manifest['security'] ?? $manifest['security_updates'] ?? null,
];
}
}
} catch (Throwable) {
}
Notification::make() Notification::make()
->title('Check connettivita completato') ->title('Check connettivita completato')
->success() ->success()
@ -412,16 +444,32 @@ private function startUpdateJob(bool $fallback): void
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$adminId = $this->resolveAmministratoreId(); $adminId = $this->resolveAmministratoreId();
if ($adminId <= 0) {
$this->updateInProgress = false;
$this->updateProgressStatus = 'failed';
$this->updateProgressPercent = 100;
$this->updateProgressMessage = 'Amministratore Google non risolto';
$this->lastUpdateExitCode = 1;
$this->lastUpdateOutput = 'Backup Google Drive obbligatorio: impossibile risolvere amministratore attivo per il sito corrente.';
$this->lastUpdateIssue = 'Aggiornamento bloccato: serve un amministratore attivo con integrazione Google Drive per creare la seconda copia ripristinabile.';
@file_put_contents($progressPath, json_encode([
'timestamp' => now()->toIso8601String(),
'percent' => 100,
'message' => 'Amministratore Google non risolto',
'status' => 'failed',
'exit_code' => 1,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return;
}
$backupParams = [ $backupParams = [
'--differential' => true, '--differential' => true,
'--drive' => true,
'--require-drive' => true,
'--admin-id' => (string) $adminId,
'--tag' => 'update-' . $jobId, '--tag' => 'update-' . $jobId,
]; ];
if ($adminId > 0) {
$backupParams['--admin-id'] = (string) $adminId;
$backupParams['--drive'] = true;
}
try { try {
$backupExit = Artisan::call('netgescon:preupdate-backup', $backupParams); $backupExit = Artisan::call('netgescon:preupdate-backup', $backupParams);
$backupOut = trim((string) Artisan::output()); $backupOut = trim((string) Artisan::output());
@ -440,8 +488,10 @@ private function startUpdateJob(bool $fallback): void
'status' => 'failed', 'status' => 'failed',
'exit_code' => 1, 'exit_code' => 1,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$this->loadLatestBackupSummary();
return; return;
} }
$this->loadLatestBackupSummary();
} catch (Throwable $e) { } catch (Throwable $e) {
$this->updateInProgress = false; $this->updateInProgress = false;
$this->updateProgressStatus = 'failed'; $this->updateProgressStatus = 'failed';
@ -531,6 +581,55 @@ private function registerPlannedUpdateSummary(): void
$this->appendSupportEvent('update_plan', 'Piano aggiornamento registrato', $summary); $this->appendSupportEvent('update_plan', 'Piano aggiornamento registrato', $summary);
} }
private function loadLatestBackupSummary(): void
{
$this->latestBackupSummary = null;
$path = storage_path('app/update-backups/latest-manifest.json');
if (! is_file($path)) {
return;
}
$decoded = json_decode((string) @file_get_contents($path), true);
if (! is_array($decoded)) {
return;
}
$zipPath = (string) ($decoded['zip_path'] ?? '');
$drive = is_array($decoded['drive'] ?? null) ? $decoded['drive'] : null;
$this->latestBackupSummary = [
'snapshot_id' => (string) ($decoded['snapshot_id'] ?? '-'),
'created_at' => (string) ($decoded['created_at'] ?? '-'),
'zip_path' => $zipPath,
'zip_exists' => $zipPath !== '' && is_file($zipPath),
'zip_size_mb' => $zipPath !== '' && is_file($zipPath) ? round(((float) filesize($zipPath)) / 1048576, 2) : null,
'drive' => $drive,
'copied_files' => (int) ($decoded['copied_files'] ?? 0),
'tables_count' => is_array($decoded['tables'] ?? null) ? count((array) $decoded['tables']) : 0,
];
}
/** @return array<string,mixed> */
private function buildDistributionHttpOptions(): array
{
$raw = trim((string) config('distribution.http_resolve', ''));
if ($raw === '') {
return [];
}
$entries = array_values(array_filter(array_map('trim', explode(',', $raw)), static fn(string $v): bool => $v !== ''));
if ($entries === []) {
return [];
}
return [
'curl' => [
CURLOPT_RESOLVE => $entries,
],
];
}
private function loadRecentFilamentPages(): void private function loadRecentFilamentPages(): void
{ {
$this->recentFilamentPages = []; $this->recentFilamentPages = [];

View File

@ -251,11 +251,10 @@ public function openAttachmentPreview(int $attachmentId): void
} }
$name = (string) ($attachment->original_file_name ?? 'allegato'); $name = (string) ($attachment->original_file_name ?? 'allegato');
$mime = strtolower((string) ($attachment->mime_type ?? '')); $mime = $attachment->resolvedMimeType();
$ext = strtolower((string) pathinfo($name, PATHINFO_EXTENSION));
$isImage = str_starts_with($mime, 'image/') || in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'], true); $isImage = $attachment->isImage();
$isPdf = $mime === 'application/pdf' || $ext === 'pdf'; $isPdf = $attachment->isPdf();
$this->attachmentPreview = [ $this->attachmentPreview = [
'id' => (int) $attachment->id, 'id' => (int) $attachment->id,
@ -264,6 +263,7 @@ public function openAttachmentPreview(int $attachmentId): void
'url' => $this->getAttachmentPublicUrl($attachment), 'url' => $this->getAttachmentPublicUrl($attachment),
'is_image' => $isImage, 'is_image' => $isImage,
'is_pdf' => $isPdf, 'is_pdf' => $isPdf,
'mime' => $mime,
]; ];
} }
@ -411,6 +411,11 @@ public function caricaAllegati(): void
} }
$path = $file->store('ticket-gestione/' . $ticket->id, 'public'); $path = $file->store('ticket-gestione/' . $ticket->id, 'public');
$mime = TicketAttachment::normalizeMimeType(
method_exists($file, 'getClientMimeType') ? (string) $file->getClientMimeType() : '',
method_exists($file, 'getClientOriginalName') ? (string) $file->getClientOriginalName() : '',
$path
);
TicketAttachment::query()->create([ TicketAttachment::query()->create([
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
@ -418,7 +423,7 @@ public function caricaAllegati(): void
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'file_path' => $path, 'file_path' => $path,
'original_file_name' => (string) $file->getClientOriginalName(), 'original_file_name' => (string) $file->getClientOriginalName(),
'mime_type' => (string) $file->getClientMimeType(), 'mime_type' => $mime,
'size' => (int) $file->getSize(), 'size' => (int) $file->getSize(),
'description' => $this->descrizioneAllegati ?: 'Allegato da gestione ticket', 'description' => $this->descrizioneAllegati ?: 'Allegato da gestione ticket',
]); ]);

View File

@ -5,13 +5,13 @@
use App\Models\TicketAttachment; use App\Models\TicketAttachment;
use App\Models\User; use App\Models\User;
use App\Support\StabileContext; use App\Support\StabileContext;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class TicketAttachmentViewController extends Controller class TicketAttachmentViewController extends Controller
{ {
public function show(TicketAttachment $attachment): Response public function show(TicketAttachment $attachment): BinaryFileResponse
{ {
$user = Auth::user(); $user = Auth::user();
if (! $user instanceof User) { if (! $user instanceof User) {
@ -39,7 +39,7 @@ public function show(TicketAttachment $attachment): Response
} }
$absolutePath = $disk->path($path); $absolutePath = $disk->path($path);
$mime = (string) ($attachment->mime_type ?: $disk->mimeType($path) ?: 'application/octet-stream'); $mime = $attachment->resolvedMimeType();
return response()->file($absolutePath, [ return response()->file($absolutePath, [
'Content-Type' => $mime, 'Content-Type' => $mime,

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
class RubricaUniversale extends Model class RubricaUniversale extends Model
@ -18,6 +19,10 @@ class RubricaUniversale extends Model
'tipo_contatto', // 'persona_fisica', 'persona_giuridica' 'tipo_contatto', // 'persona_fisica', 'persona_giuridica'
'codice_fiscale', 'codice_fiscale',
'partita_iva', 'partita_iva',
'sesso',
'data_nascita',
'luogo_nascita',
'provincia_nascita',
'indirizzo', 'indirizzo',
'civico', 'civico',
'cap', 'cap',
@ -50,6 +55,7 @@ class RubricaUniversale extends Model
protected $casts = [ protected $casts = [
'data_inserimento' => 'date', 'data_inserimento' => 'date',
'data_ultima_modifica' => 'date', 'data_ultima_modifica' => 'date',
'data_nascita' => 'date',
'created_at' => 'datetime', 'created_at' => 'datetime',
'updated_at' => 'datetime', 'updated_at' => 'datetime',
'deleted_at' => 'datetime', 'deleted_at' => 'datetime',
@ -79,7 +85,7 @@ public function datiBancari()
return $this->hasMany(DatiBancari::class, 'contatto_id'); return $this->hasMany(DatiBancari::class, 'contatto_id');
} }
public function titolo() public function titolo(): BelongsTo
{ {
return $this->belongsTo(Titolo::class, 'titolo_id'); return $this->belongsTo(Titolo::class, 'titolo_id');
} }

65
app/Models/Scadenza.php Normal file
View File

@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Scadenza extends Model
{
use HasFactory;
protected $table = 'scadenze';
protected $fillable = [
'legacy_id',
'stabile_id',
'codice_stabile_legacy',
'descrizione_sintetica',
'data_scadenza',
'ora_scadenza',
'natura',
'natura_label',
'gestione_tipo',
'num_assemblea',
'numero_straordinaria',
'anno',
'rata',
'fatto',
'tipo_riga',
'rif_f24',
'importo_f24',
'richiede_pop_up',
'giorni_anticipo',
'popup_dal',
'calendar_event_id',
'calendar_synced_at',
'legacy_payload',
];
protected $casts = [
'data_scadenza' => 'date',
'popup_dal' => 'date',
'richiede_pop_up' => 'boolean',
'importo_f24' => 'decimal:2',
'calendar_synced_at' => 'datetime',
'legacy_payload' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function stabile()
{
return $this->belongsTo(Stabile::class, 'stabile_id');
}
public function getTitoloCalendarioAttribute(): string
{
$parts = array_filter([
$this->natura_label ?: $this->natura,
trim((string) ($this->descrizione_sintetica ?? '')),
]);
return implode(' - ', $parts) ?: ('Scadenza #' . (int) $this->id);
}
}

View File

@ -20,6 +20,13 @@ class StabileServizioLettura extends Model
'tipologia_lettura', 'tipologia_lettura',
'canale_acquisizione', 'canale_acquisizione',
'riferimento_acquisizione', 'riferimento_acquisizione',
'workflow_stato',
'protocollo_categoria',
'protocollo_numero',
'richiesta_lettura_inviata_at',
'prossima_lettura_scadenza_at',
'archivio_documentale_path',
'archivio_documentale_sha256',
'lettura_inizio', 'lettura_inizio',
'lettura_fine', 'lettura_fine',
'consumo_valore', 'consumo_valore',
@ -32,6 +39,8 @@ class StabileServizioLettura extends Model
protected $casts = [ protected $casts = [
'periodo_dal' => 'date', 'periodo_dal' => 'date',
'periodo_al' => 'date', 'periodo_al' => 'date',
'richiesta_lettura_inviata_at' => 'datetime',
'prossima_lettura_scadenza_at' => 'datetime',
'lettura_inizio' => 'decimal:3', 'lettura_inizio' => 'decimal:3',
'lettura_fine' => 'decimal:3', 'lettura_fine' => 'decimal:3',
'consumo_valore' => 'decimal:3', 'consumo_valore' => 'decimal:3',

View File

@ -1,15 +1,13 @@
<?php <?php
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class TicketAttachment extends Model class TicketAttachment extends Model
{ {
use HasFactory; use HasFactory;
protected $table = 'ticket_attachments'; protected $table = 'ticket_attachments';
@ -29,4 +27,66 @@ public function user(): BelongsTo
{ {
return $this->belongsTo(User::class, 'user_id', 'id'); return $this->belongsTo(User::class, 'user_id', 'id');
} }
public function resolvedMimeType(): string
{
return self::normalizeMimeType(
(string) ($this->mime_type ?? ''),
(string) ($this->original_file_name ?? ''),
(string) ($this->file_path ?? '')
);
}
public function isImage(): bool
{
return str_starts_with($this->resolvedMimeType(), 'image/');
}
public function isPdf(): bool
{
return $this->resolvedMimeType() === 'application/pdf';
}
public static function normalizeMimeType(?string $mimeType, ?string $originalName = null, ?string $path = null): string
{
$mime = strtolower(trim((string) $mimeType));
if ($mime !== '' && $mime !== 'application/octet-stream') {
return $mime;
}
$extension = strtolower((string) pathinfo((string) ($originalName ?: $path), PATHINFO_EXTENSION));
$fromExtension = match ($extension) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'pdf' => 'application/pdf',
'txt' => 'text/plain',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
default => '',
};
if ($fromExtension !== '') {
return $fromExtension;
}
$diskPath = trim((string) $path);
if ($diskPath !== '') {
try {
$detected = Storage::disk('public')->mimeType($diskPath);
if (is_string($detected) && trim($detected) !== '') {
return strtolower(trim($detected));
}
} catch (\Throwable) {
}
}
return 'application/octet-stream';
}
} }

View File

@ -0,0 +1,44 @@
<?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('scadenze', function (Blueprint $table): void {
$table->id();
$table->unsignedInteger('legacy_id')->nullable()->unique();
$table->foreignId('stabile_id')->nullable()->constrained('stabili')->nullOnDelete();
$table->string('codice_stabile_legacy', 20)->nullable()->index();
$table->string('descrizione_sintetica')->nullable();
$table->date('data_scadenza')->nullable()->index();
$table->time('ora_scadenza')->nullable();
$table->string('natura', 20)->nullable()->index();
$table->string('natura_label', 120)->nullable();
$table->string('gestione_tipo', 20)->nullable();
$table->unsignedInteger('num_assemblea')->nullable();
$table->unsignedInteger('numero_straordinaria')->nullable();
$table->string('anno', 20)->nullable();
$table->unsignedInteger('rata')->nullable();
$table->string('fatto', 20)->nullable();
$table->string('tipo_riga', 60)->nullable();
$table->unsignedBigInteger('rif_f24')->nullable();
$table->decimal('importo_f24', 12, 2)->nullable();
$table->boolean('richiede_pop_up')->default(false);
$table->unsignedInteger('giorni_anticipo')->nullable();
$table->date('popup_dal')->nullable();
$table->string('calendar_event_id')->nullable()->index();
$table->timestamp('calendar_synced_at')->nullable();
$table->json('legacy_payload')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('scadenze');
}
};

View File

@ -0,0 +1,51 @@
<?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
{
if (! Schema::hasTable('rubrica_universale')) {
return;
}
Schema::table('rubrica_universale', function (Blueprint $table): void {
if (! Schema::hasColumn('rubrica_universale', 'sesso')) {
$table->string('sesso', 1)->nullable()->after('partita_iva');
}
if (! Schema::hasColumn('rubrica_universale', 'data_nascita')) {
$table->date('data_nascita')->nullable()->after('sesso');
}
if (! Schema::hasColumn('rubrica_universale', 'luogo_nascita')) {
$table->string('luogo_nascita', 100)->nullable()->after('data_nascita');
}
if (! Schema::hasColumn('rubrica_universale', 'provincia_nascita')) {
$table->string('provincia_nascita', 2)->nullable()->after('luogo_nascita');
}
});
}
public function down(): void
{
if (! Schema::hasTable('rubrica_universale')) {
return;
}
Schema::table('rubrica_universale', function (Blueprint $table): void {
$drops = [];
foreach (['provincia_nascita', 'luogo_nascita', 'data_nascita', 'sesso'] as $column) {
if (Schema::hasColumn('rubrica_universale', $column)) {
$drops[] = $column;
}
}
if ($drops !== []) {
$table->dropColumn($drops);
}
});
}
};

View File

@ -0,0 +1,68 @@
<?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
{
if (! Schema::hasTable('stabile_servizio_letture')) {
return;
}
Schema::table('stabile_servizio_letture', function (Blueprint $table): void {
if (! Schema::hasColumn('stabile_servizio_letture', 'workflow_stato')) {
$table->string('workflow_stato', 40)->nullable()->after('riferimento_acquisizione');
}
if (! Schema::hasColumn('stabile_servizio_letture', 'protocollo_categoria')) {
$table->string('protocollo_categoria', 40)->nullable()->after('workflow_stato');
}
if (! Schema::hasColumn('stabile_servizio_letture', 'protocollo_numero')) {
$table->string('protocollo_numero', 50)->nullable()->after('protocollo_categoria');
}
if (! Schema::hasColumn('stabile_servizio_letture', 'richiesta_lettura_inviata_at')) {
$table->dateTime('richiesta_lettura_inviata_at')->nullable()->after('protocollo_numero');
}
if (! Schema::hasColumn('stabile_servizio_letture', 'prossima_lettura_scadenza_at')) {
$table->dateTime('prossima_lettura_scadenza_at')->nullable()->after('richiesta_lettura_inviata_at');
}
if (! Schema::hasColumn('stabile_servizio_letture', 'archivio_documentale_path')) {
$table->string('archivio_documentale_path', 500)->nullable()->after('prossima_lettura_scadenza_at');
}
if (! Schema::hasColumn('stabile_servizio_letture', 'archivio_documentale_sha256')) {
$table->string('archivio_documentale_sha256', 64)->nullable()->after('archivio_documentale_path');
}
});
}
public function down(): void
{
if (! Schema::hasTable('stabile_servizio_letture')) {
return;
}
Schema::table('stabile_servizio_letture', function (Blueprint $table): void {
foreach ([
'archivio_documentale_sha256',
'archivio_documentale_path',
'prossima_lettura_scadenza_at',
'richiesta_lettura_inviata_at',
'protocollo_numero',
'protocollo_categoria',
'workflow_stato',
] as $column) {
if (Schema::hasColumn('stabile_servizio_letture', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,123 @@
# Assicurazioni Roadmap
Base gia presente nel codice:
- `Ticket -> insuranceClaim()` gia esiste.
- `InsuranceClaim` gestisce oggi il collegamento minimo ticket/sinistro.
- `DocumentoCollegato` supporta gia il tipo documento `assicurazione` e il collegamento allo stabile.
- `Scadenza` e ora disponibile per rinnovi polizza, pagamenti premio, richieste integrazione, riserve e liquidazioni.
Obiettivo prodotto
- Gestire la polizza dello stabile come dossier unico.
- Caricare documenti assicurativi da email e archiviarli nei documenti collegati dello stabile.
- Gestire quietanze di pagamento con ricevuta allegata e collegamento al movimento bancario.
- Aprire il sinistro da ticket e seguirlo fino a liquidazione, diniego o passaggio al legale.
- Generare scadenze operative e amministrative lungo tutto il ciclo.
Modello dati minimo consigliato
1. `insurance_policies`
- `id`
- `stabile_id`
- `compagnia_rubrica_id`
- `broker_rubrica_id`
- `documento_collegato_id`
- `numero_polizza`
- `ramo`
- `descrizione_copertura`
- `massimale`
- `franchigia`
- `premio_annuo`
- `decorrenza`
- `scadenza`
- `rinnovo_tacito`
- `giorni_preavviso_disdetta`
- `stato`
- `metadata`
1. `insurance_policy_payments`
- `id`
- `insurance_policy_id`
- `movimento_bancario_id`
- `documento_collegato_id`
- `numero_quietanza`
- `data_pagamento`
- `competenza_dal`
- `competenza_al`
- `importo`
- `stato`
- `metadata`
1. Estendere `insurance_claims`
- mantenere `ticket_id`
- aggiungere `insurance_policy_id`
- aggiungere `documento_collegato_id`
- aggiungere `numero_pratica_compagnia`
- aggiungere `stato_workflow`
- aggiungere `data_evento`
- aggiungere `data_denuncia`
- aggiungere `data_apertura_compagnia`
- aggiungere `data_liquidazione`
- aggiungere `importo_riservato`
- aggiungere `importo_liquidato`
- aggiungere `passata_a_legale_at`
- aggiungere `legale_rubrica_id`
1. `insurance_claim_events`
- timeline strutturata della pratica
- tipi evento: apertura, invio_documenti, integrazione, sopralluogo, riserva, liquidazione, diniego, passaggio_legale, mediazione, chiusura
Agganci applicativi
1. Email -> Documenti
- intercettare mail della compagnia/broker
- salvare EML e allegati
- creare o aggiornare `DocumentoCollegato` tipo `assicurazione`
- se il soggetto mittente e riconosciuto, collegare `compagnia_rubrica_id` o `broker_rubrica_id`
1. Documenti stabile
- nella tab `documenti-collegati` dello stabile filtrare ed evidenziare le polizze assicurative
- mostrare polizza attiva, prossima scadenza, ultima quietanza, ultimo sinistro aperto
1. Ticket -> Sinistro
- aggiungere in `TicketGestione` una action `Apri pratica assicurativa`
- precompilare riferimento polizza, data evento, descrizione danno, allegati ticket
- creare automaticamente almeno una `Scadenza` di follow-up
1. Banca -> Quietanze
- permettere collegamento manuale e poi semi-automatico tra quietanza e `movimento_bancario`
- usare causali bancarie che contengono `polizza`, `assicurazione`, `quietanza`, numero pratica o numero polizza
1. Scadenze
- rinnovo polizza
- termine disdetta
- richiesta integrazione documenti
- scadenza per denuncia
- follow-up con compagnia
- data prevista liquidazione
- termine per invio al legale/mediazione
Primo slice implementativo consigliato
1. Migrazione + model `insurance_policies`
2. Estensione `insurance_claims`
3. Action in ticket per aprire la pratica assicurativa con collegamento a polizza e allegati ticket
4. Vista riepilogo assicurazione nello stabile
5. Quietanza + collegamento movimento bancario
6. Automazione email in ingresso
Note operative
- Riutilizzare `RubricaUniversale` per compagnia, broker e legale.
- Riutilizzare `DocumentoCollegato` per il fascicolo documentale, senza creare un archivio parallelo.
- Riutilizzare `Scadenza` per tutte le attivita operative invece di introdurre una seconda agenda dedicata.

106
info_per_scambio.md Normal file
View File

@ -0,0 +1,106 @@
Ecco il prompt unico da dare all Agent DNS/Web per chiudere configurazione host, DNS e virtual host dei siti NetGescon.
Obiettivo operativo
- Rendere coerenti e raggiungibili in HTTPS i siti NetGescon usati oggi:
- <https://staging.netgescon.it>
- <https://updates.netgescon.it>
- Fare in modo che il nodo update pubblichi davvero gli endpoint distribution usati dal client update Laravel.
Prompt unico per Agent DNS/Web
Verifica e correggi l infrastruttura web/DNS di NetGescon con questi vincoli e test finali.
Contesto verificato
- La macchina interna di release/update risponde su 192.168.0.53.
- staging.netgescon.it oggi risponde correttamente in HTTPS.
- updates.netgescon.it oggi non ha un virtual host dedicato attivo su nginx, oppure punta a un istanza Laravel non allineata.
- Test eseguiti:
- <https://staging.netgescon.it/api/v1/distribution/health> => HTTP 200
- <https://staging.netgescon.it/api/v1/distribution/updates/manifest?channel=free> => HTTP 200, manifest versione 0.8.1
- <https://updates.netgescon.it/api/v1/distribution/health> forzato verso 192.168.0.53 => HTTP 200 ma con risposta incoerente/vecchia
- <https://updates.netgescon.it/api/v1/distribution/updates/manifest?channel=free> forzato verso 192.168.0.53 => HTTP 404
- <https://updates.netgescon.it/api/v1/distribution/updates/package?channel=free&file=netgescon-0.8.1.zip> forzato verso 192.168.0.53 => HTTP 404
Lavori richiesti
1. DNS
- Verifica il record A/AAAA di staging.netgescon.it.
- Verifica il record A/AAAA di updates.netgescon.it.
- Se il nodo update deve stare sulla macchina interna 192.168.0.53, allinea DNS interno o split-horizon DNS in modo che updates.netgescon.it risolva correttamente verso quel nodo.
- Se esiste proxy esterno o reverse proxy, verifica che inoltri Host header e HTTPS correttamente.
1. Nginx / vhost
- Verifica i virtual host attivi in /etc/nginx/sites-available e /etc/nginx/sites-enabled.
- staging.netgescon.it deve avere il suo vhost dedicato e funzionante.
- Crea o correggi un vhost dedicato per updates.netgescon.it.
- Non lasciare updates.netgescon.it servito dal vhost di default o da un server_name diverso.
- Il vhost updates deve puntare all applicazione Laravel corretta che espone:
- /api/v1/distribution/health
- /api/v1/distribution/updates/manifest
- /api/v1/distribution/updates/package
1. Laravel / env del nodo update
- Verifica che il document root punti a public/ della app corretta.
- Verifica che APP_URL del nodo update sia <https://updates.netgescon.it>.
- Verifica che routes/api.php sia registrato nel bootstrap dell app attiva.
- Verifica che storage/app/distribution/free/manifest.json esista sul nodo update corretto.
- Verifica che il package pubblicato esista e sia leggibile dal web server.
1. TLS
- Verifica certificato valido per staging.netgescon.it.
- Verifica certificato valido per updates.netgescon.it.
- Se necessario, rigenera o riallinea certificati e catena completa.
1. Output richiesto dall Agent
- Elenco file nginx modificati.
- Record DNS verificati/modificati.
- APP_URL e document root finali di staging e updates.
- Risultato finale dei test sotto.
Test finali obbligatori
- curl -k <https://staging.netgescon.it/api/v1/distribution/health>
- curl -k "<https://staging.netgescon.it/api/v1/distribution/updates/manifest?channel=free>"
- curl -k <https://updates.netgescon.it/api/v1/distribution/health>
- curl -k "<https://updates.netgescon.it/api/v1/distribution/updates/manifest?channel=free>"
- curl -k "<https://updates.netgescon.it/api/v1/distribution/updates/package?channel=free&file=netgescon-0.8.1.zip>" -I
Esito atteso
- staging.health = 200
- staging.manifest = 200
- updates.health = 200
- updates.manifest = 200
- updates.package = 200 oppure 302/304 se gestito da proxy/CDN, ma mai 404
Nota applicativa utile
- Su staging i valori target distribution sono:
- NETGESCON_UPDATE_URL=<https://updates.netgescon.it>
- NETGESCON_UPDATE_CHANNEL=free
- NETGESCON_NODE_CODE=staging-01
Parte scadenze legacy avviata qui
- La tabella sorgente MDB e letta da parti_comuni.mdb -> Scadenze.
- Campi operativi gia verificati:
- id_scadenza
- Dt_scadenza
- ora_scadenza
- cod_stabile
- Descriz_sintetica
- Gest_ors
- Natura
- Rif_f24
- Importo_f24
- Richiede_pop_up
- gg_anticipo
- Pop_Up_dal
- Non usare Descriz_lunga.

View File

@ -35,6 +35,27 @@
<span class="mb-1 block text-gray-600">Partita IVA</span> <span class="mb-1 block text-gray-600">Partita IVA</span>
<input type="text" wire:model.defer="inlineForm.partita_iva" class="w-full rounded-md border-gray-300 text-sm" /> <input type="text" wire:model.defer="inlineForm.partita_iva" class="w-full rounded-md border-gray-300 text-sm" />
</label> </label>
<label class="text-sm">
<span class="mb-1 block text-gray-600">Sesso</span>
<select wire:model.defer="inlineForm.sesso" class="w-full rounded-md border-gray-300 text-sm">
<option value="">Seleziona</option>
<option value="M">M</option>
<option value="F">F</option>
</select>
</label>
<label class="text-sm">
<span class="mb-1 block text-gray-600">Data nascita</span>
<input type="date" wire:model.defer="inlineForm.data_nascita" class="w-full rounded-md border-gray-300 text-sm" />
</label>
<label class="text-sm">
<span class="mb-1 block text-gray-600">Luogo nascita</span>
<input type="text" wire:model.defer="inlineForm.luogo_nascita" class="w-full rounded-md border-gray-300 text-sm" />
</label>
<label class="text-sm">
<span class="mb-1 block text-gray-600">Provincia nascita</span>
<input type="text" wire:model.defer="inlineForm.provincia_nascita" maxlength="2" class="w-full rounded-md border-gray-300 text-sm" />
</label>
<label class="text-sm"> <label class="text-sm">
<span class="mb-1 block text-gray-600">Email</span> <span class="mb-1 block text-gray-600">Email</span>
<input type="text" wire:model.defer="inlineForm.email" class="w-full rounded-md border-gray-300 text-sm" /> <input type="text" wire:model.defer="inlineForm.email" class="w-full rounded-md border-gray-300 text-sm" />
@ -57,8 +78,28 @@
<input type="text" wire:model.defer="inlineForm.telefono_casa" class="w-full rounded-md border-gray-300 text-sm" /> <input type="text" wire:model.defer="inlineForm.telefono_casa" class="w-full rounded-md border-gray-300 text-sm" />
</label> </label>
<label class="text-sm md:col-span-2">
<span class="mb-1 block text-gray-600">Indirizzo</span>
<input type="text" wire:model.defer="inlineForm.indirizzo" class="w-full rounded-md border-gray-300 text-sm" />
</label>
<label class="text-sm">
<span class="mb-1 block text-gray-600">Civico</span>
<input type="text" wire:model.defer="inlineForm.civico" class="w-full rounded-md border-gray-300 text-sm" />
</label>
<label class="text-sm">
<span class="mb-1 block text-gray-600">CAP</span>
<input type="text" wire:model.defer="inlineForm.cap" maxlength="5" class="w-full rounded-md border-gray-300 text-sm" />
</label>
<label class="text-sm">
<span class="mb-1 block text-gray-600">Città</span>
<input type="text" wire:model.defer="inlineForm.citta" class="w-full rounded-md border-gray-300 text-sm" />
</label>
<label class="text-sm">
<span class="mb-1 block text-gray-600">Provincia</span>
<input type="text" wire:model.defer="inlineForm.provincia" maxlength="2" class="w-full rounded-md border-gray-300 text-sm" />
</label>
<label class="text-sm md:col-span-3"> <label class="text-sm md:col-span-3">
<span class="mb-1 block text-gray-600">Indirizzo completo</span> <span class="mb-1 block text-gray-600">Indirizzo completo legacy</span>
<input type="text" wire:model.defer="inlineForm.indirizzo_completo" class="w-full rounded-md border-gray-300 text-sm" /> <input type="text" wire:model.defer="inlineForm.indirizzo_completo" class="w-full rounded-md border-gray-300 text-sm" />
</label> </label>
@ -203,11 +244,50 @@
<div class="font-mono">{{ $rubrica->partita_iva ?? '—' }}</div> <div class="font-mono">{{ $rubrica->partita_iva ?? '—' }}</div>
</div> </div>
<div>
<div class="text-sm text-gray-500">Sesso</div>
<div>{{ $rubrica->sesso ?? '—' }}</div>
</div>
<div>
<div class="text-sm text-gray-500">Data nascita</div>
<div>{{ $rubrica->data_nascita?->format('d/m/Y') ?? '—' }}</div>
</div>
<div>
<div class="text-sm text-gray-500">Luogo nascita</div>
<div>{{ $rubrica->luogo_nascita ?? '—' }}</div>
</div>
<div>
<div class="text-sm text-gray-500">Provincia nascita</div>
<div>{{ $rubrica->provincia_nascita ?? '—' }}</div>
</div>
<div> <div>
<div class="text-sm text-gray-500">Email</div> <div class="text-sm text-gray-500">Email</div>
<div>{{ $rubrica->email ?? '—' }}</div> <div>{{ $rubrica->email ?? '—' }}</div>
</div> </div>
<div class="md:col-span-2">
<div class="text-sm text-gray-500">Email aggiuntive</div>
@if(empty($emailMultiple))
<div></div>
@else
<div class="flex flex-wrap gap-2">
@foreach($emailMultiple as $emailRow)
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs font-semibold text-gray-800">
{{ $emailRow['email'] }}
<span class="ml-1 text-gray-500">· {{ $emailRow['tipo_email'] ?? 'secondaria' }}</span>
@if(empty($emailRow['attiva']))
<span class="ml-1 text-gray-500">· inattiva</span>
@endif
</span>
@endforeach
</div>
@endif
</div>
<div> <div>
<div class="text-sm text-gray-500">PEC</div> <div class="text-sm text-gray-500">PEC</div>
<div>{{ $rubrica->pec ?? '—' }}</div> <div>{{ $rubrica->pec ?? '—' }}</div>
@ -246,6 +326,23 @@
<div class="md:col-span-2"> <div class="md:col-span-2">
<div class="text-sm text-gray-500">Indirizzo</div> <div class="text-sm text-gray-500">Indirizzo</div>
<div>{{ $rubrica->indirizzo_completo ?? '—' }}</div> <div>{{ $rubrica->indirizzo_completo ?? '—' }}</div>
@if($rubrica->indirizzo || $rubrica->cap || $rubrica->citta || $rubrica->provincia)
<div class="mt-1 text-xs text-gray-500">
{{ $rubrica->indirizzo ?? '—' }}
@if($rubrica->civico)
, {{ $rubrica->civico }}
@endif
@if($rubrica->cap)
· {{ $rubrica->cap }}
@endif
@if($rubrica->citta)
· {{ $rubrica->citta }}
@endif
@if($rubrica->provincia)
({{ $rubrica->provincia }})
@endif
</div>
@endif
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">

View File

@ -10,7 +10,7 @@
</x-filament-panels::page> </x-filament-panels::page>
@else @else
<x-filament-panels::page> <x-filament-panels::page>
<div class="space-y-4" wire:key="fornitori-archivio-{{ $this->activeTab }}-{{ (int) ($this->selectedFornitoreId ?? 0) }}"> <div class="space-y-4" wire:key="fornitori-archivio-shell">
<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
@ -23,7 +23,6 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t
<button <button
type="button" type="button"
wire:click="apriTabScheda" wire:click="apriTabScheda"
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' }}" 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)
@ -87,7 +86,7 @@ class="inline-flex items-center rounded-md bg-slate-100 px-3 py-2 text-xs font-m
{{ $token }} {{ $token }}
<button <button
type="button" type="button"
wire:click='removeSearchFilter(@js($token))' wire:click="removeSearchFilter({{ \Illuminate\Support\Js::from($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" 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" title="Rimuovi filtro"
aria-label="Rimuovi filtro" aria-label="Rimuovi filtro"
@ -144,7 +143,7 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5
</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.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> <button type="button" wire:click.prevent="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>
<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>
@ -171,11 +170,12 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5
<div class="flex flex-wrap items-center gap-2"> <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="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> <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>
<button type="button" wire:click="apriNuovoFornitore" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Nuovo 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>
<div class="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2"> <div id="fornitore-inline-form" class="mb-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="rounded-lg border p-3 text-xs"> <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="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="grid grid-cols-1 gap-2 md:grid-cols-2">
@ -407,7 +407,10 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5
<input type="text" wire:model.defer="newFornitoreCodiceFiscale" class="w-full rounded-md border-gray-300 text-xs"> <input type="text" wire:model.defer="newFornitoreCodiceFiscale" class="w-full rounded-md border-gray-300 text-xs">
</div> </div>
</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 class="mt-3 flex flex-wrap gap-2">
<button type="button" wire:click="createFornitoreRapido" class="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>
<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">Annulla</button>
</div>
</div> </div>
</div> </div>
@endif @endif
@ -434,30 +437,38 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5
<th class="border px-2 py-2 text-left">Nominativo</th> <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">Contatti</th>
<th class="border px-2 py-2 text-left">Stato</th> <th class="border px-2 py-2 text-left">Stato</th>
<th class="border px-2 py-2 text-left">Azione</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@forelse($this->dipendentiRows as $d) @forelse($this->dipendentiRows as $d)
<tr> <tr>
<td class="border px-2 py-2"> <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> <div class="text-[11px] text-gray-500">#{{ (int) $d->id }}</div>
<div class="mt-1 grid grid-cols-1 gap-2 md:grid-cols-2">
<input type="text" wire:model.defer="dipendenteInline.{{ (int) $d->id }}.nome" class="w-full rounded-md border-gray-300 text-xs" placeholder="Nome">
<input type="text" wire:model.defer="dipendenteInline.{{ (int) $d->id }}.cognome" class="w-full rounded-md border-gray-300 text-xs" placeholder="Cognome">
</div>
</td> </td>
<td class="border px-2 py-2"> <td class="border px-2 py-2">
<div>{{ $d->email ?: '-' }}</div> <div class="grid grid-cols-1 gap-2">
<div class="text-gray-500">{{ $d->telefono ?: '-' }}</div> <input type="email" wire:model.defer="dipendenteInline.{{ (int) $d->id }}.email" class="w-full rounded-md border-gray-300 text-xs" placeholder="Email">
<input type="text" wire:model.defer="dipendenteInline.{{ (int) $d->id }}.telefono" class="w-full rounded-md border-gray-300 text-xs" placeholder="Telefono">
</div>
</td> </td>
<td class="border px-2 py-2"> <td class="border px-2 py-2">
@if((bool) ($d->attivo ?? false)) <label class="inline-flex items-center gap-2">
<span class="inline-flex items-center rounded-md bg-emerald-100 px-2 py-1 text-[11px] font-medium text-emerald-800">Attivo</span> <input type="checkbox" wire:model.defer="dipendenteInline.{{ (int) $d->id }}.attivo">
@else <span>{{ (bool) ($this->dipendenteInline[(int) $d->id]['attivo'] ?? false) ? 'Attivo' : 'Disattivo' }}</span>
<span class="inline-flex items-center rounded-md bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-700">Disattivo</span> </label>
@endif </td>
<td class="border px-2 py-2">
<button type="button" wire:click="saveDipendenteInline({{ (int) $d->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">Salva</button>
</td> </td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="3" class="border px-2 py-4 text-center text-gray-500">Nessun dipendente collegato.</td> <td colspan="4" class="border px-2 py-4 text-center text-gray-500">Nessun dipendente collegato.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@ -466,6 +477,20 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5
</div> </div>
<div class="rounded-lg border p-3"> <div class="rounded-lg border p-3">
<div class="mb-4 rounded-lg border border-dashed p-3">
<div class="mb-2 text-xs font-semibold text-gray-700">Nuovo nominativo da collegare al fornitore</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<input type="text" wire:model.defer="newDipendenteNome" class="w-full rounded-md border-gray-300 text-xs" placeholder="Nome *">
<input type="text" wire:model.defer="newDipendenteCognome" class="w-full rounded-md border-gray-300 text-xs" placeholder="Cognome">
<input type="email" wire:model.defer="newDipendenteEmail" class="w-full rounded-md border-gray-300 text-xs" placeholder="Email">
<input type="text" wire:model.defer="newDipendenteTelefono" class="w-full rounded-md border-gray-300 text-xs" placeholder="Telefono">
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" wire:click="addDipendenteManuale" class="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 solo dipendente</button>
<button type="button" wire:click="createDipendenteRubricaAndLink" 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">Crea nominativo in rubrica e collega</button>
</div>
</div>
<div class="mb-2 text-xs font-semibold text-gray-700">Rubrica nominativi da agganciare</div> <div class="mb-2 text-xs font-semibold text-gray-700">Rubrica nominativi da agganciare</div>
<div class="mb-3"> <div class="mb-3">
<input <input

View File

@ -150,6 +150,57 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t
</div> </div>
</div> </div>
<div class="mt-3 grid gap-3 lg:grid-cols-2">
<div class="rounded-lg border bg-white p-3">
<div class="text-xs font-semibold text-gray-700">Ultimo backup pronto per update</div>
@if($this->latestBackupSummary)
<div class="mt-2 space-y-1 text-[11px] text-gray-600">
<div>Snapshot: <span class="font-mono">{{ $this->latestBackupSummary['snapshot_id'] }}</span></div>
<div>Creato: <span class="font-medium">{{ $this->latestBackupSummary['created_at'] }}</span></div>
<div>Copia locale: <span class="font-medium {{ $this->latestBackupSummary['zip_exists'] ? 'text-emerald-700' : 'text-rose-700' }}">{{ $this->latestBackupSummary['zip_exists'] ? 'OK' : 'mancante' }}</span></div>
<div>ZIP: <span class="break-all font-mono">{{ $this->latestBackupSummary['zip_path'] ?: '-' }}</span></div>
<div>Dimensione: <span class="font-medium">{{ $this->latestBackupSummary['zip_size_mb'] !== null ? number_format((float) $this->latestBackupSummary['zip_size_mb'], 2, ',', '.') . ' MB' : '-' }}</span></div>
<div>File copiati: <span class="font-medium">{{ (int) $this->latestBackupSummary['copied_files'] }}</span> · Tabelle indicizzate: <span class="font-medium">{{ (int) $this->latestBackupSummary['tables_count'] }}</span></div>
<div>
Copia Google Drive:
@if(is_array($this->latestBackupSummary['drive'] ?? null))
<span class="font-medium text-emerald-700">OK</span>
@if(!empty($this->latestBackupSummary['drive']['webViewLink']))
<a href="{{ $this->latestBackupSummary['drive']['webViewLink'] }}" target="_blank" class="ml-1 text-primary-600 hover:underline">Apri su Drive</a>
@endif
@else
<span class="font-medium text-rose-700">non disponibile</span>
@endif
</div>
</div>
@else
<div class="mt-2 text-[11px] text-gray-500">Nessun backup pre-update registrato.</div>
@endif
</div>
<div class="rounded-lg border bg-white p-3">
<div class="text-xs font-semibold text-gray-700">Anteprima pacchetto in arrivo</div>
@if($this->lastManifestPreview)
<div class="mt-2 space-y-1 text-[11px] text-gray-600">
<div>Versione remota: <span class="font-semibold">{{ $this->lastManifestPreview['version'] }}</span></div>
<div>Migration richieste: <span class="font-medium {{ $this->lastManifestPreview['migrate_required'] ? 'text-amber-700' : 'text-emerald-700' }}">{{ $this->lastManifestPreview['migrate_required'] ? 'si' : 'no' }}</span></div>
<div>Pacchetto: <span class="break-all font-mono">{{ $this->lastManifestPreview['package_url'] }}</span></div>
<div>SHA256: <span class="break-all font-mono">{{ $this->lastManifestPreview['sha256'] }}</span></div>
@if(filled($this->lastManifestPreview['release_notes'] ?? null))
<div class="rounded bg-slate-50 px-2 py-1 text-[10px] text-slate-700">{{ $this->lastManifestPreview['release_notes'] }}</div>
@endif
@if(!empty($this->lastManifestPreview['security']))
<div class="rounded border border-amber-300 bg-amber-50 px-2 py-1 text-[10px] text-amber-900">
Aggiornamenti sicurezza: {{ is_array($this->lastManifestPreview['security']) ? json_encode($this->lastManifestPreview['security'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $this->lastManifestPreview['security'] }}
</div>
@endif
</div>
@else
<div class="mt-2 text-[11px] text-gray-500">Esegui “Check connettivita update” per leggere manifest e contenuto dell aggiornamento prima del lancio.</div>
@endif
</div>
</div>
<div class="mt-3 rounded-lg border bg-blue-50 p-3"> <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> <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"> <ul class="list-disc space-y-1 pl-4 text-[11px] text-blue-900">

View File

@ -276,12 +276,12 @@
@forelse($ticket->attachments as $a) @forelse($ticket->attachments as $a)
<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($a->isImage())
<img src="{{ $this->getAttachmentPublicUrl($a) }}" 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>
<div class="text-sm font-semibold">{{ strtoupper(pathinfo((string) $a->original_file_name, PATHINFO_EXTENSION) ?: 'FILE') }}</div> <div class="text-sm font-semibold">{{ strtoupper(pathinfo((string) $a->original_file_name, PATHINFO_EXTENSION) ?: ($a->isPdf() ? 'PDF' : 'FILE')) }}</div>
<div class="mt-1 text-[11px]">Anteprima disponibile in modal</div> <div class="mt-1 text-[11px]">Anteprima disponibile in modal</div>
</div> </div>
</div> </div>
@ -342,7 +342,7 @@
@if($attachmentPreview) @if($attachmentPreview)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div class="w-full max-w-4xl rounded-xl bg-white shadow-2xl"> <div class="h-[92vh] w-full max-w-[96vw] rounded-xl bg-white shadow-2xl">
<div class="flex items-center justify-between border-b px-4 py-3"> <div class="flex items-center justify-between border-b px-4 py-3">
<div> <div>
<div class="text-sm font-semibold">{{ $attachmentPreview['name'] ?? 'Allegato' }}</div> <div class="text-sm font-semibold">{{ $attachmentPreview['name'] ?? 'Allegato' }}</div>
@ -350,14 +350,17 @@
<div class="text-xs text-gray-500">{{ $attachmentPreview['description'] }}</div> <div class="text-xs text-gray-500">{{ $attachmentPreview['description'] }}</div>
@endif @endif
</div> </div>
<div class="flex items-center gap-2">
<a href="{{ $attachmentPreview['url'] }}" target="_blank" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Apri in nuova scheda</a>
<button type="button" wire:click="closeAttachmentPreview" class="rounded-md bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">Chiudi</button> <button type="button" wire:click="closeAttachmentPreview" class="rounded-md bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">Chiudi</button>
</div> </div>
</div>
<div class="max-h-[75vh] overflow-auto p-4"> <div class="h-[calc(92vh-64px)] overflow-auto p-4">
@if($attachmentPreview['is_image'] ?? false) @if($attachmentPreview['is_image'] ?? false)
<img src="{{ $attachmentPreview['url'] }}" alt="Anteprima allegato" class="mx-auto max-h-[70vh] w-auto rounded border" /> <img src="{{ $attachmentPreview['url'] }}" alt="Anteprima allegato" class="mx-auto max-h-[84vh] w-auto rounded border" />
@elseif($attachmentPreview['is_pdf'] ?? false) @elseif($attachmentPreview['is_pdf'] ?? false)
<iframe src="{{ $attachmentPreview['url'] }}" class="h-[70vh] w-full rounded border"></iframe> <iframe src="{{ $attachmentPreview['url'] }}" class="h-[84vh] w-full rounded border"></iframe>
@else @else
<div class="rounded-md border bg-gray-50 p-4 text-sm text-gray-700"> <div class="rounded-md border bg-gray-50 p-4 text-sm text-gray-700">
Anteprima non disponibile per questo formato. Scarica o apri il file: Anteprima non disponibile per questo formato. Scarica o apri il file:

View File

@ -94,7 +94,7 @@
<div class="rounded-md border p-2 text-xs"> <div class="rounded-md border p-2 text-xs">
<div class="font-medium">{{ $a->original_file_name }}</div> <div class="font-medium">{{ $a->original_file_name }}</div>
<div class="text-gray-500">{{ $a->description }}</div> <div class="text-gray-500">{{ $a->description }}</div>
<a target="_blank" href="{{ '/storage/' . ltrim((string) $a->file_path, '/') }}" class="text-primary-600 hover:underline">Apri allegato</a> <a target="_blank" href="{{ route('filament.tickets.attachments.view', ['attachment' => (int) $a->id]) }}" class="text-primary-600 hover:underline">Apri allegato</a>
</div> </div>
@empty @empty
<div class="text-xs text-gray-500">Nessun allegato.</div> <div class="text-xs text-gray-500">Nessun allegato.</div>