Compare commits

...

2 Commits

13 changed files with 1280 additions and 129 deletions

View File

@ -1,6 +1,7 @@
<?php
namespace App\Console\Commands;
use App\Models\RubricaContattoCanale;
use App\Models\RubricaRuolo;
use App\Models\RubricaUniversale;
use App\Models\Stabile;
@ -373,6 +374,21 @@ public function handle(): int
$fiscal = $this->normalizeFiscalIds(! empty($t['cf']) ? (string) $t['cf'] : null);
$allEmails = $this->collectNormalizedEmails([
$t['email'] ?? null,
$row->email ?? null,
$row->e_mail_condomino ?? null,
$keyRow->email ?? null,
$keyRow->e_mail_condomino ?? null,
]);
$allPecs = $this->collectNormalizedEmails([
$t['pec'] ?? null,
$row->pec ?? null,
$row->pec_condominio ?? null,
$keyRow->pec ?? null,
$keyRow->pec_condominio ?? null,
]);
$data = [
'amministratore_id' => $amministratoreId,
'codice_univoco' => $rubricaCode,
@ -382,8 +398,8 @@ public function handle(): int
'tipo_contatto' => $ragione ? 'persona_giuridica' : 'persona_fisica',
'codice_fiscale' => $fiscal['cf'] ? mb_substr($fiscal['cf'], 0, 20) : null,
'partita_iva' => $fiscal['piva'] ? mb_substr($fiscal['piva'], 0, 20) : null,
'email' => ($emailNorm = $this->normalizeEmail(! empty($t['email']) ? (string) $t['email'] : null)) ? mb_substr($emailNorm, 0, 191) : null,
'pec' => ($pecNorm = $this->normalizeEmail(! empty($t['pec']) ? (string) $t['pec'] : null)) ? mb_substr($pecNorm, 0, 191) : null,
'email' => ($allEmails[0] ?? null) ? mb_substr((string) $allEmails[0], 0, 191) : null,
'pec' => ($allPecs[0] ?? null) ? mb_substr((string) $allPecs[0], 0, 191) : null,
'telefono_ufficio' => ($telNorm = $this->normalizePhone(! empty($t['telefono']) ? (string) $t['telefono'] : null)) ? mb_substr($telNorm, 0, 50) : null,
'telefono_cellulare' => ($cellNorm = $this->normalizePhone(! empty($t['cellulare']) ? (string) $t['cellulare'] : null)) ? mb_substr($cellNorm, 0, 50) : null,
'indirizzo' => $this->firstNonEmptyStr([
@ -418,24 +434,16 @@ public function handle(): int
continue;
}
$record = $this->findExistingRubrica($amministratoreId, $data) ?: RubricaUniversale::withTrashed()->where($match)->first();
$existingMatch = $this->findExistingRubricaMatch($amministratoreId, $data);
$record = $existingMatch['record'] ?: RubricaUniversale::withTrashed()->where($match)->first();
if ($record) {
if (method_exists($record, 'trashed') && $record->trashed()) {
$record->restore();
}
// Se ho trovato per identità (CF/PIVA/email/telefono), NON cambiare il codice_univoco esistente.
if (! empty($record->codice_univoco) && (string) $record->codice_univoco !== (string) $rubricaCode) {
unset($data['codice_univoco']);
}
// Non sovrascrivere note manuali: aggiungi solo se vuoto.
if (empty($record->note)) {
$data['note'] = $note;
}
$record->fill($data);
if ($record->isDirty()) {
$record->save();
}
$this->mergeImportDataIntoRubrica(
$record,
$data,
$rubricaCode,
$note,
(string) ($existingMatch['match_type'] ?? 'none'),
);
$updated++;
} else {
$data['note'] = $note;
@ -444,6 +452,16 @@ public function handle(): int
$record = RubricaUniversale::withTrashed()->where($match)->first();
}
if ($record) {
$this->syncRubricaEmailChannels(
$record,
(int) ($domain['id'] ?? 0),
$this->resolveUnitaImmobiliareId((int) ($domain['id'] ?? 0), $scala, $interno, $piano),
$allEmails,
$allPecs,
);
}
if ($linkRuolo && $record) {
$stabileId = (int) ($domain['id'] ?? 0);
if ($stabileId > 0) {
@ -590,18 +608,16 @@ public function handle(): int
$exists = RubricaUniversale::withTrashed()->where($match)->exists();
$exists ? $updated++ : $inserted++;
} else {
$record = $this->findExistingRubrica($amministratoreId, $data) ?: RubricaUniversale::withTrashed()->where($match)->first();
$existingMatch = $this->findExistingRubricaMatch($amministratoreId, $data);
$record = $existingMatch['record'] ?: RubricaUniversale::withTrashed()->where($match)->first();
if ($record) {
if (method_exists($record, 'trashed') && $record->trashed()) {
$record->restore();
}
if (! empty($record->codice_univoco) && (string) $record->codice_univoco !== (string) $rubricaCode) {
unset($data['codice_univoco']);
}
$record->fill($data);
if ($record->isDirty()) {
$record->save();
}
$this->mergeImportDataIntoRubrica(
$record,
$data,
$rubricaCode,
'Auto-sync comproprietari (staging)',
(string) ($existingMatch['match_type'] ?? 'none'),
);
$updated++;
} else {
$data['note'] = 'Auto-sync comproprietari (staging)';
@ -942,7 +958,10 @@ private function lookupTenantIdentityFromStaging(string $conn, string $codStabil
}
}
private function findExistingRubrica(int $amministratoreId, array $data): ?RubricaUniversale
/**
* @return array{record:?RubricaUniversale,match_type:string}
*/
private function findExistingRubricaMatch(int $amministratoreId, array $data): array
{
$ids = $this->normalizeFiscalIds($data['codice_fiscale'] ?? null);
$cf = $ids['cf'];
@ -968,7 +987,7 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri
->orderBy('id')
->first();
if ($found) {
return $found;
return ['record' => $found, 'match_type' => 'fiscal'];
}
$orphan = RubricaUniversale::query()
@ -990,7 +1009,7 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri
->orderBy('id')
->first();
if ($orphan) {
return $orphan;
return ['record' => $orphan, 'match_type' => 'fiscal'];
}
}
@ -1001,7 +1020,7 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri
->where('email', $email)
->first();
if ($found) {
return $found;
return ['record' => $found, 'match_type' => 'email'];
}
$orphan = RubricaUniversale::query()
@ -1010,7 +1029,7 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri
->orderBy('id')
->first();
if ($orphan) {
return $orphan;
return ['record' => $orphan, 'match_type' => 'email'];
}
}
@ -1029,7 +1048,7 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri
})
->first();
if ($found) {
return $found;
return ['record' => $found, 'match_type' => 'phone'];
}
$orphan = RubricaUniversale::query()
@ -1044,11 +1063,86 @@ private function findExistingRubrica(int $amministratoreId, array $data): ?Rubri
->orderBy('id')
->first();
if ($orphan) {
return $orphan;
return ['record' => $orphan, 'match_type' => 'phone'];
}
}
return null;
return ['record' => null, 'match_type' => 'none'];
}
private function mergeImportDataIntoRubrica(
RubricaUniversale $record,
array $data,
string $rubricaCode,
string $note,
string $matchType,
): void {
if (method_exists($record, 'trashed') && $record->trashed()) {
$record->restore();
}
if (! empty($record->codice_univoco) && (string) $record->codice_univoco !== $rubricaCode) {
unset($data['codice_univoco']);
}
$data = $this->mergeRubricaDataPreservingExisting($record, $data);
if (empty($record->note)) {
$data['note'] = $note;
} else {
unset($data['note']);
}
$record->fill($data);
if ($record->isDirty()) {
$record->save();
}
}
private function mergeRubricaDataPreservingExisting(RubricaUniversale $record, array $incoming): array
{
$preserveIfFilled = [
'ragione_sociale',
'nome',
'cognome',
'tipo_contatto',
'codice_fiscale',
'partita_iva',
'email',
'pec',
'telefono_ufficio',
'telefono_cellulare',
'telefono_casa',
'indirizzo',
'cap',
'citta',
'provincia',
];
foreach ($preserveIfFilled as $field) {
if ($this->isFilledScalar($record->{$field} ?? null)) {
$incoming[$field] = $record->{$field};
}
}
if ($this->isFilledScalar($record->amministratore_id ?? null)) {
$incoming['amministratore_id'] = (int) $record->amministratore_id;
}
return $incoming;
}
private function isFilledScalar(mixed $value): bool
{
if ($value === null) {
return false;
}
if (is_string($value)) {
return trim($value) !== '';
}
return true;
}
private function normalizeDateToYmd(mixed $v): ?string
@ -1361,4 +1455,76 @@ private function normalizePercentValue(mixed $raw): ?float
return (float) $raw;
}
/** @return array<int,string> */
private function collectNormalizedEmails(array $values): array
{
$result = [];
foreach ($values as $value) {
if ($value === null) {
continue;
}
$parts = preg_split('/[;,\n\r]+/', (string) $value) ?: [];
foreach ($parts as $part) {
$email = $this->normalizeEmail($part);
if ($email === null || in_array($email, $result, true)) {
continue;
}
$result[] = $email;
}
}
return $result;
}
/** @param array<int,string> $emails */
private function syncRubricaEmailChannels(RubricaUniversale $record, int $stabileId, ?int $unitaId, array $emails, array $pecs): void
{
if (! Schema::hasTable('rubrica_contatti_canali')) {
return;
}
foreach ($emails as $index => $email) {
RubricaContattoCanale::query()->updateOrCreate(
[
'rubrica_id' => (int) $record->id,
'tipo' => 'email',
'valore' => $email,
'stabile_id' => $stabileId > 0 ? $stabileId : null,
'unita_immobiliare_id' => $unitaId,
],
[
'etichetta' => $index === 0 ? 'Email primaria legacy' : 'Email aggiuntiva legacy',
'is_principale' => $index === 0,
'meta' => [
'source' => 'gescon_import.condomin',
'scope' => 'auto_sync_anagrafiche',
],
],
);
}
foreach ($pecs as $index => $pec) {
RubricaContattoCanale::query()->updateOrCreate(
[
'rubrica_id' => (int) $record->id,
'tipo' => 'pec',
'valore' => $pec,
'stabile_id' => $stabileId > 0 ? $stabileId : null,
'unita_immobiliare_id' => $unitaId,
],
[
'etichetta' => $index === 0 ? 'PEC primaria legacy' : 'PEC aggiuntiva legacy',
'is_principale' => $index === 0,
'meta' => [
'source' => 'gescon_import.condomin',
'scope' => 'auto_sync_anagrafiche',
],
],
);
}
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use App\Models\Stabile;
use App\Services\Support\GmailTicketImportService;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
class GmailImportStabileTicketsCommand extends Command
{
protected $signature = 'gmail:import-stabile-tickets
{--stabile-id= : ID dello stabile da elaborare}
{--stabile= : Codice stabile alternativo}
{--casella= : Etichetta o email della casella da importare}
{--max=10 : Numero massimo di messaggi da importare per casella}';
protected $description = 'Importa email Gmail/PEC configurate su uno stabile e crea ticket con EML e allegati.';
public function handle(GmailTicketImportService $service): int
{
$stabile = $this->resolveStabile();
if (! $stabile instanceof Stabile) {
$this->error('Stabile non trovato. Usa --stabile-id oppure --stabile.');
return self::FAILURE;
}
$posta = (array) Arr::get($stabile->configurazione_avanzata ?? [], 'posta', []);
$caselle = array_values(array_filter((array) Arr::get($posta, 'caselle', []), function ($mailbox): bool {
return is_array($mailbox)
&& (bool) ($mailbox['enabled'] ?? false)
&& strtolower(trim((string) ($mailbox['tipo'] ?? ''))) === 'gmail';
}));
$filter = trim((string) $this->option('casella'));
if ($filter !== '') {
$caselle = array_values(array_filter($caselle, static function (array $mailbox) use ($filter): bool {
return strcasecmp((string) ($mailbox['label'] ?? ''), $filter) === 0
|| strcasecmp((string) ($mailbox['email'] ?? ''), $filter) === 0;
}));
}
if ($caselle === []) {
$this->warn('Nessuna casella Gmail attiva trovata per questo stabile.');
return self::SUCCESS;
}
$max = max(1, min((int) $this->option('max'), 50));
foreach ($caselle as $mailbox) {
$this->line('Casella: ' . (string) ($mailbox['label'] ?? $mailbox['email'] ?? 'gmail'));
try {
$result = $service->importMailbox($stabile, $mailbox, $max);
$this->info('Importati ' . $result['imported'] . ' messaggi, saltati ' . $result['skipped'] . ', allegati salvati ' . $result['attachments'] . '.');
} catch (\Throwable $e) {
$this->error($e->getMessage());
}
}
return self::SUCCESS;
}
private function resolveStabile(): ?Stabile
{
$stabileId = (int) $this->option('stabile-id');
if ($stabileId > 0) {
return Stabile::query()->find($stabileId);
}
$codice = trim((string) $this->option('stabile'));
if ($codice !== '') {
return Stabile::query()
->where('codice_stabile', $codice)
->orWhere('codice_interno', $codice)
->first();
}
return null;
}
}

View File

@ -1,7 +1,9 @@
<?php
namespace App\Filament\Pages\Condomini;
use App\Models\Amministratore;
use App\Models\Stabile;
use App\Support\GoogleAccountStore;
use App\Models\User;
use App\Support\StabileContext;
use BackedEnum;
@ -39,6 +41,9 @@ class StabilePosta extends Page implements HasForms
public ?Stabile $stabile = null;
/** @var array<string, string> */
public array $googleAccountOptions = [];
public static function canAccess(): bool
{
$user = Auth::user();
@ -61,6 +66,9 @@ public function mount(): void
abort_unless($stabile instanceof Stabile, 404);
$this->stabile = $stabile;
$this->googleAccountOptions = $stabile->amministratore instanceof Amministratore
? app(GoogleAccountStore::class)->options($stabile->amministratore)
: [];
$posta = (array) Arr::get($stabile->configurazione_avanzata ?? [], 'posta', []);
@ -120,6 +128,11 @@ public function form(Schema $schema): Schema
->label('Etichetta')
->required()
->maxLength(120),
Select::make('google_account_key')
->label('Account Google collegato')
->options(fn (): array => $this->googleAccountOptions)
->visible(fn (callable $get): bool => strtolower((string) $get('tipo')) === 'gmail')
->helperText('Per Gmail seleziona laccount Google autorizzato che leggera questa casella.'),
TextInput::make('email')
->label('Email casella')
->email()
@ -153,6 +166,10 @@ public function form(Schema $schema): Schema
TextInput::make('gmail_query')
->label('Filtro Gmail / query')
->maxLength(255),
Toggle::make('crea_ticket_automatico')
->label('Crea ticket automatici dai nuovi messaggi')
->default(true)
->visible(fn (callable $get): bool => strtolower((string) $get('tipo')) === 'gmail'),
TextInput::make('mittenti_autorizzati')
->label('Mittenti ammessi')
->maxLength(500)
@ -200,4 +217,36 @@ public function save(): void
->success()
->send();
}
/**
* @return array<int, array<string, mixed>>
*/
public function getGoogleAccountsSummary(): array
{
if (! $this->stabile?->amministratore instanceof Amministratore) {
return [];
}
return array_values(app(GoogleAccountStore::class)->all($this->stabile->amministratore));
}
public function getStableGoogleConnectUrl(): string
{
$label = 'PEC ' . ($this->stabile?->denominazione ?: ('stabile-' . ($this->stabile?->id ?? 0)));
return route('oauth.google.redirect', [
'account_key' => 'stabile-' . ($this->stabile?->id ?? 0) . '-pec',
'label' => $label,
'return_to' => request()->getRequestUri(),
]);
}
public function getTestGoogleConnectUrl(): string
{
return route('oauth.google.redirect', [
'account_key' => 'gmail-test',
'label' => 'Account Google test',
'return_to' => request()->getRequestUri(),
]);
}
}

