Compare commits
2 Commits
684b71e2ff
...
972d2c7e83
| Author | SHA1 | Date | |
|---|---|---|---|
| 972d2c7e83 | |||
| 00b046d4d3 |
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
app/Console/Commands/GmailImportStabileTicketsCommand.php
Normal file
83
app/Console/Commands/GmailImportStabileTicketsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 l’account 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@ public function openAttachmentMapPreview(int $attachmentId): void
|
|||
|
||||
public function closeAttachmentPreview(): void
|
||||
{
|
||||
$this->attachmentPreview = null;
|
||||
$this->attachmentPreview = null;
|
||||
$this->attachmentMapPreview = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
||||
|
|
|
|||
417
app/Services/Support/GmailTicketImportService.php
Normal file
417
app/Services/Support/GmailTicketImportService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
336
app/Support/GoogleAccountStore.php
Normal file
336
app/Support/GoogleAccountStore.php
Normal 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'] ?? '')) !== '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user