Checkpoint day0: sync staging fixes and operational workflows
This commit is contained in:
parent
c505104244
commit
fe16ef481b
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -106,6 +106,9 @@ storage/framework/cache/
|
|||
storage/framework/sessions/
|
||||
storage/framework/views/
|
||||
storage/backups/
|
||||
storage/app/distribution/
|
||||
storage/app/support/
|
||||
storage/app/update-backups/
|
||||
|
||||
# Local personal workspace (keep only operational note)
|
||||
Miki-Bug-workspace/**
|
||||
|
|
|
|||
258
app/Console/Commands/GoogleSyncScadenzeCalendarCommand.php
Normal file
258
app/Console/Commands/GoogleSyncScadenzeCalendarCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
260
app/Console/Commands/ImportScadenzeMdb.php
Normal file
260
app/Console/Commands/ImportScadenzeMdb.php
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Amministratore;
|
||||
|
|
@ -17,6 +16,7 @@ class NetgesconPreupdateBackupCommand extends Command
|
|||
{--admin-id= : ID amministratore per backup su Google Drive}
|
||||
{--differential : Copia solo file cambiati rispetto all ultimo snapshot}
|
||||
{--drive : Carica backup anche su Google Drive}
|
||||
{--require-drive : Fallisce se il backup Google Drive non viene completato}
|
||||
{--tag=auto : Etichetta operazione (es. auto/manual)}
|
||||
{--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,
|
||||
'zip_path' => $zipPath,
|
||||
'created_at' => now()->toIso8601String(),
|
||||
'copied_files' => $copiedCount,
|
||||
'tables' => $dbSummary,
|
||||
'files_manifest' => $manifest,
|
||||
];
|
||||
file_put_contents($latestPath, json_encode($latestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
|
@ -95,19 +97,29 @@ public function handle(): int
|
|||
$this->info('Backup locale creato: ' . $zipPath);
|
||||
|
||||
$driveInfo = null;
|
||||
$requireDrive = (bool) $this->option('require-drive');
|
||||
if ((bool) $this->option('drive')) {
|
||||
$adminId = (int) $this->option('admin-id');
|
||||
if ($adminId <= 0) {
|
||||
$this->warn('Drive upload saltato: --admin-id mancante.');
|
||||
if ($requireDrive) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
} else {
|
||||
$driveInfo = $this->uploadBackupToDrive($adminId, $zipPath, $snapshotId);
|
||||
if (is_array($driveInfo)) {
|
||||
$this->info('Backup caricato su Drive: ' . (string) ($driveInfo['webViewLink'] ?? $driveInfo['id'] ?? 'ok'));
|
||||
} else {
|
||||
$this->warn('Upload Drive non riuscito.');
|
||||
if ($requireDrive) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$latestPayload['drive'] = $driveInfo;
|
||||
file_put_contents($latestPath, json_encode($latestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$result = [
|
||||
'snapshot_id' => $snapshotId,
|
||||
|
|
@ -381,10 +393,8 @@ private function uploadDriveFile(string $token, string $parentId, string $name,
|
|||
$content .= "--{$boundary}--";
|
||||
|
||||
$response = Http::withToken($token)
|
||||
->withHeaders([
|
||||
'Content-Type' => 'multipart/related; boundary=' . $boundary,
|
||||
])
|
||||
->post('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,name,webViewLink', $content);
|
||||
->withBody($content, 'multipart/related; boundary=' . $boundary)
|
||||
->send('POST', 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,name,webViewLink');
|
||||
|
||||
if (! $response->successful()) {
|
||||
$this->warn('Upload Drive fallito: HTTP ' . $response->status());
|
||||
|
|
@ -398,11 +408,11 @@ private function uploadDriveFile(string $token, string $parentId, string $name,
|
|||
private function resolveGoogleAccessToken(Amministratore $admin, array $google, array $oauth): ?string
|
||||
{
|
||||
$accessToken = trim((string) Arr::get($oauth, 'access_token', ''));
|
||||
if ($accessToken !== '') {
|
||||
$refreshToken = trim((string) Arr::get($oauth, 'refresh_token', ''));
|
||||
if ($refreshToken === '' && $accessToken !== '') {
|
||||
return $accessToken;
|
||||
}
|
||||
|
||||
$refreshToken = trim((string) Arr::get($oauth, 'refresh_token', ''));
|
||||
if ($refreshToken === '') {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
use App\Filament\Pages\Contabilita\FatturaElettronicaScheda;
|
||||
use App\Models\Fornitore;
|
||||
use App\Models\Stabile;
|
||||
use App\Models\StabileServizio;
|
||||
use App\Models\StabileServizioLettura;
|
||||
use App\Models\UnitaImmobiliare;
|
||||
|
|
@ -30,6 +31,7 @@ class LettureServiziArchivio extends Page implements HasTable
|
|||
use InteractsWithTable;
|
||||
|
||||
public ?int $servizioFilter = null;
|
||||
public string $archivioScope = 'active';
|
||||
|
||||
protected static ?string $navigationLabel = 'Letture servizi';
|
||||
|
||||
|
|
@ -58,6 +60,8 @@ public function mount(): void
|
|||
{
|
||||
$reqServizio = request()->integer('servizio');
|
||||
$this->servizioFilter = $reqServizio > 0 ? $reqServizio : null;
|
||||
$scope = strtolower(trim((string) request()->query('scope', 'active')));
|
||||
$this->archivioScope = in_array($scope, ['active', 'all'], true) ? $scope : 'active';
|
||||
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
|
@ -69,15 +73,30 @@ protected function getTableQuery(): Builder
|
|||
return StabileServizioLettura::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$stabileId = StabileContext::resolveActiveStabileId($user);
|
||||
if (! $stabileId) {
|
||||
$stabiliIds = StabileContext::accessibleStabili($user)
|
||||
->pluck('id')
|
||||
->map(fn($id) => (int) $id)
|
||||
->filter(fn(int $id): bool => $id > 0)
|
||||
->values();
|
||||
|
||||
if ($stabiliIds->isEmpty()) {
|
||||
return StabileServizioLettura::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$activeStabileId = StabileContext::resolveActiveStabileId($user);
|
||||
if ($this->archivioScope !== 'all' && ! $activeStabileId) {
|
||||
return StabileServizioLettura::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return StabileServizioLettura::query()
|
||||
->where('stabile_id', (int) $stabileId)
|
||||
->when(
|
||||
$this->archivioScope === 'all',
|
||||
fn(Builder $q) => $q->whereIn('stabile_id', $stabiliIds->all()),
|
||||
fn(Builder $q) => $q->where('stabile_id', (int) $activeStabileId)
|
||||
)
|
||||
->when($this->servizioFilter, fn(Builder $q) => $q->where('stabile_servizio_id', (int) $this->servizioFilter))
|
||||
->with([
|
||||
'stabile:id,codice_stabile,denominazione',
|
||||
'servizio:id,stabile_id,tipo,nome,contatore_matricola',
|
||||
'fornitore:id,ragione_sociale',
|
||||
'voceSpesa:id,descrizione,tipo_gestione',
|
||||
|
|
@ -130,6 +149,19 @@ public function table(Table $table): Table
|
|||
return $query->whereHas('servizio', fn(Builder $q) => $q->where('tipo', $value));
|
||||
}),
|
||||
|
||||
SelectFilter::make('stabile_id')
|
||||
->label('Stabile')
|
||||
->options(fn() => $this->getStabiliOptions())
|
||||
->searchable()
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = (int) ($data['value'] ?? 0);
|
||||
if ($value <= 0) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where('stabile_id', $value);
|
||||
}),
|
||||
|
||||
SelectFilter::make('tipo_gestione')
|
||||
->label('Gestione (O/R/S)')
|
||||
->options($gestioneOptions)
|
||||
|
|
@ -146,6 +178,22 @@ public function table(Table $table): Table
|
|||
TextColumn::make('periodo_dal')->label('Dal')->date('d/m/Y')->sortable()->toggleable(),
|
||||
TextColumn::make('periodo_al')->label('Al')->date('d/m/Y')->sortable(),
|
||||
|
||||
TextColumn::make('stabile.denominazione')
|
||||
->label('Stabile')
|
||||
->formatStateUsing(function ($state, StabileServizioLettura $record): string {
|
||||
$stabile = $record->stabile;
|
||||
if (! $stabile instanceof Stabile) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$codice = trim((string) ($stabile->codice_stabile ?? ''));
|
||||
$nome = trim((string) ($stabile->denominazione ?? ''));
|
||||
|
||||
return trim($codice . ($nome !== '' ? (' - ' . $nome) : '')) ?: ('Stabile #' . $stabile->id);
|
||||
})
|
||||
->wrap()
|
||||
->toggleable(isToggledHiddenByDefault: $this->archivioScope !== 'all'),
|
||||
|
||||
TextColumn::make('servizio.nome')
|
||||
->label('Servizio')
|
||||
->formatStateUsing(function ($state, StabileServizioLettura $record): string {
|
||||
|
|
@ -217,8 +265,38 @@ public function table(Table $table): Table
|
|||
})
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('workflow_stato')
|
||||
->label('Workflow')
|
||||
->formatStateUsing(fn($state): string => trim((string) $state) !== '' ? (string) $state : 'Acquisita')
|
||||
->badge()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('protocollo_numero')
|
||||
->label('Protocollo')
|
||||
->formatStateUsing(function ($state, StabileServizioLettura $record): string {
|
||||
$numero = trim((string) ($state ?? ''));
|
||||
$categoria = trim((string) ($record->protocollo_categoria ?? ''));
|
||||
|
||||
if ($numero === '' && $categoria === '') {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return trim($categoria . ($numero !== '' ? (' #' . $numero) : ''));
|
||||
})
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('prossima_lettura_scadenza_at')
|
||||
->label('Scadenza prossima lettura')
|
||||
->dateTime('d/m/Y H:i')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('riferimento_acquisizione')->label('Riferimento')->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('archivio_documentale_path')
|
||||
->label('Archivio documentale')
|
||||
->wrap()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('fatturaElettronica.numero_fattura')
|
||||
->label('Fattura FE')
|
||||
->url(fn(StabileServizioLettura $record): ?string => $record->fattura_elettronica_id
|
||||
|
|
@ -238,6 +316,14 @@ public function table(Table $table): Table
|
|||
TextColumn::make('created_at')->label('Creato')->dateTime('d/m/Y H:i')->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('toggleScope')
|
||||
->label(fn(): string => $this->archivioScope === 'all' ? 'Vista: tutti gli stabili' : 'Vista: stabile attivo')
|
||||
->icon('heroicon-o-building-office-2')
|
||||
->color(fn(): string => $this->archivioScope === 'all' ? 'primary' : 'gray')
|
||||
->action(function (): void {
|
||||
$this->archivioScope = $this->archivioScope === 'all' ? 'active' : 'all';
|
||||
}),
|
||||
|
||||
Action::make('create')
|
||||
->label('Nuova')
|
||||
->icon('heroicon-o-plus')
|
||||
|
|
@ -274,7 +360,23 @@ public function table(Table $table): Table
|
|||
'm_bus' => 'Contatore elettronico',
|
||||
'pdf_ocr' => 'PDF/OCR',
|
||||
]),
|
||||
Select::make('workflow_stato')
|
||||
->label('Workflow')
|
||||
->options([
|
||||
'acquisita' => 'Acquisita',
|
||||
'da_richiedere' => 'Da richiedere',
|
||||
'richiesta_inviata' => 'Richiesta inviata',
|
||||
'ricevuta' => 'Ricevuta',
|
||||
'protocollata' => 'Protocollata',
|
||||
'archiviata' => 'Archiviata',
|
||||
])
|
||||
->default('acquisita'),
|
||||
TextInput::make('protocollo_categoria')->label('Categoria protocollo')->maxLength(40),
|
||||
TextInput::make('protocollo_numero')->label('Numero protocollo')->maxLength(50),
|
||||
TextInput::make('riferimento_acquisizione')->label('Riferimento acquisizione')->maxLength(191),
|
||||
DatePicker::make('richiesta_lettura_inviata_at')->label('Richiesta inviata il')->native(false),
|
||||
DatePicker::make('prossima_lettura_scadenza_at')->label('Prossima scadenza lettura')->native(false),
|
||||
TextInput::make('archivio_documentale_path')->label('Path archivio documentale')->maxLength(500),
|
||||
TextInput::make('lettura_inizio')->label('Lettura inizio')->numeric(),
|
||||
TextInput::make('lettura_fine')->label('Lettura fine')->numeric(),
|
||||
TextInput::make('consumo_valore')->label('Consumo valore')->numeric(),
|
||||
|
|
@ -302,7 +404,13 @@ public function table(Table $table): Table
|
|||
'periodo_al' => $data['periodo_al'] ?? null,
|
||||
'tipologia_lettura' => $data['tipologia_lettura'] ?? null,
|
||||
'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: 'manuale',
|
||||
'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: 'acquisita',
|
||||
'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null,
|
||||
'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null,
|
||||
'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null,
|
||||
'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null,
|
||||
'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null,
|
||||
'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null,
|
||||
'lettura_inizio' => $data['lettura_inizio'] ?? null,
|
||||
'lettura_fine' => $data['lettura_fine'] ?? null,
|
||||
'consumo_valore' => $data['consumo_valore'] ?? null,
|
||||
|
|
@ -365,7 +473,13 @@ public function table(Table $table): Table
|
|||
'periodo_al' => $record->periodo_al,
|
||||
'tipologia_lettura' => (string) ($record->tipologia_lettura ?? ''),
|
||||
'canale_acquisizione' => (string) ($record->canale_acquisizione ?? ''),
|
||||
'workflow_stato' => (string) ($record->workflow_stato ?? ''),
|
||||
'protocollo_categoria' => (string) ($record->protocollo_categoria ?? ''),
|
||||
'protocollo_numero' => (string) ($record->protocollo_numero ?? ''),
|
||||
'riferimento_acquisizione' => (string) ($record->riferimento_acquisizione ?? ''),
|
||||
'richiesta_lettura_inviata_at' => $record->richiesta_lettura_inviata_at,
|
||||
'prossima_lettura_scadenza_at' => $record->prossima_lettura_scadenza_at,
|
||||
'archivio_documentale_path' => (string) ($record->archivio_documentale_path ?? ''),
|
||||
'lettura_inizio' => $record->lettura_inizio,
|
||||
'lettura_fine' => $record->lettura_fine,
|
||||
'consumo_valore' => $record->consumo_valore,
|
||||
|
|
@ -382,7 +496,13 @@ public function table(Table $table): Table
|
|||
'periodo_al' => $data['periodo_al'] ?? null,
|
||||
'tipologia_lettura' => $data['tipologia_lettura'] ?? null,
|
||||
'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: null,
|
||||
'workflow_stato' => trim((string) ($data['workflow_stato'] ?? '')) ?: null,
|
||||
'protocollo_categoria' => trim((string) ($data['protocollo_categoria'] ?? '')) ?: null,
|
||||
'protocollo_numero' => trim((string) ($data['protocollo_numero'] ?? '')) ?: null,
|
||||
'riferimento_acquisizione' => trim((string) ($data['riferimento_acquisizione'] ?? '')) ?: null,
|
||||
'richiesta_lettura_inviata_at' => $data['richiesta_lettura_inviata_at'] ?? null,
|
||||
'prossima_lettura_scadenza_at' => $data['prossima_lettura_scadenza_at'] ?? null,
|
||||
'archivio_documentale_path' => trim((string) ($data['archivio_documentale_path'] ?? '')) ?: null,
|
||||
'lettura_inizio' => $data['lettura_inizio'] ?? null,
|
||||
'lettura_fine' => $data['lettura_fine'] ?? null,
|
||||
'consumo_valore' => $data['consumo_valore'] ?? null,
|
||||
|
|
@ -436,6 +556,27 @@ private function getServiziOptions(): array
|
|||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ class FornitoriArchivio extends Page
|
|||
public string $activeTab = 'elenco';
|
||||
public ?int $selectedFornitoreId = null;
|
||||
|
||||
/** @var Collection<int, Fornitore> */
|
||||
public $fornitoreMatches;
|
||||
|
||||
/** @var array<string, string> */
|
||||
public array $automazioni = [
|
||||
'fornitore_acqua' => '0',
|
||||
|
|
@ -64,6 +67,9 @@ class FornitoriArchivio extends Page
|
|||
public string $newDipendenteEmail = '';
|
||||
public string $newDipendenteTelefono = '';
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $dipendenteInline = [];
|
||||
|
||||
protected static ?string $navigationLabel = 'Fornitori';
|
||||
|
||||
protected static ?string $title = 'Fornitori';
|
||||
|
|
@ -93,7 +99,9 @@ public function mount(): void
|
|||
$this->fornitoriCount = 0;
|
||||
}
|
||||
|
||||
$this->fornitoreMatches = new Collection();
|
||||
$this->searchInput = $this->fornitoriSearch;
|
||||
$this->searchFornitoreMatches();
|
||||
}
|
||||
|
||||
protected function getTableQuery(): Builder
|
||||
|
|
@ -131,31 +139,22 @@ public function getFornitoriRowsProperty(): Collection
|
|||
->orderBy('cognome')
|
||||
->orderBy('nome');
|
||||
|
||||
$term = trim($this->fornitoriSearch);
|
||||
if ($term !== '') {
|
||||
$hasTags = Schema::hasColumn('fornitori', 'tags');
|
||||
$like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%';
|
||||
|
||||
$query->where(function (Builder $sub) use ($like, $hasTags): void {
|
||||
$sub->where('ragione_sociale', 'like', $like)
|
||||
->orWhere('nome', 'like', $like)
|
||||
->orWhere('cognome', 'like', $like)
|
||||
->orWhere('partita_iva', 'like', $like)
|
||||
->orWhere('codice_fiscale', 'like', $like)
|
||||
->orWhere('email', 'like', $like)
|
||||
->orWhere('telefono', 'like', $like);
|
||||
|
||||
if ($hasTags) {
|
||||
$sub->orWhere('tags', 'like', $like);
|
||||
}
|
||||
});
|
||||
}
|
||||
$this->applyFornitoreSearch($query, trim($this->fornitoriSearch));
|
||||
|
||||
return $query->limit(400)->get();
|
||||
}
|
||||
|
||||
public function updatedFornitoriSearch(): void
|
||||
{}
|
||||
{
|
||||
$this->searchInput = $this->fornitoriSearch;
|
||||
$this->searchFornitoreMatches();
|
||||
}
|
||||
|
||||
public function updatedSearchInput(): void
|
||||
{
|
||||
$this->fornitoriSearch = trim($this->searchInput);
|
||||
$this->searchFornitoreMatches();
|
||||
}
|
||||
|
||||
public function getFornitoreLabel(Fornitore $fornitore): string
|
||||
{
|
||||
|
|
@ -277,6 +276,12 @@ public function apriElenco(): void
|
|||
$this->activeTab = 'elenco';
|
||||
}
|
||||
|
||||
public function apriNuovoFornitore(): void
|
||||
{
|
||||
$this->selectedFornitoreId = null;
|
||||
$this->activeTab = 'scheda';
|
||||
}
|
||||
|
||||
public function apriTabScheda(): void
|
||||
{
|
||||
if ((int) ($this->selectedFornitoreId ?? 0) <= 0) {
|
||||
|
|
@ -310,6 +315,7 @@ public function apriTabDipendenti(): void
|
|||
public function applicaRicerca(): void
|
||||
{
|
||||
$this->fornitoriSearch = trim($this->searchInput);
|
||||
$this->searchFornitoreMatches();
|
||||
$this->activeTab = 'elenco';
|
||||
}
|
||||
|
||||
|
|
@ -317,6 +323,7 @@ public function pulisciRicerca(): void
|
|||
{
|
||||
$this->fornitoriSearch = '';
|
||||
$this->searchInput = '';
|
||||
$this->searchFornitoreMatches();
|
||||
$this->activeTab = 'elenco';
|
||||
}
|
||||
|
||||
|
|
@ -329,6 +336,7 @@ public function removeSearchFilter(string $token): void
|
|||
|
||||
$this->fornitoriSearch = implode(' e ', $tokens);
|
||||
$this->searchInput = $this->fornitoriSearch;
|
||||
$this->searchFornitoreMatches();
|
||||
$this->activeTab = 'elenco';
|
||||
}
|
||||
|
||||
|
|
@ -343,12 +351,15 @@ public function apriScheda(int $fornitoreId): void
|
|||
$this->selectedFornitoreId = (int) $fornitore->id;
|
||||
$this->activeTab = 'scheda';
|
||||
$this->loadSchedaState($fornitore);
|
||||
$this->loadDipendentiInlineState();
|
||||
}
|
||||
|
||||
public function updatedSelectedFornitoreId(): void
|
||||
{
|
||||
if (in_array($this->activeTab, ['scheda', 'dipendenti'], true)) {
|
||||
$this->hydrateSchedaFromSelected();
|
||||
}
|
||||
}
|
||||
|
||||
public function createFornitoreRapido(): void
|
||||
{
|
||||
|
|
@ -402,6 +413,9 @@ public function createFornitoreRapido(): void
|
|||
|
||||
$this->fornitoriCount = (int) $this->getTableQuery()->count();
|
||||
$this->apriScheda((int) $fornitore->id);
|
||||
$this->searchInput = $this->getFornitoreLabel($fornitore);
|
||||
$this->fornitoriSearch = $this->searchInput;
|
||||
$this->searchFornitoreMatches();
|
||||
|
||||
Notification::make()->title('Fornitore creato')->success()->send();
|
||||
}
|
||||
|
|
@ -462,6 +476,7 @@ public function saveSchedaAnagrafica(): void
|
|||
$fornitore->save();
|
||||
|
||||
$this->loadSchedaState($fornitore->fresh());
|
||||
$this->searchFornitoreMatches();
|
||||
Notification::make()->title('Anagrafica fornitore aggiornata')->success()->send();
|
||||
}
|
||||
|
||||
|
|
@ -506,10 +521,43 @@ public function addDipendenteManuale(): void
|
|||
$this->newDipendenteCognome = '';
|
||||
$this->newDipendenteEmail = '';
|
||||
$this->newDipendenteTelefono = '';
|
||||
$this->loadDipendentiInlineState();
|
||||
|
||||
Notification::make()->title('Dipendente aggiunto')->success()->send();
|
||||
}
|
||||
|
||||
public function createDipendenteRubricaAndLink(): void
|
||||
{
|
||||
$fornitore = $this->selectedFornitore;
|
||||
if (! $fornitore instanceof Fornitore) {
|
||||
Notification::make()->title('Seleziona prima un fornitore')->warning()->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$nome = trim($this->newDipendenteNome);
|
||||
if ($nome === '') {
|
||||
Notification::make()->title('Nome nominativo obbligatorio')->warning()->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$rubrica = RubricaUniversale::query()->create([
|
||||
'nome' => $nome,
|
||||
'cognome' => $this->cleanNullable($this->newDipendenteCognome),
|
||||
'email' => $this->cleanNullable($this->newDipendenteEmail),
|
||||
'telefono_cellulare' => $this->cleanNullable($this->newDipendenteTelefono),
|
||||
'tipo_contatto' => 'persona_fisica',
|
||||
'categoria' => 'fornitore',
|
||||
'stato' => 'attivo',
|
||||
'note' => 'Creato da Fornitori > Dipendenti per il fornitore #' . (int) $fornitore->id,
|
||||
'data_inserimento' => now()->toDateString(),
|
||||
'data_ultima_modifica' => now()->toDateString(),
|
||||
'creato_da' => Auth::id(),
|
||||
'modificato_da' => Auth::id(),
|
||||
]);
|
||||
|
||||
$this->addDipendenteFromRubrica((int) $rubrica->id);
|
||||
}
|
||||
|
||||
public function addDipendenteFromRubrica(int $rubricaId): void
|
||||
{
|
||||
$fornitore = $this->selectedFornitore;
|
||||
|
|
@ -581,10 +629,51 @@ public function addDipendenteFromRubrica(int $rubricaId): void
|
|||
$dipendente->note = Str::limit(trim($linkNote . ($existingNote !== '' ? (' | ' . $existingNote) : '')), 2000, '');
|
||||
$dipendente->save();
|
||||
|
||||
$this->loadDipendentiInlineState();
|
||||
|
||||
Notification::make()->title('Dipendente collegato al fornitore')->success()->send();
|
||||
$this->activeTab = 'dipendenti';
|
||||
}
|
||||
|
||||
public function saveDipendenteInline(int $dipendenteId): void
|
||||
{
|
||||
$fornitore = $this->selectedFornitore;
|
||||
if (! $fornitore instanceof Fornitore) {
|
||||
Notification::make()->title('Seleziona prima un fornitore')->warning()->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$dipendente = FornitoreDipendente::query()
|
||||
->where('fornitore_id', (int) $fornitore->id)
|
||||
->find($dipendenteId);
|
||||
|
||||
if (! $dipendente instanceof FornitoreDipendente) {
|
||||
Notification::make()->title('Dipendente non trovato')->danger()->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$state = $this->dipendenteInline[$dipendenteId] ?? [];
|
||||
$data = Validator::make($state, [
|
||||
'nome' => ['required', 'string', 'max:255'],
|
||||
'cognome' => ['nullable', 'string', 'max:255'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'telefono' => ['nullable', 'string', 'max:64'],
|
||||
'attivo' => ['nullable', 'boolean'],
|
||||
])->validate();
|
||||
|
||||
$dipendente->nome = trim((string) ($data['nome'] ?? ''));
|
||||
$dipendente->cognome = $this->cleanNullable($data['cognome'] ?? null);
|
||||
$dipendente->email = $this->cleanNullable($data['email'] ?? null);
|
||||
$dipendente->telefono = $this->cleanNullable($data['telefono'] ?? null);
|
||||
$dipendente->attivo = (bool) ($data['attivo'] ?? false);
|
||||
$dipendente->updated_by_user_id = Auth::id();
|
||||
$dipendente->save();
|
||||
|
||||
$this->loadDipendentiInlineState();
|
||||
|
||||
Notification::make()->title('Dipendente aggiornato')->success()->send();
|
||||
}
|
||||
|
||||
public function saveSchedaAutomazioni(): void
|
||||
{
|
||||
$fornitore = $this->selectedFornitore;
|
||||
|
|
@ -654,6 +743,23 @@ private function loadSchedaState(Fornitore $fornitore): void
|
|||
$this->editNote = (string) ($fornitore->note ?? '');
|
||||
}
|
||||
|
||||
private function loadDipendentiInlineState(): void
|
||||
{
|
||||
$rows = $this->getDipendentiRowsProperty();
|
||||
|
||||
$this->dipendenteInline = $rows->mapWithKeys(function (FornitoreDipendente $dipendente): array {
|
||||
return [
|
||||
(int) $dipendente->id => [
|
||||
'nome' => (string) ($dipendente->nome ?? ''),
|
||||
'cognome' => (string) ($dipendente->cognome ?? ''),
|
||||
'email' => (string) ($dipendente->email ?? ''),
|
||||
'telefono' => (string) ($dipendente->telefono ?? ''),
|
||||
'attivo' => (bool) ($dipendente->attivo ?? false),
|
||||
],
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
private function resolveAmministratoreId(): int
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
|
@ -670,7 +776,7 @@ private function resolveAmministratoreId(): int
|
|||
return (int) ($stabile?->amministratore_id ?? 0);
|
||||
}
|
||||
|
||||
private function hydrateSchedaFromSelected(): void
|
||||
public function hydrateSchedaFromSelected(): void
|
||||
{
|
||||
if ((int) ($this->selectedFornitoreId ?? 0) <= 0) {
|
||||
return;
|
||||
|
|
@ -682,6 +788,67 @@ private function hydrateSchedaFromSelected(): void
|
|||
}
|
||||
|
||||
$this->loadSchedaState($fornitore);
|
||||
$this->loadDipendentiInlineState();
|
||||
}
|
||||
|
||||
private function applyFornitoreSearch(Builder $query, string $raw): Builder
|
||||
{
|
||||
$tokens = $this->extractSearchTokens($raw);
|
||||
if ($tokens === []) {
|
||||
return $query;
|
||||
}
|
||||
$hasTags = Schema::hasColumn('fornitori', 'tags');
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$term = trim($token);
|
||||
if ($term === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $term) ?: '';
|
||||
$needleText = '%' . mb_strtolower($term) . '%';
|
||||
$needle = '%' . $digits . '%';
|
||||
|
||||
$query->where(function (Builder $sub) use ($needleText, $needle, $digits, $hasTags): void {
|
||||
$sub->whereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$needleText])
|
||||
->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$needleText])
|
||||
->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$needleText])
|
||||
->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$needleText])
|
||||
->orWhereRaw("LOWER(COALESCE(partita_iva, '')) LIKE ?", [$needleText])
|
||||
->orWhereRaw("LOWER(COALESCE(codice_fiscale, '')) LIKE ?", [$needleText]);
|
||||
|
||||
if ($hasTags) {
|
||||
$sub->orWhereRaw("LOWER(COALESCE(tags, '')) LIKE ?", [$needleText]);
|
||||
}
|
||||
|
||||
if ($digits !== '') {
|
||||
$sub->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
|
||||
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function searchFornitoreMatches(): void
|
||||
{
|
||||
$raw = trim($this->searchInput !== '' ? $this->searchInput : $this->fornitoriSearch);
|
||||
if ($raw === '') {
|
||||
$this->fornitoreMatches = new Collection();
|
||||
return;
|
||||
}
|
||||
|
||||
$query = $this->getTableQuery()
|
||||
->withCount('dipendenti')
|
||||
->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedFornitoreId ?? 0)])
|
||||
->orderBy('ragione_sociale')
|
||||
->orderBy('cognome')
|
||||
->orderBy('nome');
|
||||
|
||||
$this->fornitoreMatches = $this->applyFornitoreSearch($query, $raw)
|
||||
->limit(12)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function cleanNullable(?string $value): ?string
|
||||
|
|
|
|||
|
|
@ -133,14 +133,39 @@ public function table(Table $table): Table
|
|||
return $query;
|
||||
}
|
||||
|
||||
$like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $search) . '%';
|
||||
$tokens = preg_split('/(?:[,;|+]|
|
||||
\s(?:e|ed|and)\s|
|
||||
\s+
|
||||
)+/ux', $search) ?: [];
|
||||
$tokens = array_values(array_filter(array_map(static fn(string $value): string => trim($value), $tokens), static fn(string $value): bool => $value !== ''));
|
||||
|
||||
return $query->where(function (Builder $q) use ($like) {
|
||||
$q->where('ragione_sociale', 'like', $like)
|
||||
->orWhere('nome', 'like', $like)
|
||||
->orWhere('cognome', 'like', $like)
|
||||
->orWhereRaw("TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, ''))) LIKE ?", [$like]);
|
||||
if ($tokens === []) {
|
||||
$tokens = [$search];
|
||||
}
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$like = '%' . mb_strtolower($token) . '%';
|
||||
$digits = preg_replace('/\D+/', '', $token) ?: '';
|
||||
$query->where(function (Builder $q) use ($like, $digits) {
|
||||
$q->whereRaw("LOWER(COALESCE(ragione_sociale, '')) LIKE ?", [$like])
|
||||
->orWhereRaw("LOWER(COALESCE(nome, '')) LIKE ?", [$like])
|
||||
->orWhereRaw("LOWER(COALESCE(cognome, '')) LIKE ?", [$like])
|
||||
->orWhereRaw("LOWER(TRIM(CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, '')))) LIKE ?", [$like])
|
||||
->orWhereRaw("LOWER(COALESCE(email, '')) LIKE ?", [$like])
|
||||
->orWhereRaw("LOWER(COALESCE(pec, '')) LIKE ?", [$like])
|
||||
->orWhereRaw("LOWER(COALESCE(codice_fiscale, '')) LIKE ?", [$like])
|
||||
->orWhereRaw("LOWER(COALESCE(partita_iva, '')) LIKE ?", [$like]);
|
||||
|
||||
if ($digits !== '') {
|
||||
$digitLike = '%' . $digits . '%';
|
||||
$q->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$digitLike])
|
||||
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$digitLike])
|
||||
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$digitLike]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}),
|
||||
TextColumn::make('categoria')->label('Categoria')->sortable()->toggleable(),
|
||||
TextColumn::make('partita_iva')->label('P.IVA')->searchable()->toggleable(isToggledHiddenByDefault: true),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
|
||||
use App\Models\Fornitore;
|
||||
use App\Models\FornitoreDipendente;
|
||||
use App\Models\Persona;
|
||||
use App\Models\PersonaEmailMultipla;
|
||||
use App\Models\RubricaContattoCanale;
|
||||
use App\Models\RubricaRuolo;
|
||||
use App\Models\RubricaUniversale;
|
||||
|
|
@ -20,6 +22,7 @@
|
|||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilderContract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -91,6 +94,9 @@ class RubricaUniversaleScheda extends Page
|
|||
/** @var array<string,mixed> */
|
||||
public array $inlineForm = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $emailMultiple = [];
|
||||
|
||||
public function mount(int | string $record): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
|
@ -259,6 +265,7 @@ public function mount(int | string $record): void
|
|||
])
|
||||
->all();
|
||||
|
||||
$this->hydrateEmailMultiple();
|
||||
$this->fillInlineForm();
|
||||
$this->hydrateFornitoriWorkspace();
|
||||
}
|
||||
|
|
@ -277,6 +284,9 @@ public function cancelInlineEdit(): void
|
|||
|
||||
public function saveInlineEdit(): void
|
||||
{
|
||||
$legacyIdentity = $this->extractLegacyIdentityFromNote($this->inlineForm['note'] ?? null);
|
||||
$resolvedAddress = $this->parseAddressComponents($this->inlineForm['indirizzo_completo'] ?? null);
|
||||
|
||||
$data = validator($this->inlineForm, [
|
||||
'nome' => ['nullable', 'string', 'max:255'],
|
||||
'cognome' => ['nullable', 'string', 'max:255'],
|
||||
|
|
@ -285,11 +295,21 @@ public function saveInlineEdit(): void
|
|||
'categoria' => ['nullable', 'string', 'max:120'],
|
||||
'codice_fiscale' => ['nullable', 'string', 'max:32'],
|
||||
'partita_iva' => ['nullable', 'string', 'max:32'],
|
||||
'sesso' => ['nullable', 'string', 'max:1'],
|
||||
'data_nascita' => ['nullable', 'date'],
|
||||
'luogo_nascita' => ['nullable', 'string', 'max:100'],
|
||||
'provincia_nascita' => ['nullable', 'string', 'max:2'],
|
||||
'email' => ['nullable', 'string', 'max:255'],
|
||||
'pec' => ['nullable', 'string', 'max:255'],
|
||||
'telefono_ufficio' => ['nullable', 'string', 'max:64'],
|
||||
'telefono_cellulare' => ['nullable', 'string', 'max:64'],
|
||||
'telefono_casa' => ['nullable', 'string', 'max:64'],
|
||||
'indirizzo' => ['nullable', 'string', 'max:255'],
|
||||
'civico' => ['nullable', 'string', 'max:32'],
|
||||
'cap' => ['nullable', 'string', 'max:5'],
|
||||
'citta' => ['nullable', 'string', 'max:100'],
|
||||
'provincia' => ['nullable', 'string', 'max:2'],
|
||||
'nazione' => ['nullable', 'string', 'max:50'],
|
||||
'indirizzo_completo' => ['nullable', 'string', 'max:255'],
|
||||
'tipo_utenza_call' => ['nullable', 'string', 'max:120'],
|
||||
'riferimento_stabile' => ['nullable', 'string', 'max:255'],
|
||||
|
|
@ -298,6 +318,17 @@ public function saveInlineEdit(): void
|
|||
'note_segreteria' => ['nullable', 'string', 'max:5000'],
|
||||
])->validate();
|
||||
|
||||
$normalizedBirthDate = $this->normalizeLegacyDateValue($data['data_nascita'] ?? $legacyIdentity['data_nascita'] ?? null);
|
||||
$cleanNote = $this->cleanNullable($legacyIdentity['note_clean'] ?? ($data['note'] ?? null));
|
||||
$sesso = $this->normalizeSessoValue($data['sesso'] ?? $legacyIdentity['sesso'] ?? null);
|
||||
$provinciaNascita = $this->normalizeProvince($data['provincia_nascita'] ?? $legacyIdentity['provincia_nascita'] ?? null);
|
||||
|
||||
$indirizzo = $this->cleanNullable($data['indirizzo'] ?? null) ?: ($resolvedAddress['indirizzo'] ?? null);
|
||||
$civico = $this->cleanNullable($data['civico'] ?? null) ?: ($resolvedAddress['civico'] ?? null);
|
||||
$cap = $this->cleanNullable($data['cap'] ?? null) ?: ($resolvedAddress['cap'] ?? null);
|
||||
$citta = $this->cleanNullable($data['citta'] ?? null) ?: ($resolvedAddress['citta'] ?? null);
|
||||
$provincia = $this->normalizeProvince($data['provincia'] ?? null) ?: ($resolvedAddress['provincia'] ?? null);
|
||||
|
||||
$this->rubrica->fill([
|
||||
'nome' => $this->cleanNullable($data['nome'] ?? null),
|
||||
'cognome' => $this->cleanNullable($data['cognome'] ?? null),
|
||||
|
|
@ -306,21 +337,34 @@ public function saveInlineEdit(): void
|
|||
'categoria' => $this->cleanNullable($data['categoria'] ?? null),
|
||||
'codice_fiscale' => $this->cleanNullable($data['codice_fiscale'] ?? null),
|
||||
'partita_iva' => $this->cleanNullable($data['partita_iva'] ?? null),
|
||||
'sesso' => $sesso,
|
||||
'data_nascita' => $normalizedBirthDate,
|
||||
'luogo_nascita' => $this->cleanNullable($data['luogo_nascita'] ?? $legacyIdentity['luogo_nascita'] ?? null),
|
||||
'provincia_nascita' => $provinciaNascita,
|
||||
'email' => $this->cleanNullable($data['email'] ?? null),
|
||||
'pec' => $this->cleanNullable($data['pec'] ?? null),
|
||||
'telefono_ufficio' => $this->cleanNullable($data['telefono_ufficio'] ?? null),
|
||||
'telefono_cellulare' => $this->cleanNullable($data['telefono_cellulare'] ?? null),
|
||||
'telefono_casa' => $this->cleanNullable($data['telefono_casa'] ?? null),
|
||||
'indirizzo_completo' => $this->cleanNullable($data['indirizzo_completo'] ?? null),
|
||||
'indirizzo' => $indirizzo,
|
||||
'civico' => $civico,
|
||||
'cap' => $cap,
|
||||
'citta' => $citta,
|
||||
'provincia' => $provincia,
|
||||
'nazione' => $this->cleanNullable($data['nazione'] ?? null) ?: $this->rubrica->nazione,
|
||||
'tipo_utenza_call' => $this->cleanNullable($data['tipo_utenza_call'] ?? null),
|
||||
'riferimento_stabile' => $this->cleanNullable($data['riferimento_stabile'] ?? null),
|
||||
'riferimento_unita' => $this->cleanNullable($data['riferimento_unita'] ?? null),
|
||||
'note' => $this->cleanNullable($data['note'] ?? null),
|
||||
'note' => $cleanNote,
|
||||
'note_segreteria' => $this->cleanNullable($data['note_segreteria'] ?? null),
|
||||
'data_ultima_modifica' => now()->toDateString(),
|
||||
'modificato_da' => Auth::id(),
|
||||
]);
|
||||
|
||||
if (! $this->rubrica->titolo_id && ! empty($legacyIdentity['titolo_id'])) {
|
||||
$this->rubrica->titolo_id = (int) $legacyIdentity['titolo_id'];
|
||||
}
|
||||
|
||||
$this->rubrica->save();
|
||||
|
||||
$this->mount((int) $this->rubrica->id);
|
||||
|
|
@ -547,6 +591,53 @@ protected function getHeaderActions(): array
|
|||
$this->startInlineEdit();
|
||||
}),
|
||||
|
||||
Action::make('email_multiple')
|
||||
->label('Email aggiuntive')
|
||||
->icon('heroicon-o-envelope')
|
||||
->modalWidth('4xl')
|
||||
->form([
|
||||
Repeater::make('emails')
|
||||
->label('Email aggiuntive')
|
||||
->defaultItems(0)
|
||||
->reorderable(true)
|
||||
->schema([
|
||||
Hidden::make('id'),
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpan(6),
|
||||
Select::make('tipo_email')
|
||||
->label('Tipo')
|
||||
->native(false)
|
||||
->options([
|
||||
'secondaria' => 'Secondaria',
|
||||
'lavoro' => 'Lavoro',
|
||||
'personale' => 'Personale',
|
||||
'pec' => 'PEC',
|
||||
'altro' => 'Altro',
|
||||
])
|
||||
->default('secondaria')
|
||||
->columnSpan(4),
|
||||
Toggle::make('attiva')
|
||||
->label('Attiva')
|
||||
->default(true)
|
||||
->columnSpan(2),
|
||||
])
|
||||
->columns(12),
|
||||
])
|
||||
->fillForm(fn(): array=> ['emails' => $this->getAdditionalEmailsFormRows()])
|
||||
->action(function (array $data): void {
|
||||
$this->saveAdditionalEmails($data['emails'] ?? []);
|
||||
$this->mount((int) $this->rubrica->id);
|
||||
|
||||
Notification::make()
|
||||
->title('Email aggiuntive aggiornate')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('contatti_avanzati')
|
||||
->label('Contatti avanzati')
|
||||
->icon('heroicon-o-rectangle-stack')
|
||||
|
|
@ -906,6 +997,9 @@ protected function getHeaderActions(): array
|
|||
|
||||
private function fillInlineForm(): void
|
||||
{
|
||||
$legacyIdentity = $this->extractLegacyIdentityFromNote($this->rubrica->note ?? null);
|
||||
$resolvedAddress = $this->parseAddressComponents($this->rubrica->indirizzo_completo ?? null);
|
||||
|
||||
$this->inlineForm = [
|
||||
'nome' => (string) ($this->rubrica->nome ?? ''),
|
||||
'cognome' => (string) ($this->rubrica->cognome ?? ''),
|
||||
|
|
@ -914,16 +1008,26 @@ private function fillInlineForm(): void
|
|||
'categoria' => (string) ($this->rubrica->categoria ?? ''),
|
||||
'codice_fiscale' => (string) ($this->rubrica->codice_fiscale ?? ''),
|
||||
'partita_iva' => (string) ($this->rubrica->partita_iva ?? ''),
|
||||
'sesso' => (string) ($this->rubrica->sesso ?? $legacyIdentity['sesso'] ?? ''),
|
||||
'data_nascita' => (string) (($this->rubrica->data_nascita?->format('Y-m-d')) ?? $legacyIdentity['data_nascita'] ?? ''),
|
||||
'luogo_nascita' => (string) ($this->rubrica->luogo_nascita ?? $legacyIdentity['luogo_nascita'] ?? ''),
|
||||
'provincia_nascita' => (string) ($this->rubrica->provincia_nascita ?? $legacyIdentity['provincia_nascita'] ?? ''),
|
||||
'email' => (string) ($this->rubrica->email ?? ''),
|
||||
'pec' => (string) ($this->rubrica->pec ?? ''),
|
||||
'telefono_ufficio' => (string) ($this->rubrica->telefono_ufficio ?? ''),
|
||||
'telefono_cellulare' => (string) ($this->rubrica->telefono_cellulare ?? ''),
|
||||
'telefono_casa' => (string) ($this->rubrica->telefono_casa ?? ''),
|
||||
'indirizzo' => (string) ($this->rubrica->indirizzo ?? $resolvedAddress['indirizzo'] ?? ''),
|
||||
'civico' => (string) ($this->rubrica->civico ?? $resolvedAddress['civico'] ?? ''),
|
||||
'cap' => (string) ($this->rubrica->cap ?? $resolvedAddress['cap'] ?? ''),
|
||||
'citta' => (string) ($this->rubrica->citta ?? $resolvedAddress['citta'] ?? ''),
|
||||
'provincia' => (string) ($this->rubrica->provincia ?? $resolvedAddress['provincia'] ?? ''),
|
||||
'nazione' => (string) ($this->rubrica->nazione ?? 'Italia'),
|
||||
'indirizzo_completo' => (string) ($this->rubrica->indirizzo_completo ?? ''),
|
||||
'tipo_utenza_call' => (string) ($this->rubrica->tipo_utenza_call ?? ''),
|
||||
'riferimento_stabile' => (string) ($this->rubrica->riferimento_stabile ?? ''),
|
||||
'riferimento_unita' => (string) ($this->rubrica->riferimento_unita ?? ''),
|
||||
'note' => (string) ($this->rubrica->note ?? ''),
|
||||
'note' => (string) ($legacyIdentity['note_clean'] ?? $this->rubrica->note ?? ''),
|
||||
'note_segreteria' => (string) ($this->rubrica->note_segreteria ?? ''),
|
||||
];
|
||||
}
|
||||
|
|
@ -939,6 +1043,395 @@ private function cleanNullable(mixed $value): mixed
|
|||
return $trim === '' ? null : $trim;
|
||||
}
|
||||
|
||||
/** @return array<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
|
||||
{
|
||||
if (! $unitaId) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
|
@ -51,6 +52,12 @@ class Modifiche extends Page
|
|||
|
||||
public ?string $lastConnectivityCheckOutput = null;
|
||||
|
||||
/** @var array<string,mixed>|null */
|
||||
public ?array $lastManifestPreview = null;
|
||||
|
||||
/** @var array<string,mixed>|null */
|
||||
public ?array $latestBackupSummary = null;
|
||||
|
||||
public ?string $lastPlannedUpdateSummary = null;
|
||||
|
||||
public bool $updateInProgress = false;
|
||||
|
|
@ -135,6 +142,7 @@ public function reloadDashboardData(): void
|
|||
$this->loadRuntimeErrors();
|
||||
$this->loadRecentFilamentPages();
|
||||
$this->loadFunctionalHighlights();
|
||||
$this->loadLatestBackupSummary();
|
||||
|
||||
if ($this->selectedCommitHash === null && isset($this->latestCommits[0]['hash'])) {
|
||||
$this->selectedCommitHash = (string) $this->latestCommits[0]['hash'];
|
||||
|
|
@ -279,7 +287,7 @@ public function getUpdatePlannedStepsProperty(): array
|
|||
$steps = [
|
||||
'Verifica canale update: ' . $this->updateChannel,
|
||||
'Backup pre-update automatico (snapshot differenziale + indice record)',
|
||||
'Upload backup su Google Drive se account amministratore disponibile',
|
||||
'Upload backup su Google Drive obbligatorio prima dell update',
|
||||
'Esecuzione in modalita ' . ($this->updateDryRun ? 'dry-run (nessuna scrittura)' : 'apply (aggiornamento reale)'),
|
||||
'Flag force: ' . ($this->updateForce ? 'abilitato' : 'disabilitato'),
|
||||
'Check endpoint update consigliato prima del lancio',
|
||||
|
|
@ -320,6 +328,7 @@ public function runUpdateConnectivityCheck(): void
|
|||
$out[] = trim($headResult->output() !== '' ? $headResult->output() : $headResult->errorOutput());
|
||||
|
||||
$this->lastConnectivityCheckOutput = trim(implode(PHP_EOL, $out));
|
||||
$this->lastManifestPreview = null;
|
||||
|
||||
$combined = Str::lower($this->lastConnectivityCheckOutput);
|
||||
if (str_contains($combined, 'timed out') || str_contains($combined, 'curl: (28)')) {
|
||||
|
|
@ -332,6 +341,29 @@ public function runUpdateConnectivityCheck(): void
|
|||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$manifestUrl = rtrim((string) config('distribution.update_url', 'https://updates.netgescon.it'), '/') . '/api/v1/distribution/updates/manifest';
|
||||
$response = Http::timeout((int) config('distribution.http_timeout_seconds', 60))
|
||||
->withOptions($this->buildDistributionHttpOptions())
|
||||
->acceptJson()
|
||||
->get($manifestUrl, ['channel' => $this->updateChannel]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$manifest = $response->json();
|
||||
if (is_array($manifest)) {
|
||||
$this->lastManifestPreview = [
|
||||
'version' => (string) ($manifest['version'] ?? '-'),
|
||||
'package_url' => (string) ($manifest['package_url'] ?? '-'),
|
||||
'sha256' => (string) ($manifest['sha256'] ?? '-'),
|
||||
'migrate_required' => (bool) ($manifest['migrate_required'] ?? false),
|
||||
'release_notes' => (string) ($manifest['release_notes'] ?? $manifest['notes'] ?? ''),
|
||||
'security' => $manifest['security'] ?? $manifest['security_updates'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Check connettivita completato')
|
||||
->success()
|
||||
|
|
@ -412,16 +444,32 @@ private function startUpdateJob(bool $fallback): void
|
|||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$adminId = $this->resolveAmministratoreId();
|
||||
if ($adminId <= 0) {
|
||||
$this->updateInProgress = false;
|
||||
$this->updateProgressStatus = 'failed';
|
||||
$this->updateProgressPercent = 100;
|
||||
$this->updateProgressMessage = 'Amministratore Google non risolto';
|
||||
$this->lastUpdateExitCode = 1;
|
||||
$this->lastUpdateOutput = 'Backup Google Drive obbligatorio: impossibile risolvere amministratore attivo per il sito corrente.';
|
||||
$this->lastUpdateIssue = 'Aggiornamento bloccato: serve un amministratore attivo con integrazione Google Drive per creare la seconda copia ripristinabile.';
|
||||
@file_put_contents($progressPath, json_encode([
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'percent' => 100,
|
||||
'message' => 'Amministratore Google non risolto',
|
||||
'status' => 'failed',
|
||||
'exit_code' => 1,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
return;
|
||||
}
|
||||
|
||||
$backupParams = [
|
||||
'--differential' => true,
|
||||
'--drive' => true,
|
||||
'--require-drive' => true,
|
||||
'--admin-id' => (string) $adminId,
|
||||
'--tag' => 'update-' . $jobId,
|
||||
];
|
||||
|
||||
if ($adminId > 0) {
|
||||
$backupParams['--admin-id'] = (string) $adminId;
|
||||
$backupParams['--drive'] = true;
|
||||
}
|
||||
|
||||
try {
|
||||
$backupExit = Artisan::call('netgescon:preupdate-backup', $backupParams);
|
||||
$backupOut = trim((string) Artisan::output());
|
||||
|
|
@ -440,8 +488,10 @@ private function startUpdateJob(bool $fallback): void
|
|||
'status' => 'failed',
|
||||
'exit_code' => 1,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
$this->loadLatestBackupSummary();
|
||||
return;
|
||||
}
|
||||
$this->loadLatestBackupSummary();
|
||||
} catch (Throwable $e) {
|
||||
$this->updateInProgress = false;
|
||||
$this->updateProgressStatus = 'failed';
|
||||
|
|
@ -531,6 +581,55 @@ private function registerPlannedUpdateSummary(): void
|
|||
$this->appendSupportEvent('update_plan', 'Piano aggiornamento registrato', $summary);
|
||||
}
|
||||
|
||||
private function loadLatestBackupSummary(): void
|
||||
{
|
||||
$this->latestBackupSummary = null;
|
||||
|
||||
$path = storage_path('app/update-backups/latest-manifest.json');
|
||||
if (! is_file($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) @file_get_contents($path), true);
|
||||
if (! is_array($decoded)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$zipPath = (string) ($decoded['zip_path'] ?? '');
|
||||
$drive = is_array($decoded['drive'] ?? null) ? $decoded['drive'] : null;
|
||||
|
||||
$this->latestBackupSummary = [
|
||||
'snapshot_id' => (string) ($decoded['snapshot_id'] ?? '-'),
|
||||
'created_at' => (string) ($decoded['created_at'] ?? '-'),
|
||||
'zip_path' => $zipPath,
|
||||
'zip_exists' => $zipPath !== '' && is_file($zipPath),
|
||||
'zip_size_mb' => $zipPath !== '' && is_file($zipPath) ? round(((float) filesize($zipPath)) / 1048576, 2) : null,
|
||||
'drive' => $drive,
|
||||
'copied_files' => (int) ($decoded['copied_files'] ?? 0),
|
||||
'tables_count' => is_array($decoded['tables'] ?? null) ? count((array) $decoded['tables']) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<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
|
||||
{
|
||||
$this->recentFilamentPages = [];
|
||||
|
|
|
|||
|
|
@ -251,11 +251,10 @@ public function openAttachmentPreview(int $attachmentId): void
|
|||
}
|
||||
|
||||
$name = (string) ($attachment->original_file_name ?? 'allegato');
|
||||
$mime = strtolower((string) ($attachment->mime_type ?? ''));
|
||||
$ext = strtolower((string) pathinfo($name, PATHINFO_EXTENSION));
|
||||
$mime = $attachment->resolvedMimeType();
|
||||
|
||||
$isImage = str_starts_with($mime, 'image/') || in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'], true);
|
||||
$isPdf = $mime === 'application/pdf' || $ext === 'pdf';
|
||||
$isImage = $attachment->isImage();
|
||||
$isPdf = $attachment->isPdf();
|
||||
|
||||
$this->attachmentPreview = [
|
||||
'id' => (int) $attachment->id,
|
||||
|
|
@ -264,6 +263,7 @@ public function openAttachmentPreview(int $attachmentId): void
|
|||
'url' => $this->getAttachmentPublicUrl($attachment),
|
||||
'is_image' => $isImage,
|
||||
'is_pdf' => $isPdf,
|
||||
'mime' => $mime,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -411,6 +411,11 @@ public function caricaAllegati(): void
|
|||
}
|
||||
|
||||
$path = $file->store('ticket-gestione/' . $ticket->id, 'public');
|
||||
$mime = TicketAttachment::normalizeMimeType(
|
||||
method_exists($file, 'getClientMimeType') ? (string) $file->getClientMimeType() : '',
|
||||
method_exists($file, 'getClientOriginalName') ? (string) $file->getClientOriginalName() : '',
|
||||
$path
|
||||
);
|
||||
|
||||
TicketAttachment::query()->create([
|
||||
'ticket_id' => $ticket->id,
|
||||
|
|
@ -418,7 +423,7 @@ public function caricaAllegati(): void
|
|||
'user_id' => Auth::id(),
|
||||
'file_path' => $path,
|
||||
'original_file_name' => (string) $file->getClientOriginalName(),
|
||||
'mime_type' => (string) $file->getClientMimeType(),
|
||||
'mime_type' => $mime,
|
||||
'size' => (int) $file->getSize(),
|
||||
'description' => $this->descrizioneAllegati ?: 'Allegato da gestione ticket',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
use App\Models\TicketAttachment;
|
||||
use App\Models\User;
|
||||
use App\Support\StabileContext;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class TicketAttachmentViewController extends Controller
|
||||
{
|
||||
public function show(TicketAttachment $attachment): Response
|
||||
public function show(TicketAttachment $attachment): BinaryFileResponse
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (! $user instanceof User) {
|
||||
|
|
@ -39,7 +39,7 @@ public function show(TicketAttachment $attachment): Response
|
|||
}
|
||||
|
||||
$absolutePath = $disk->path($path);
|
||||
$mime = (string) ($attachment->mime_type ?: $disk->mimeType($path) ?: 'application/octet-stream');
|
||||
$mime = $attachment->resolvedMimeType();
|
||||
|
||||
return response()->file($absolutePath, [
|
||||
'Content-Type' => $mime,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class RubricaUniversale extends Model
|
||||
|
|
@ -18,6 +19,10 @@ class RubricaUniversale extends Model
|
|||
'tipo_contatto', // 'persona_fisica', 'persona_giuridica'
|
||||
'codice_fiscale',
|
||||
'partita_iva',
|
||||
'sesso',
|
||||
'data_nascita',
|
||||
'luogo_nascita',
|
||||
'provincia_nascita',
|
||||
'indirizzo',
|
||||
'civico',
|
||||
'cap',
|
||||
|
|
@ -50,6 +55,7 @@ class RubricaUniversale extends Model
|
|||
protected $casts = [
|
||||
'data_inserimento' => 'date',
|
||||
'data_ultima_modifica' => 'date',
|
||||
'data_nascita' => 'date',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
|
|
@ -79,7 +85,7 @@ public function datiBancari()
|
|||
return $this->hasMany(DatiBancari::class, 'contatto_id');
|
||||
}
|
||||
|
||||
public function titolo()
|
||||
public function titolo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Titolo::class, 'titolo_id');
|
||||
}
|
||||
|
|
|
|||
65
app/Models/Scadenza.php
Normal file
65
app/Models/Scadenza.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,13 @@ class StabileServizioLettura extends Model
|
|||
'tipologia_lettura',
|
||||
'canale_acquisizione',
|
||||
'riferimento_acquisizione',
|
||||
'workflow_stato',
|
||||
'protocollo_categoria',
|
||||
'protocollo_numero',
|
||||
'richiesta_lettura_inviata_at',
|
||||
'prossima_lettura_scadenza_at',
|
||||
'archivio_documentale_path',
|
||||
'archivio_documentale_sha256',
|
||||
'lettura_inizio',
|
||||
'lettura_fine',
|
||||
'consumo_valore',
|
||||
|
|
@ -32,6 +39,8 @@ class StabileServizioLettura extends Model
|
|||
protected $casts = [
|
||||
'periodo_dal' => 'date',
|
||||
'periodo_al' => 'date',
|
||||
'richiesta_lettura_inviata_at' => 'datetime',
|
||||
'prossima_lettura_scadenza_at' => 'datetime',
|
||||
'lettura_inizio' => 'decimal:3',
|
||||
'lettura_fine' => 'decimal:3',
|
||||
'consumo_valore' => 'decimal:3',
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TicketAttachment extends Model
|
||||
{
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'ticket_attachments';
|
||||
|
|
@ -29,4 +27,66 @@ public function user(): BelongsTo
|
|||
{
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
123
docs/assicurazioni-roadmap.md
Normal file
123
docs/assicurazioni-roadmap.md
Normal 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
106
info_per_scambio.md
Normal 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.
|
||||
|
|
@ -35,6 +35,27 @@
|
|||
<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" />
|
||||
</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">
|
||||
<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" />
|
||||
|
|
@ -57,8 +78,28 @@
|
|||
<input type="text" wire:model.defer="inlineForm.telefono_casa" class="w-full rounded-md border-gray-300 text-sm" />
|
||||
</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">
|
||||
<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" />
|
||||
</label>
|
||||
|
||||
|
|
@ -203,11 +244,50 @@
|
|||
<div class="font-mono">{{ $rubrica->partita_iva ?? '—' }}</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 class="text-sm text-gray-500">Email</div>
|
||||
<div>{{ $rubrica->email ?? '—' }}</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 class="text-sm text-gray-500">PEC</div>
|
||||
<div>{{ $rubrica->pec ?? '—' }}</div>
|
||||
|
|
@ -246,6 +326,23 @@
|
|||
<div class="md:col-span-2">
|
||||
<div class="text-sm text-gray-500">Indirizzo</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 class="md:col-span-2">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
</x-filament-panels::page>
|
||||
@else
|
||||
<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="mb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
|
|
@ -23,7 +23,6 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t
|
|||
<button
|
||||
type="button"
|
||||
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' }}"
|
||||
>
|
||||
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 }}
|
||||
<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"
|
||||
title="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 class="border px-2 py-2">
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<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="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>
|
||||
</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="mb-2 text-xs font-semibold text-gray-700">Anagrafica fornitore</div>
|
||||
<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">
|
||||
</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>
|
||||
@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">Contatti</th>
|
||||
<th class="border px-2 py-2 text-left">Stato</th>
|
||||
<th class="border px-2 py-2 text-left">Azione</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($this->dipendentiRows as $d)
|
||||
<tr>
|
||||
<td class="border px-2 py-2">
|
||||
<div class="font-medium">{{ trim(($d->nome ?? '') . ' ' . ($d->cognome ?? '')) ?: '-' }}</div>
|
||||
<div class="text-[11px] text-gray-500">#{{ (int) $d->id }}</div>
|
||||
<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 class="border px-2 py-2">
|
||||
<div>{{ $d->email ?: '-' }}</div>
|
||||
<div class="text-gray-500">{{ $d->telefono ?: '-' }}</div>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<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 class="border px-2 py-2">
|
||||
@if((bool) ($d->attivo ?? false))
|
||||
<span class="inline-flex items-center rounded-md bg-emerald-100 px-2 py-1 text-[11px] font-medium text-emerald-800">Attivo</span>
|
||||
@else
|
||||
<span class="inline-flex items-center rounded-md bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-700">Disattivo</span>
|
||||
@endif
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" wire:model.defer="dipendenteInline.{{ (int) $d->id }}.attivo">
|
||||
<span>{{ (bool) ($this->dipendenteInline[(int) $d->id]['attivo'] ?? false) ? 'Attivo' : 'Disattivo' }}</span>
|
||||
</label>
|
||||
</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>
|
||||
</tr>
|
||||
@empty
|
||||
<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>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
|
@ -466,6 +477,20 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5
|
|||
</div>
|
||||
|
||||
<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-3">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -150,6 +150,57 @@ class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $t
|
|||
</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="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">
|
||||
|
|
|
|||
|
|
@ -276,12 +276,12 @@
|
|||
@forelse($ticket->attachments as $a)
|
||||
<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">
|
||||
@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" />
|
||||
@else
|
||||
<div class="flex h-full items-center justify-center text-center text-gray-500">
|
||||
<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>
|
||||
</div>
|
||||
|
|
@ -342,7 +342,7 @@
|
|||
|
||||
@if($attachmentPreview)
|
||||
<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>
|
||||
<div class="text-sm font-semibold">{{ $attachmentPreview['name'] ?? 'Allegato' }}</div>
|
||||
|
|
@ -350,14 +350,17 @@
|
|||
<div class="text-xs text-gray-500">{{ $attachmentPreview['description'] }}</div>
|
||||
@endif
|
||||
</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>
|
||||
</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)
|
||||
<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)
|
||||
<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
|
||||
<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:
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@
|
|||
<div class="rounded-md border p-2 text-xs">
|
||||
<div class="font-medium">{{ $a->original_file_name }}</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>
|
||||
@empty
|
||||
<div class="text-xs text-gray-500">Nessun allegato.</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user