View File

@ -370,7 +370,7 @@ public function openAttachmentMapPreview(int $attachmentId): void
public function closeAttachmentPreview(): void
{
$this->attachmentPreview = null;
$this->attachmentPreview = null;
$this->attachmentMapPreview = null;
}

View File

@ -6,6 +6,7 @@
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\Stabile;
use App\Support\GoogleAccountStore;
use App\Models\User;
use App\Support\StabileContext;
use Illuminate\Http\RedirectResponse;
@ -18,12 +19,7 @@
class GoogleOAuthController extends Controller
{
private const GOOGLE_SCOPES = [
'openid',
'email',
'profile',
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/contacts',
'https://www.googleapis.com/auth/drive.file',
...GoogleAccountStore::SCOPES,
];
public function redirect(): RedirectResponse
@ -35,7 +31,11 @@ public function redirect(): RedirectResponse
}
$google = Arr::get($amministratore->impostazioni ?? [], 'google', []);
$oauth = Arr::get($google, 'oauth', []);
$store = app(GoogleAccountStore::class);
$accountKey = $store->normalizeAccountKey((string) request()->query('account_key', ''));
$accountLabel = trim((string) request()->query('label', ''));
$account = $store->get($amministratore, $accountKey !== '' ? $accountKey : null);
$returnTo = trim((string) request()->query('return_to', ''));
$clientId = (string) ($google['client_id'] ?? config('services.google.client_id'));
$clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret'));
@ -47,7 +47,12 @@ public function redirect(): RedirectResponse
}
$state = bin2hex(random_bytes(20));
session()->put('google_oauth_state', $state);
session()->put('google_oauth_context', [
'state' => $state,
'account_key' => $accountKey,
'label' => $accountLabel,
'return_to' => str_starts_with($returnTo, '/') ? $returnTo : null,
]);
$queryParams = [
'client_id' => $clientId,
@ -59,10 +64,15 @@ public function redirect(): RedirectResponse
'state' => $state,
];
if (trim((string) Arr::get($oauth, 'refresh_token', '')) === '') {
$refreshToken = trim((string) ($account['refresh_token'] ?? ''));
if ($refreshToken === '') {
$queryParams['prompt'] = 'consent';
}
if (! empty($account['email'])) {
$queryParams['login_hint'] = (string) $account['email'];
}
$query = http_build_query($queryParams);
return redirect()->away('https://accounts.google.com/o/oauth2/v2/auth?' . $query);
@ -76,15 +86,16 @@ public function callback(): RedirectResponse
abort(403, 'Amministratore non disponibile per questa sessione.');
}
$google = Arr::get($amministratore->impostazioni ?? [], 'google', []);
$existingOauth = Arr::get($google, 'oauth', []);
$clientId = (string) ($google['client_id'] ?? config('services.google.client_id'));
$clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret'));
$redirectUri = (string) ($google['redirect_uri'] ?? config('services.google.redirect'));
$google = Arr::get($amministratore->impostazioni ?? [], 'google', []);
$store = app(GoogleAccountStore::class);
$clientId = (string) ($google['client_id'] ?? config('services.google.client_id'));
$clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret'));
$redirectUri = (string) ($google['redirect_uri'] ?? config('services.google.redirect'));
try {
$oauthContext = (array) session()->pull('google_oauth_context', []);
$incomingState = (string) request()->query('state', '');
$sessionState = (string) session()->pull('google_oauth_state', '');
$sessionState = (string) ($oauthContext['state'] ?? '');
if ($incomingState === '' || $sessionState === '' || ! hash_equals($sessionState, $incomingState)) {
return redirect('/admin-filament/impostazioni/scheda-amministratore')
->with('error', 'Stato OAuth non valido. Riprova il collegamento Google.');
@ -113,10 +124,7 @@ public function callback(): RedirectResponse
$payload = $tokenResponse->json();
$accessToken = (string) Arr::get($payload, 'access_token', '');
$refreshToken = (string) Arr::get($payload, 'refresh_token', '');
if ($refreshToken === '') {
$refreshToken = (string) Arr::get($existingOauth, 'refresh_token', '');
}
$expiresIn = (int) Arr::get($payload, 'expires_in', 3600);
$expiresIn = (int) Arr::get($payload, 'expires_in', 3600);
$profileResponse = Http::withToken($accessToken)
->acceptJson()
@ -127,25 +135,39 @@ public function callback(): RedirectResponse
$email = (string) Arr::get($profile, 'email', '');
$name = (string) Arr::get($profile, 'name', '');
$impostazioni = $amministratore->impostazioni ?? [];
$impostazioni['google'] = array_merge($google, [
'oauth' => [
$requestedKey = $store->normalizeAccountKey((string) ($oauthContext['account_key'] ?? ''));
$accountKey = $requestedKey !== ''
? $requestedKey
: $store->normalizeAccountKey($googleUserId !== '' ? $googleUserId : ($email !== '' ? $email : 'primary'));
$accountLabel = trim((string) ($oauthContext['label'] ?? '')) ?: ($name !== '' ? $name : $email);
$existing = $store->get($amministratore, $accountKey);
if ($refreshToken === '') {
$refreshToken = trim((string) ($existing['refresh_token'] ?? ''));
}
$store->upsertAccount(
$amministratore,
$accountKey,
[
'connected' => true,
'label' => $accountLabel,
'email' => $email,
'name' => $name,
'google_user_id' => $googleUserId,
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => $expiresIn,
'connected_at' => now()->toDateTimeString(),
'connected_at' => (string) ($existing['connected_at'] ?? now()->toDateTimeString()),
'refreshed_at' => now()->toDateTimeString(),
'scopes' => self::GOOGLE_SCOPES,
],
]);
$existing === null
);
$amministratore->impostazioni = $impostazioni;
$amministratore->save();
$redirectTo = (string) ($oauthContext['return_to'] ?? '');
return redirect('/admin-filament/impostazioni/scheda-amministratore')
return redirect($redirectTo !== '' ? $redirectTo : '/admin-filament/impostazioni/scheda-amministratore')
->with('status', 'Account Google collegato con successo.');
} catch (Throwable $e) {
Log::error('Google OAuth callback failed', [
@ -166,18 +188,14 @@ public function disconnect(): RedirectResponse
abort(403, 'Amministratore non disponibile per questa sessione.');
}
$impostazioni = $amministratore->impostazioni ?? [];
$google = Arr::get($impostazioni, 'google', []);
$google['oauth'] = [
'connected' => false,
'disconnected_at' => now()->toDateTimeString(),
];
app(GoogleAccountStore::class)->disconnectAccount(
$amministratore,
(string) request()->input('account_key', request()->query('account_key', ''))
);
$impostazioni['google'] = $google;
$amministratore->impostazioni = $impostazioni;
$amministratore->save();
$returnTo = trim((string) request()->input('return_to', request()->query('return_to', '')));
return redirect('/admin-filament/impostazioni/scheda-amministratore')
return redirect($returnTo !== '' && str_starts_with($returnTo, '/') ? $returnTo : '/admin-filament/impostazioni/scheda-amministratore')
->with('status', 'Collegamento Google rimosso.');
}

View File

@ -0,0 +1,417 @@
<?php
namespace App\Services\Support;
use App\Models\CommunicationMessage;
use App\Models\Documento;
use App\Models\Stabile;
use App\Models\Ticket;
use App\Models\TicketAttachment;
use App\Support\GoogleAccountStore;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
class GmailTicketImportService
{
public function __construct(
private readonly GoogleAccountStore $googleAccountStore,
private readonly TicketAttachmentUploadService $ticketAttachmentUploadService,
) {
}
/**
* @param array<string, mixed> $mailbox
* @return array{mailbox:string,imported:int,skipped:int,attachments:int}
*/
public function importMailbox(Stabile $stabile, array $mailbox, int $maxMessages = 10): array
{
$amministratore = $stabile->amministratore;
if (! $amministratore) {
throw new RuntimeException('Amministratore non disponibile per lo stabile #' . $stabile->id . '.');
}
$openingUserId = (int) ($amministratore->user_id ?? 0);
if ($openingUserId <= 0) {
throw new RuntimeException('Utente amministratore non disponibile per aprire i ticket dello stabile #' . $stabile->id . '.');
}
$accountKey = trim((string) ($mailbox['google_account_key'] ?? ''));
$token = $this->googleAccountStore->resolveAccessToken($amministratore, $accountKey, false);
if ($token === null || $token === '') {
throw new RuntimeException('Token Google non disponibile per la casella Gmail collegata allo stabile #' . $stabile->id . '.');
}
$query = trim((string) ($mailbox['gmail_query'] ?? ''));
$response = Http::withToken($token)
->acceptJson()
->get('https://gmail.googleapis.com/gmail/v1/users/me/messages', array_filter([
'maxResults' => max(1, min($maxMessages, 50)),
'q' => $query !== '' ? $query : null,
], static fn ($value) => $value !== null));
if (! $response->successful()) {
throw new RuntimeException('Gmail API non disponibile: ' . $response->body());
}
$imported = 0;
$skipped = 0;
$attachments = 0;
$messages = (array) Arr::get($response->json(), 'messages', []);
foreach ($messages as $row) {
$gmailId = trim((string) ($row['id'] ?? ''));
if ($gmailId === '') {
continue;
}
$message = $this->fetchMessage($token, $gmailId, 'full');
$headers = $this->extractHeaders((array) Arr::get($message, 'payload.headers', []));
$headerMessageId = trim((string) ($headers['message-id'] ?? ''));
$externalId = $headerMessageId !== '' ? $headerMessageId : $gmailId;
if ($this->alreadyImported($externalId, $gmailId)) {
$skipped++;
continue;
}
$bodyText = $this->extractBodyText((array) Arr::get($message, 'payload', []));
$subject = trim((string) ($headers['subject'] ?? ''));
$from = trim((string) ($headers['from'] ?? ''));
$to = trim((string) ($headers['to'] ?? ''));
$date = $headers['date'] ?? null;
$ticket = Ticket::query()->create([
'stabile_id' => (int) $stabile->id,
'aperto_da_user_id' => $openingUserId,
'titolo' => Str::limit($subject !== '' ? $subject : ('Email importata da ' . ($from !== '' ? $from : 'Gmail')), 255),
'descrizione' => $this->buildTicketDescription($stabile, $mailbox, $subject, $from, $to, $date, $bodyText),
'categoria_ticket_id' => null,
'luogo_intervento' => 'Casella email stabile',
'data_apertura' => $this->normalizeDate($date) ?? now(),
'stato' => 'Aperto',
'priorita' => 'Media',
]);
$rawMessage = $this->fetchMessage($token, $gmailId, 'raw');
$rawEml = $this->decodeBase64Url((string) Arr::get($rawMessage, 'raw', ''));
$documento = $this->storeRawEmailDocumento($ticket, $mailbox, $subject, $from, $to, $date, $rawEml, $gmailId, $openingUserId);
$ticket->messages()->create([
'user_id' => $openingUserId,
'messaggio' => Str::limit($bodyText !== '' ? $bodyText : ($subject !== '' ? $subject : 'Email importata da Gmail'), 4000),
'canale' => 'email',
'direzione' => 'inbound',
'oggetto' => $subject !== '' ? $subject : null,
'email_mittente' => $from !== '' ? $from : null,
'email_destinatario' => $to !== '' ? $to : null,
'external_message_id' => $externalId,
'inviato_il' => $this->normalizeDate($date),
'eml_documento_id' => $documento->id,
'metadata' => [
'gmail_message_id' => $gmailId,
'gmail_thread_id' => Arr::get($message, 'threadId'),
'gmail_mailbox_email' => $mailbox['email'] ?? null,
'google_account_key' => $mailbox['google_account_key'] ?? null,
],
]);
CommunicationMessage::query()->create([
'channel' => 'email',
'direction' => 'inbound',
'external_message_id' => $externalId,
'stabile_id' => (int) $stabile->id,
'sender_name' => $from !== '' ? $from : null,
'message_text' => Str::limit($bodyText !== '' ? $bodyText : ($subject !== '' ? $subject : 'Email importata da Gmail'), 4000),
'ticket_id' => (int) $ticket->id,
'status' => 'linked_to_ticket',
'received_at' => $this->normalizeDate($date),
'metadata' => [
'gmail_message_id' => $gmailId,
'gmail_thread_id' => Arr::get($message, 'threadId'),
'gmail_mailbox_email' => $mailbox['email'] ?? null,
'google_account_key' => $mailbox['google_account_key'] ?? null,
'subject' => $subject,
'to' => $to,
'documento_id' => (int) $documento->id,
],
]);
$attachments += $this->storeAttachments($token, $gmailId, $ticket, (array) Arr::get($message, 'payload', []), $subject, $openingUserId);
$imported++;
}
return [
'mailbox' => (string) ($mailbox['label'] ?? $mailbox['email'] ?? 'casella-gmail'),
'imported' => $imported,
'skipped' => $skipped,
'attachments' => $attachments,
];
}
/**
* @return array<string, string>
*/
private function extractHeaders(array $headers): array
{
$result = [];
foreach ($headers as $header) {
$name = strtolower(trim((string) ($header['name'] ?? '')));
if ($name === '') {
continue;
}
$result[$name] = trim((string) ($header['value'] ?? ''));
}
return $result;
}
/**
* @param array<string, mixed> $payload
*/
private function extractBodyText(array $payload): string
{
$parts = [];
$this->collectTextParts($payload, $parts);
$plain = trim(implode("\n\n", array_filter($parts)));
if ($plain !== '') {
return Str::limit($plain, 3500);
}
return trim((string) Arr::get($payload, 'body.data', '')) !== ''
? Str::limit($this->decodeBase64Url((string) Arr::get($payload, 'body.data', '')), 3500)
: '';
}
/**
* @param array<string, mixed> $part
* @param array<int, string> $collector
*/
private function collectTextParts(array $part, array &$collector): void
{
$mimeType = strtolower(trim((string) ($part['mimeType'] ?? '')));
if ($mimeType === 'text/plain') {
$text = trim($this->decodeBase64Url((string) Arr::get($part, 'body.data', '')));
if ($text !== '') {
$collector[] = $text;
}
}
foreach ((array) ($part['parts'] ?? []) as $child) {
if (is_array($child)) {
$this->collectTextParts($child, $collector);
}
}
}
/**
* @param array<string, mixed> $payload
*/
private function storeAttachments(string $token, string $gmailId, Ticket $ticket, array $payload, string $subject, int $userId): int
{
$count = 0;
foreach ($this->flattenAttachmentParts($payload) as $part) {
$fileName = trim((string) ($part['filename'] ?? ''));
if ($fileName === '') {
continue;
}
$data = trim((string) Arr::get($part, 'body.data', ''));
if ($data === '') {
$attachmentId = trim((string) Arr::get($part, 'body.attachmentId', ''));
if ($attachmentId !== '') {
$response = Http::withToken($token)
->acceptJson()
->get('https://gmail.googleapis.com/gmail/v1/users/me/messages/' . rawurlencode($gmailId) . '/attachments/' . rawurlencode($attachmentId));
if ($response->successful()) {
$data = trim((string) Arr::get($response->json(), 'data', ''));
}
}
}
if ($data === '') {
continue;
}
$binary = $this->decodeBase64Url($data);
if ($binary === '') {
continue;
}
$stored = $this->ticketAttachmentUploadService->storeRawContents(
$binary,
'ticket-email/' . $ticket->id,
$fileName,
[
'mime' => (string) ($part['mimeType'] ?? ''),
'optimize_image' => false,
'display_name' => $fileName,
'stored_basename' => pathinfo($fileName, PATHINFO_FILENAME),
]
);
TicketAttachment::query()->create([
'ticket_id' => (int) $ticket->id,
'ticket_update_id' => null,
'user_id' => $userId,
'file_path' => $stored['path'],
'original_file_name' => $stored['original_name'],
'mime_type' => $stored['mime'],
'size' => $stored['size'],
'description' => Str::limit('Allegato email importata' . ($subject !== '' ? ': ' . $subject : ''), 255),
]);
$count++;
}
return $count;
}
/**
* @param array<string, mixed> $payload
* @return array<int, array<string, mixed>>
*/
private function flattenAttachmentParts(array $payload): array
{
$parts = [];
foreach ((array) ($payload['parts'] ?? []) as $part) {
if (! is_array($part)) {
continue;
}
if (trim((string) ($part['filename'] ?? '')) !== '') {
$parts[] = $part;
}
$parts = array_merge($parts, $this->flattenAttachmentParts($part));
}
return $parts;
}
private function alreadyImported(string $externalId, string $gmailId): bool
{
return CommunicationMessage::query()
->whereIn('external_message_id', array_values(array_filter([$externalId, $gmailId])))
->exists();
}
/**
* @param array<string, mixed> $mailbox
*/
private function buildTicketDescription(Stabile $stabile, array $mailbox, string $subject, string $from, string $to, mixed $date, string $bodyText): string
{
$lines = [
'Email importata automaticamente dalla casella stabile.',
'Stabile: ' . ($stabile->denominazione ?: ('#' . $stabile->id)),
];
$mailboxLabel = trim((string) ($mailbox['label'] ?? ''));
$mailboxEmail = trim((string) ($mailbox['email'] ?? ''));
if ($mailboxLabel !== '' || $mailboxEmail !== '') {
$lines[] = 'Casella: ' . trim($mailboxLabel . ($mailboxEmail !== '' ? ' <' . $mailboxEmail . '>' : ''));
}
if ($subject !== '') {
$lines[] = 'Oggetto: ' . $subject;
}
if ($from !== '') {
$lines[] = 'Mittente: ' . $from;
}
if ($to !== '') {
$lines[] = 'Destinatario: ' . $to;
}
if ($date) {
$lines[] = 'Data email: ' . (string) $date;
}
if ($bodyText !== '') {
$lines[] = '';
$lines[] = 'Corpo messaggio:';
$lines[] = $bodyText;
}
return trim(implode("\n", $lines));
}
private function storeRawEmailDocumento(Ticket $ticket, array $mailbox, string $subject, string $from, string $to, mixed $date, string $rawEml, string $gmailId, int $userId): Documento
{
$fileName = 'gmail-' . $gmailId . '.eml';
$path = 'documenti/ticket-email/' . $ticket->id . '/' . $fileName;
Storage::disk('public')->put($path, $rawEml);
return Documento::query()->create([
'documentable_id' => $ticket->id,
'documentable_type' => Ticket::class,
'stabile_id' => $ticket->stabile_id,
'utente_id' => $userId,
'nome_file' => $fileName,
'path_file' => $path,
'percorso_file' => $path,
'tipo_documento' => 'Email EML',
'tipologia' => 'comunicazione',
'nome' => $subject !== '' ? $subject : ('Email Gmail ticket #' . $ticket->id),
'mime_type' => 'message/rfc822',
'estensione' => 'eml',
'dimensione_file' => strlen($rawEml),
'data_documento' => $this->normalizeDate($date),
'data_upload' => now(),
'descrizione' => 'Email importata automaticamente da ' . trim((string) ($mailbox['email'] ?? 'Gmail')),
'tags' => 'email,ticket,gmail,pec',
'note' => 'Import automatico Gmail per ticket #' . $ticket->id . ' da ' . ($from !== '' ? $from : 'mittente non disponibile'),
]);
}
/**
* @return array<string, mixed>
*/
private function fetchMessage(string $token, string $gmailId, string $format): array
{
$response = Http::withToken($token)
->acceptJson()
->get('https://gmail.googleapis.com/gmail/v1/users/me/messages/' . rawurlencode($gmailId), [
'format' => $format,
]);
if (! $response->successful()) {
throw new RuntimeException('Impossibile leggere il messaggio Gmail ' . $gmailId . '.');
}
return (array) $response->json();
}
private function decodeBase64Url(string $value): string
{
$normalized = strtr($value, '-_', '+/');
$padding = strlen($normalized) % 4;
if ($padding > 0) {
$normalized .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode($normalized, true);
return is_string($decoded) ? $decoded : '';
}
private function normalizeDate(mixed $value): mixed
{
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
try {
return Carbon::parse($raw);
} catch (\Throwable) {
return null;
}
}
}

View File

@ -76,6 +76,44 @@ public function store(object $file, string $directory, array $options = []): arr
];
}
/**
* @return array{path:string,mime:string,size:int,original_name:string,source_original_name:string,stored_name:string,metadata:array<string,mixed>}
*/
public function storeRawContents(string $contents, string $directory, string $originalName, array $options = []): array
{
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
$storedBasename = trim((string) ($options['stored_basename'] ?? ''));
$displayName = trim((string) ($options['display_name'] ?? '')) ?: $originalName;
$sanitizedBase = trim((string) Str::of(pathinfo($storedBasename !== '' ? $storedBasename : $displayName, PATHINFO_FILENAME))->slug('-'), '-');
$sanitizedBase = $sanitizedBase !== '' ? $sanitizedBase : 'file';
$storedName = $sanitizedBase . ($extension !== '' ? '.' . $extension : '');
$path = trim($directory, '/') . '/' . $storedName;
Storage::disk('public')->put($path, $contents);
$mime = TicketAttachment::normalizeMimeType((string) ($options['mime'] ?? ''), $originalName, $path);
$metadata = [];
if (str_starts_with($mime, 'image/')) {
$metadata = $this->extractImageMetadata($path);
if ($this->shouldOptimizeImage($metadata, $options)) {
$this->optimizeImage($path, $mime, $metadata);
}
$mime = TicketAttachment::normalizeMimeType($mime, $originalName, $path);
}
return [
'path' => $path,
'mime' => $mime,
'size' => (int) Storage::disk('public')->size($path),
'original_name' => $displayName,
'source_original_name' => $originalName,
'stored_name' => $storedName,
'metadata' => $metadata,
];
}
private function shouldOptimizeImage(array $metadata, array $options = []): bool
{
if (array_key_exists('optimize_image', $options)) {
@ -236,7 +274,7 @@ private function extractImageMetadataFromFileObject(object $file): array
private function extractImageMetadataFromAbsolutePath(string $absolutePath): array
{
$metadata = [];
$metadata = [];
$imageSize = @getimagesize($absolutePath);
if (is_array($imageSize)) {

View File

@ -0,0 +1,336 @@
<?php
namespace App\Support;
use App\Models\Amministratore;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class GoogleAccountStore
{
public const SCOPES = [
'openid',
'email',
'profile',
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/contacts',
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/gmail.readonly',
];
/**
* @return array<string, array<string, mixed>>
*/
public function all(Amministratore $amministratore): array
{
$google = Arr::get($amministratore->impostazioni ?? [], 'google', []);
$accounts = [];
foreach ((array) Arr::get($google, 'accounts', []) as $rawKey => $account) {
if (! is_array($account)) {
continue;
}
$key = $this->normalizeAccountKey((string) $rawKey);
if ($key === '') {
$key = $this->normalizeAccountKey(
(string) ($account['email'] ?? $account['google_user_id'] ?? $account['label'] ?? 'google-account')
);
}
$accounts[$key] = $this->normalizeAccountPayload($account, $key);
}
$legacyOauth = Arr::get($google, 'oauth', []);
if (is_array($legacyOauth) && $this->hasUsableOauthPayload($legacyOauth)) {
$legacyKey = $this->normalizeAccountKey(
(string) (Arr::get($google, 'default_account_key')
?: Arr::get($legacyOauth, 'google_user_id')
?: Arr::get($legacyOauth, 'email')
?: 'primary')
);
if (! isset($accounts[$legacyKey])) {
$accounts[$legacyKey] = $this->normalizeAccountPayload($legacyOauth, $legacyKey);
}
}
$defaultKey = $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', ''));
if ($defaultKey === '' && $accounts !== []) {
$defaultKey = (string) array_key_first($accounts);
}
foreach ($accounts as $key => $account) {
$accounts[$key]['is_default'] = $key === $defaultKey;
}
return $accounts;
}
/**
* @return array<string, string>
*/
public function options(Amministratore $amministratore): array
{
$options = [];
foreach ($this->all($amministratore) as $key => $account) {
$label = trim((string) ($account['label'] ?? ''));
$email = trim((string) ($account['email'] ?? ''));
$text = $label !== '' ? $label : $key;
if ($email !== '') {
$text .= ' (' . $email . ')';
}
if (! empty($account['is_default'])) {
$text .= ' · predefinito';
}
$options[$key] = $text;
}
return $options;
}
/**
* @return array<string, mixed>|null
*/
public function get(Amministratore $amministratore, ?string $accountKey = null): ?array
{
$accounts = $this->all($amministratore);
if ($accounts === []) {
return null;
}
$resolvedKey = $this->normalizeAccountKey((string) $accountKey);
if ($resolvedKey !== '' && isset($accounts[$resolvedKey])) {
return $accounts[$resolvedKey];
}
foreach ($accounts as $account) {
if (! empty($account['is_default'])) {
return $account;
}
}
return reset($accounts) ?: null;
}
public function normalizeAccountKey(?string $value): string
{
$raw = trim((string) $value);
if ($raw === '') {
return '';
}
return (string) Str::of($raw)->lower()->replace('@', '-at-')->slug('-');
}
/**
* @param array<string, mixed> $payload
*/
public function upsertAccount(Amministratore $amministratore, string $accountKey, array $payload, bool $setDefault = false): string
{
$key = $this->normalizeAccountKey($accountKey) ?: 'primary';
$impostazioni = $amministratore->impostazioni ?? [];
$google = Arr::get($impostazioni, 'google', []);
$accounts = (array) Arr::get($google, 'accounts', []);
$existing = isset($accounts[$key]) && is_array($accounts[$key]) ? $accounts[$key] : [];
$account = $this->normalizeAccountPayload(array_replace($existing, $payload), $key);
if (empty($account['scopes'])) {
$account['scopes'] = self::SCOPES;
}
$accounts[$key] = $account;
$google['accounts'] = $accounts;
$defaultKey = $this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', ''));
if ($setDefault || $defaultKey === '') {
$defaultKey = $key;
}
$google['default_account_key'] = $defaultKey;
if ($defaultKey === $key) {
$google['oauth'] = $this->toLegacyOauthPayload($account);
if (trim((string) ($account['email'] ?? '')) !== '') {
$google['workspace_email'] = trim((string) $account['email']);
}
}
$impostazioni['google'] = $google;
$amministratore->impostazioni = $impostazioni;
$amministratore->save();
return $key;
}
public function disconnectAccount(Amministratore $amministratore, ?string $accountKey = null): void
{
$account = $this->get($amministratore, $accountKey);
if (! is_array($account)) {
return;
}
$key = (string) ($account['key'] ?? 'primary');
$impostazioni = $amministratore->impostazioni ?? [];
$google = Arr::get($impostazioni, 'google', []);
$accounts = (array) Arr::get($google, 'accounts', []);
$existing = isset($accounts[$key]) && is_array($accounts[$key]) ? $accounts[$key] : $account;
$accounts[$key] = array_replace($existing, [
'connected' => false,
'access_token' => '',
'refresh_token' => '',
'expires_in' => null,
'refreshed_at' => null,
'disconnected_at' => now()->toDateTimeString(),
]);
$google['accounts'] = $accounts;
if ($this->normalizeAccountKey((string) Arr::get($google, 'default_account_key', '')) === $key) {
$google['oauth'] = [
'connected' => false,
'disconnected_at' => now()->toDateTimeString(),
];
}
$impostazioni['google'] = $google;
$amministratore->impostazioni = $impostazioni;
$amministratore->save();
}
public function resolveAccessToken(Amministratore $amministratore, ?string $accountKey = null, bool $forceRefresh = false): ?string
{
$account = $this->get($amministratore, $accountKey);
if (! is_array($account)) {
return null;
}
$accessToken = trim((string) ($account['access_token'] ?? ''));
$refreshToken = trim((string) ($account['refresh_token'] ?? ''));
if (! $forceRefresh && $accessToken !== '' && ! $this->isExpired($account)) {
return $accessToken;
}
if ($refreshToken === '') {
return $accessToken !== '' ? $accessToken : null;
}
$google = Arr::get($amministratore->impostazioni ?? [], 'google', []);
$clientId = (string) ($google['client_id'] ?? config('services.google.client_id'));
$clientSecret = (string) ($google['client_secret'] ?? config('services.google.client_secret'));
if ($clientId === '' || $clientSecret === '') {
return $accessToken !== '' ? $accessToken : null;
}
$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()) {
return $accessToken !== '' ? $accessToken : null;
}
$payload = $response->json();
$newAccessToken = trim((string) Arr::get($payload, 'access_token', ''));
if ($newAccessToken === '') {
return $accessToken !== '' ? $accessToken : null;
}
$this->upsertAccount($amministratore, (string) ($account['key'] ?? 'primary'), array_filter([
'connected' => true,
'label' => $account['label'] ?? null,
'email' => $account['email'] ?? null,
'name' => $account['name'] ?? null,
'google_user_id'=> $account['google_user_id'] ?? null,
'access_token' => $newAccessToken,
'refresh_token' => trim((string) Arr::get($payload, 'refresh_token', '')) ?: $refreshToken,
'expires_in' => (int) Arr::get($payload, 'expires_in', $account['expires_in'] ?? 3600),
'refreshed_at' => now()->toDateTimeString(),
'scopes' => $account['scopes'] ?? self::SCOPES,
], static fn ($value) => $value !== null), ! empty($account['is_default']));
return $newAccessToken;
}
/**
* @param array<string, mixed> $account
* @return array<string, mixed>
*/
private function normalizeAccountPayload(array $account, string $key): array
{
$label = trim((string) ($account['label'] ?? $account['name'] ?? $account['email'] ?? $key));
return [
'key' => $key,
'label' => $label,
'connected' => (bool) ($account['connected'] ?? false),
'email' => trim((string) ($account['email'] ?? '')),
'name' => trim((string) ($account['name'] ?? '')),
'google_user_id' => trim((string) ($account['google_user_id'] ?? '')),
'access_token' => trim((string) ($account['access_token'] ?? '')),
'refresh_token' => trim((string) ($account['refresh_token'] ?? '')),
'expires_in' => (int) ($account['expires_in'] ?? 0),
'connected_at' => $account['connected_at'] ?? null,
'refreshed_at' => $account['refreshed_at'] ?? null,
'disconnected_at'=> $account['disconnected_at'] ?? null,
'scopes' => array_values(array_filter((array) ($account['scopes'] ?? []))),
];
}
/**
* @param array<string, mixed> $account
* @return array<string, mixed>
*/
private function toLegacyOauthPayload(array $account): array
{
return [
'connected' => (bool) ($account['connected'] ?? false),
'email' => (string) ($account['email'] ?? ''),
'name' => (string) ($account['name'] ?? ''),
'google_user_id' => (string) ($account['google_user_id'] ?? ''),
'access_token' => (string) ($account['access_token'] ?? ''),
'refresh_token' => (string) ($account['refresh_token'] ?? ''),
'expires_in' => (int) ($account['expires_in'] ?? 0),
'connected_at' => $account['connected_at'] ?? null,
'refreshed_at' => $account['refreshed_at'] ?? null,
'scopes' => $account['scopes'] ?? self::SCOPES,
];
}
/**
* @param array<string, mixed> $account
*/
private function isExpired(array $account): bool
{
$refreshedAt = trim((string) ($account['refreshed_at'] ?? $account['connected_at'] ?? ''));
$expiresIn = (int) ($account['expires_in'] ?? 0);
if ($refreshedAt === '' || $expiresIn <= 0) {
return false;
}
$timestamp = strtotime($refreshedAt);
if ($timestamp === false) {
return false;
}
return ($timestamp + max(60, $expiresIn - 120)) <= time();
}
/**
* @param array<string, mixed> $oauth
*/
private function hasUsableOauthPayload(array $oauth): bool
{
return (bool) ($oauth['connected'] ?? false)
|| trim((string) ($oauth['refresh_token'] ?? '')) !== ''
|| trim((string) ($oauth['access_token'] ?? '')) !== '';
}
}

View File

@ -39,14 +39,27 @@ .netgescon-footer {
.fi-sidebar-nav,
.fi-sidebar-nav-groups {
row-gap: calc(var(--spacing) * 4);
row-gap: calc(var(--spacing) * 1.5) !important;
}
.fi-sidebar-group-btn,
.fi-sidebar-group-dropdown-trigger-btn {
padding-block: calc(var(--spacing) * 1.25);
min-height: auto !important;
padding-block: calc(var(--spacing) * 0.55) !important;
}
.fi-sidebar-group-label {
line-height: calc(var(--spacing) * 5);
line-height: 1.2 !important;
}
.fi-sidebar-item-button,
.fi-sidebar-item a {
min-height: auto !important;
padding-block: calc(var(--spacing) * 0.45) !important;
}
.fi-sidebar-nav-groups>li,
.fi-sidebar-group,
.fi-sidebar-item {
margin-block: 0 !important;
}

View File

@ -2,3 +2,31 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css">
<link rel="stylesheet" href="{{ asset('css/netgescon-admin.css') }}">
<style>
.fi-sidebar-nav,
.fi-sidebar-nav-groups {
row-gap: calc(var(--spacing) * 1.5) !important;
}
.fi-sidebar-group-btn,
.fi-sidebar-group-dropdown-trigger-btn {
min-height: auto !important;
padding-block: calc(var(--spacing) * 0.55) !important;
}
.fi-sidebar-group-label {
line-height: 1.2 !important;
}
.fi-sidebar-item-button,
.fi-sidebar-item a {
min-height: auto !important;
padding-block: calc(var(--spacing) * 0.45) !important;
}
.fi-sidebar-nav-groups > li,
.fi-sidebar-group,
.fi-sidebar-item {
margin-block: 0 !important;
}
</style>

View File

@ -14,6 +14,41 @@
</div>
</div>
<div class="rounded-xl border border-sky-200 bg-sky-50 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-semibold text-sky-900">Account Google disponibili per posta stabile</div>
<div class="mt-1 text-xs text-sky-800">Collega qui un account di prova e un account dedicato allo stabile. Dopo il collegamento lo puoi selezionare nella casella Gmail qui sotto.</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $this->getTestGoogleConnectUrl() }}" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500">Collega account Google test</a>
<a href="{{ $this->getStableGoogleConnectUrl() }}" class="inline-flex items-center rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500">Collega account per questo stabile</a>
</div>
</div>
@if(count($this->getGoogleAccountsSummary()) > 0)
<div class="mt-3 grid gap-3 md:grid-cols-2">
@foreach($this->getGoogleAccountsSummary() as $account)
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="flex items-start justify-between gap-2">
<div>
<div class="font-semibold text-slate-900">{{ $account['label'] ?: $account['key'] }}</div>
<div class="mt-1 text-slate-600">{{ $account['email'] ?: 'Email non disponibile' }}</div>
<div class="mt-1 text-[11px] {{ !empty($account['connected']) ? 'text-emerald-700' : 'text-rose-700' }}">{{ !empty($account['connected']) ? 'Collegato' : 'Non collegato' }}@if(!empty($account['is_default'])) · predefinito @endif</div>
</div>
<form method="POST" action="{{ route('oauth.google.disconnect') }}">
@csrf
<input type="hidden" name="account_key" value="{{ $account['key'] }}" />
<input type="hidden" name="return_to" value="{{ request()->getRequestUri() }}" />
<button type="submit" class="rounded-md bg-rose-100 px-2 py-1 text-[11px] font-medium text-rose-700 hover:bg-rose-200">Disconnetti</button>
</form>
</div>
</div>
@endforeach
</div>
@endif
</div>
<form wire:submit="save" class="space-y-4">
{{ $this->getSchema('form') }}

View File

@ -201,9 +201,7 @@
</div>
</div>
@elseif($activeTab === 'scheda')
@php
$ticket = $this->selectedTicket;
@endphp
@php($ticket = $this->selectedTicket)
@if(! $ticket)
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
Nessun ticket selezionato. Vai nella TAB Elenco e premi "Scheda" sulla riga del ticket.
@ -438,12 +436,8 @@
</div>
</div>
@endif
@endif
@if($activeTab === 'assicurazione')
@php
$ticket = $this->selectedTicket;
@endphp
@elseif($activeTab === 'assicurazione')
@php($ticket = $this->selectedTicket)
@if(! $ticket)
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
Nessun ticket selezionato. Apri un ticket e poi entra nella Tab 4 assicurazione.
@ -586,28 +580,7 @@
</div>
@if($attachmentPreview)
@php($attachmentPreviewUrl = (string) ($attachmentPreview['url'] ?? ''))
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" x-data='{
zoom: 1,
pdfPage: 1,
iframeKey: 0,
pdfUrl: @js($attachmentPreviewUrl),
refreshPdf() { this.iframeKey++; },
zoomIn() { this.zoom = Math.min(this.zoom + 0.2, 4); },
zoomOut() { this.zoom = Math.max(this.zoom - 0.2, 0.5); },
resetZoom() { this.zoom = 1; },
nextPage() { this.pdfPage = this.pdfPage + 1; this.refreshPdf(); },
prevPage() { this.pdfPage = Math.max(this.pdfPage - 1, 1); this.refreshPdf(); },
printPdf() {
const frame = this.$refs.pdfFrame;
if (frame?.contentWindow) {
frame.contentWindow.focus();
frame.contentWindow.print();
} else if (this.pdfUrl) {
window.open(this.pdfUrl, "_blank");
}
}
}'>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<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>
@ -630,20 +603,15 @@
@endif
</div>
<div class="flex items-center gap-2">
@if($attachmentPreview['is_image'] ?? false)
<button type="button" x-on:click="zoomOut()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">-</button>
<button type="button" x-on:click="resetZoom()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">100%</button>
<button type="button" x-on:click="zoomIn()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">+</button>
@elseif($attachmentPreview['is_pdf'] ?? false)
<button type="button" x-on:click="prevPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina precedente</button>
<span class="text-xs text-slate-500">Pagina <span x-text="pdfPage"></span></span>
<button type="button" x-on:click="nextPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina successiva</button>
<button type="button" x-on:click="printPdf()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Stampa PDF</button>
@if(!empty($attachmentPreview['is_image']))
<span class="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-600">Anteprima immagine</span>
@elseif(!empty($attachmentPreview['is_pdf']))
<span class="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-600">Anteprima PDF</span>
@endif
@if(!empty($attachmentPreview['details']['gps']))
<button type="button" wire:click="openAttachmentMapPreview({{ (int) ($attachmentPreview['id'] ?? 0) }})" class="rounded-md bg-emerald-100 px-2 py-1 text-xs text-emerald-800 hover:bg-emerald-200">Google Maps</button>
@endif
<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>
<a href="{{ (string) ($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>
@ -655,18 +623,18 @@
<div class="mt-1 whitespace-pre-wrap">{{ $attachmentPreview['details']['full_description'] }}</div>
</div>
@endif
@if($attachmentPreview['is_image'] ?? false)
@if(!empty($attachmentPreview['is_image']))
<div class="h-full overflow-auto rounded-lg bg-slate-50 p-4">
<div class="flex min-h-full min-w-full items-start justify-center">
<img src="{{ $attachmentPreview['url'] }}" alt="Anteprima allegato" class="max-w-none rounded border bg-white shadow" x-bind:style="`width: ${zoom * 100}%; height: auto;`" />
<img src="{{ (string) ($attachmentPreview['url'] ?? '') }}" alt="Anteprima allegato" class="max-w-full rounded border bg-white shadow" />
</div>
</div>
@elseif($attachmentPreview['is_pdf'] ?? false)
<iframe x-ref="pdfFrame" x-bind:key="iframeKey" x-bind:src="pdfUrl + '#toolbar=1&navpanes=0&scrollbar=1&view=FitH&zoom=page-width&page=' + pdfPage" class="h-[84vh] w-full rounded border"></iframe>
@elseif(!empty($attachmentPreview['is_pdf']))
<iframe src="{{ (string) ($attachmentPreview['url'] ?? '') }}#toolbar=1&navpanes=0&scrollbar=1&view=FitH&zoom=page-width" 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:
<a href="{{ $attachmentPreview['url'] }}" class="ml-1 text-primary-600 hover:underline">Apri file</a>
<a href="{{ (string) ($attachmentPreview['url'] ?? '') }}" class="ml-1 text-primary-600 hover:underline">Apri file</a>
</div>
@endif
</div>

View File

@ -362,9 +362,9 @@
<div class="mt-1 text-xs text-gray-500">iPhone: su "Aggiungi allegati" scegli "Sfoglia" per aprire l'app File (iCloud Drive/On My iPhone).</div>
<div wire:loading wire:target="pendingTicketCameraShots,pendingTicketAttachments" class="mt-2 text-xs font-medium text-amber-700">Caricamento in corso: sto accodando foto e allegati alla bozza ticket.</div>
<div x-show="localCameraPreviews.length || localAttachmentPreviews.length" x-cloak class="mt-3 rounded-xl border border-sky-200 bg-sky-50 p-3">
<div x-show="(localCameraPreviews.length || localAttachmentPreviews.length) && !@js(count($this->selectedUploads) > 0)" x-cloak class="mt-3 rounded-xl border border-sky-200 bg-sky-50 p-3">
<div class="text-xs font-semibold text-sky-900">Anteprima immediata sul telefono</div>
<div class="mt-1 text-[11px] text-sky-800">Le foto e gli allegati selezionati si vedono subito qui mentre entrano in coda. Nella griglia sotto ogni miniatura resta nello stesso box della sua descrizione.</div>
<div class="mt-1 text-[11px] text-sky-800">Questa anteprima resta visibile solo finche i file non entrano nella coda operativa. Appena sono accodati, miniatura e descrizione restano nello stesso box qui sotto.</div>
<template x-if="localCameraPreviews.length">
<div class="mt-3">
@ -419,7 +419,7 @@
@if(count($this->selectedUploads) > 0)
<div class="md:col-span-2">
<div class="mb-2 text-xs font-semibold text-gray-700">Coda allegati pronta per il ticket</div>
<div class="mb-2 text-[11px] text-gray-500">Ogni allegato mantiene qui miniatura, protocollo e descrizione nello stesso riquadro, cosi puoi descrivere correttamente piu foto consecutive.</div>
<div class="mb-2 text-[11px] text-gray-500">Ogni allegato resta nello stesso riquadro con miniatura, protocollo e descrizione subito sotto. I dati EXIF e GPS non vengono rimossi dal salvataggio.</div>
<div class="grid gap-3 sm:grid-cols-2">
@foreach($this->selectedUploads as $upload)
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">