feat(posta,protocollo,ticket): studio imap mailboxes, stabili monitoring, multi-attachment resolution and ticket ui modernization

This commit is contained in:
michele 2026-09-06 21:40:26 +02:00
parent e7e60ec45d
commit d05865cb4d
12 changed files with 1382 additions and 234 deletions

View File

@ -2094,4 +2094,117 @@ private function safeAmministratoreImpostazioni(Amministratore $amministratore):
return is_array($settings) ? $settings : [];
}
public function getStabiliMailboxStatusProperty(): array
{
$user = Auth::user();
$stabili = $user instanceof User ? StabileContext::accessibleStabili($user) : Stabile::all();
$res = [];
foreach ($stabili as $s) {
$cfg = (array) ($s->configurazione_avanzata ?? []);
$posta = (array) ($cfg['posta'] ?? []);
$caselle = (array) ($posta['caselle'] ?? []);
$emailConfigured = false;
$emailInfo = '';
$pecConfigured = false;
$pecInfo = '';
foreach ($caselle as $c) {
if (! is_array($c) || empty($c['enabled'])) {
continue;
}
$isPec = ($c['tipo'] ?? '') === 'pec';
$hasConn = ! empty($c['host']) && ! empty($c['username']);
if ($isPec) {
if ($hasConn) {
$pecConfigured = true;
$pecInfo = ($c['email'] ?? $c['username'] ?? '') . ' (' . ($c['host'] ?? '') . ')';
}
} else {
if ($hasConn) {
$emailConfigured = true;
$emailInfo = ($c['email'] ?? $c['username'] ?? '') . ' (' . ($c['host'] ?? '') . ')';
}
}
}
$msgCount = CommunicationMessage::where('stabile_id', $s->id)->count();
$res[] = [
'id' => (int) $s->id,
'codice_stabile' => (string) $s->codice_stabile,
'denominazione' => (string) $s->denominazione,
'comune' => (string) $s->comune,
'codice_fiscale' => (string) $s->codice_fiscale,
'email_configured' => $emailConfigured,
'email_info' => $emailInfo,
'pec_configured' => $pecConfigured,
'pec_info' => $pecInfo,
'msg_count' => $msgCount,
'config_url' => url('/admin-filament/condomini/stabile?stabile_id=' . $s->id . '&tab=posta-ufficiale'),
'webmail_email_url' => url('/admin-filament/comunicazioni/posta-ordinaria?stabile_id=' . $s->id),
'webmail_pec_url' => url('/admin-filament/comunicazioni/posta-pec?stabile_id=' . $s->id),
];
}
return $res;
}
public function testStudioMailbox(int $index): void
{
$caselle = (array) data_get($this->data, 'impostazioni.posta.caselle', []);
$box = $caselle[$index] ?? null;
if (! is_array($box)) {
Notification::make()->title('Configurazione casella non trovata')->danger()->send();
return;
}
$service = app(\App\Services\Posta\ImapMailboxService::class);
$res = $service->testMailbox($box);
if ($res['success']) {
Notification::make()
->title('Connessione IMAP Studio Riuscita!')
->body($res['message'])
->success()
->send();
} else {
Notification::make()
->title('Test Connessione Fallito')
->body($res['message'])
->danger()
->send();
}
}
public function syncStudioMailbox(int $index): void
{
$caselle = (array) data_get($this->data, 'impostazioni.posta.caselle', []);
$box = $caselle[$index] ?? null;
if (! is_array($box)) {
Notification::make()->title('Configurazione casella non trovata')->danger()->send();
return;
}
$service = app(\App\Services\Posta\ImapMailboxService::class);
$res = $service->fetchStudioMailbox($this->amministratore, $box, 30);
if (($res['imported'] ?? 0) > 0) {
Notification::make()
->title('Sincronizzazione completata')
->body("Importati {$res['imported']} nuovi messaggi (.EML)")
->success()
->send();
} else {
Notification::make()
->title('Nessun nuovo messaggio')
->body($res['message'] ?? 'Nessun messaggio trovato da scaricare.')
->info()
->send();
}
}
}

View File

@ -36,7 +36,7 @@ class PostaOrdinariaWebmail extends Page
public string $tipoCasella = 'email'; // 'email' o 'pec'
public ?int $selectedStabileId = null;
public string|int|null $selectedStabileId = null;
public string $currentFolder = 'inbox'; // 'inbox', 'starred', 'sent', 'drafts', 'trash', 'attachments'
@ -68,9 +68,11 @@ public function mount(): void
{
$user = Auth::user();
if ($user instanceof User) {
$reqStabile = request()->integer('stabile_id');
if ($reqStabile > 0) {
$this->selectedStabileId = $reqStabile;
$reqStabile = request()->query('stabile_id');
if ($reqStabile === 'studio') {
$this->selectedStabileId = 'studio';
} elseif (is_numeric($reqStabile) && (int) $reqStabile > 0) {
$this->selectedStabileId = (int) $reqStabile;
} else {
$this->selectedStabileId = StabileContext::resolveActiveStabileId($user);
}
@ -84,9 +86,66 @@ public function getStabiliOptionsProperty(): array
return [];
}
return StabileContext::accessibleStabili($user)
$options = [
'studio' => '🏢 Posta Generale Studio / Amministrazione',
];
$stabili = StabileContext::accessibleStabili($user)
->mapWithKeys(fn(Stabile $s) => [(int) $s->id => trim((string) ($s->codice_stabile . ' - ' . $s->denominazione))])
->all();
return $options + $stabili;
}
public function getConfigMailboxUrlProperty(): string
{
if ($this->selectedStabileId === 'studio') {
return url('/admin-filament/impostazioni/scheda-amministratore?tab=posta-operativita');
}
if (is_numeric($this->selectedStabileId) && (int) $this->selectedStabileId > 0) {
return url('/admin-filament/condomini/stabile?stabile_id=' . $this->selectedStabileId . '&tab=posta-ufficiale');
}
return url('/admin-filament/condomini/stabile?tab=posta-ufficiale');
}
public function getIsMailboxConfiguredProperty(): bool
{
$user = Auth::user();
if (! $user instanceof User) {
return false;
}
if ($this->selectedStabileId === 'studio') {
$amministratore = $user->amministratore;
if ($amministratore) {
$config = (array) ($amministratore->impostazioni ?? []);
$caselle = (array) ($config['posta']['caselle'] ?? []);
foreach ($caselle as $c) {
if (! empty($c['enabled']) && ! empty($c['host']) && ($this->tipoCasella === 'pec' ? ($c['tipo'] ?? '') === 'pec' : ($c['tipo'] ?? '') !== 'pec')) {
return true;
}
}
}
return false;
}
if (is_numeric($this->selectedStabileId) && (int) $this->selectedStabileId > 0) {
$stabile = Stabile::find($this->selectedStabileId);
if ($stabile) {
$config = (array) ($stabile->configurazione_avanzata ?? []);
$caselle = (array) ($config['posta']['caselle'] ?? []);
foreach ($caselle as $c) {
if (! empty($c['enabled']) && ! empty($c['host']) && ($this->tipoCasella === 'pec' ? ($c['tipo'] ?? '') === 'pec' : ($c['tipo'] ?? '') !== 'pec')) {
return true;
}
}
}
return false;
}
return true;
}
public function selectFolder(string $folder): void
@ -174,31 +233,56 @@ public function syncNow(): void
}
$service = app(ImapMailboxService::class);
$stabili = $this->selectedStabileId
? Stabile::where('id', $this->selectedStabileId)->get()
: StabileContext::accessibleStabili($user);
$totImported = 0;
foreach ($stabili as $stabile) {
$config = (array) ($stabile->configurazione_avanzata ?? []);
$posta = (array) ($config['posta'] ?? []);
$caselle = array_values(array_filter((array) ($posta['caselle'] ?? []), 'is_array'));
foreach ($caselle as $mailbox) {
if (empty($mailbox['enabled'])) {
continue;
}
$mTipo = strtolower((string) ($mailbox['tipo'] ?? 'imap'));
if ($this->tipoCasella === 'pec' && $mTipo !== 'pec') {
continue;
}
if ($this->tipoCasella === 'email' && $mTipo === 'pec') {
continue;
if ($this->selectedStabileId === 'studio') {
$amministratore = $user->amministratore;
if ($amministratore) {
$config = (array) ($amministratore->impostazioni ?? []);
$caselle = array_values(array_filter((array) ($config['posta']['caselle'] ?? []), 'is_array'));
foreach ($caselle as $mailbox) {
if (empty($mailbox['enabled'])) {
continue;
}
$mTipo = strtolower((string) ($mailbox['tipo'] ?? 'imap'));
if ($this->tipoCasella === 'pec' && $mTipo !== 'pec') {
continue;
}
if ($this->tipoCasella === 'email' && $mTipo === 'pec') {
continue;
}
if (! empty($mailbox['host']) && ! empty($mailbox['username'])) {
$res = $service->fetchStudioMailbox($amministratore, $mailbox, 20);
$totImported += (int) ($res['imported'] ?? 0);
}
}
}
} else {
$stabili = (is_numeric($this->selectedStabileId) && (int) $this->selectedStabileId > 0)
? Stabile::where('id', (int) $this->selectedStabileId)->get()
: StabileContext::accessibleStabili($user);
if (! empty($mailbox['host']) && ! empty($mailbox['username'])) {
$res = $service->fetchMailbox($stabile, $mailbox, 20);
$totImported += (int) ($res['imported'] ?? 0);
foreach ($stabili as $stabile) {
$config = (array) ($stabile->configurazione_avanzata ?? []);
$posta = (array) ($config['posta'] ?? []);
$caselle = array_values(array_filter((array) ($posta['caselle'] ?? []), 'is_array'));
foreach ($caselle as $mailbox) {
if (empty($mailbox['enabled'])) {
continue;
}
$mTipo = strtolower((string) ($mailbox['tipo'] ?? 'imap'));
if ($this->tipoCasella === 'pec' && $mTipo !== 'pec') {
continue;
}
if ($this->tipoCasella === 'email' && $mTipo === 'pec') {
continue;
}
if (! empty($mailbox['host']) && ! empty($mailbox['username'])) {
$res = $service->fetchMailbox($stabile, $mailbox, 20);
$totImported += (int) ($res['imported'] ?? 0);
}
}
}
}
@ -283,8 +367,7 @@ public function getMessagesProperty()
? StabileContext::accessibleStabili($user)->pluck('id')->toArray()
: [];
$query = CommunicationMessage::with(['stabile', 'ticket'])
->whereIn('stabile_id', $allowedStabileIds);
$query = CommunicationMessage::with(['stabile', 'ticket']);
if ($this->tipoCasella === 'pec') {
$query->where('channel', 'pec');
@ -292,8 +375,15 @@ public function getMessagesProperty()
$query->whereIn('channel', ['email', 'gmail']);
}
if ($this->selectedStabileId) {
$query->where('stabile_id', $this->selectedStabileId);
if ($this->selectedStabileId === 'studio') {
$query->whereNull('stabile_id');
} elseif (is_numeric($this->selectedStabileId) && (int) $this->selectedStabileId > 0) {
$query->where('stabile_id', (int) $this->selectedStabileId);
} else {
$query->where(function ($q) use ($allowedStabileIds) {
$q->whereIn('stabile_id', $allowedStabileIds)
->orWhereNull('stabile_id');
});
}
if ($this->currentFolder === 'sent') {
@ -335,15 +425,22 @@ public function getFolderCountsProperty(): array
? StabileContext::accessibleStabili($user)->pluck('id')->toArray()
: [];
$base = CommunicationMessage::whereIn('stabile_id', $allowedStabileIds);
$base = CommunicationMessage::query();
if ($this->tipoCasella === 'pec') {
$base->where('channel', 'pec');
} else {
$base->whereIn('channel', ['email', 'gmail']);
}
if ($this->selectedStabileId) {
$base->where('stabile_id', $this->selectedStabileId);
if ($this->selectedStabileId === 'studio') {
$base->whereNull('stabile_id');
} elseif (is_numeric($this->selectedStabileId) && (int) $this->selectedStabileId > 0) {
$base->where('stabile_id', (int) $this->selectedStabileId);
} else {
$base->where(function ($q) use ($allowedStabileIds) {
$q->whereIn('stabile_id', $allowedStabileIds)
->orWhereNull('stabile_id');
});
}
return [

View File

@ -38,7 +38,7 @@ class PostaPecWebmail extends Page
public string $tipoCasella = 'pec';
public ?int $selectedStabileId = null;
public string|int|null $selectedStabileId = null;
public string $currentFolder = 'inbox'; // 'inbox', 'starred', 'sent', 'drafts', 'trash', 'attachments'
@ -70,9 +70,11 @@ public function mount(): void
{
$user = Auth::user();
if ($user instanceof User) {
$reqStabile = request()->integer('stabile_id');
if ($reqStabile > 0) {
$this->selectedStabileId = $reqStabile;
$reqStabile = request()->query('stabile_id');
if ($reqStabile === 'studio') {
$this->selectedStabileId = 'studio';
} elseif (is_numeric($reqStabile) && (int) $reqStabile > 0) {
$this->selectedStabileId = (int) $reqStabile;
} else {
$this->selectedStabileId = StabileContext::resolveActiveStabileId($user);
}
@ -86,9 +88,66 @@ public function getStabiliOptionsProperty(): array
return [];
}
return StabileContext::accessibleStabili($user)
$options = [
'studio' => '🏢 PEC Generale Studio / Amministrazione',
];
$stabili = StabileContext::accessibleStabili($user)
->mapWithKeys(fn(Stabile $s) => [(int) $s->id => trim((string) ($s->codice_stabile . ' - ' . $s->denominazione))])
->all();
return $options + $stabili;
}
public function getConfigMailboxUrlProperty(): string
{
if ($this->selectedStabileId === 'studio') {
return url('/admin-filament/impostazioni/scheda-amministratore?tab=posta-operativita');
}
if (is_numeric($this->selectedStabileId) && (int) $this->selectedStabileId > 0) {
return url('/admin-filament/condomini/stabile?stabile_id=' . $this->selectedStabileId . '&tab=posta-ufficiale');
}
return url('/admin-filament/condomini/stabile?tab=posta-ufficiale');
}
public function getIsMailboxConfiguredProperty(): bool
{
$user = Auth::user();
if (! $user instanceof User) {
return false;
}
if ($this->selectedStabileId === 'studio') {
$amministratore = $user->amministratore;
if ($amministratore) {
$config = (array) ($amministratore->impostazioni ?? []);
$caselle = (array) ($config['posta']['caselle'] ?? []);
foreach ($caselle as $c) {
if (! empty($c['enabled']) && ! empty($c['host']) && ($c['tipo'] ?? '') === 'pec') {
return true;
}
}
}
return false;
}
if (is_numeric($this->selectedStabileId) && (int) $this->selectedStabileId > 0) {
$stabile = Stabile::find($this->selectedStabileId);
if ($stabile) {
$config = (array) ($stabile->configurazione_avanzata ?? []);
$caselle = (array) ($config['posta']['caselle'] ?? []);
foreach ($caselle as $c) {
if (! empty($c['enabled']) && ! empty($c['host']) && ($c['tipo'] ?? '') === 'pec') {
return true;
}
}
}
return false;
}
return true;
}
public function selectFolder(string $folder): void
@ -176,28 +235,50 @@ public function syncNow(): void
}
$service = app(ImapMailboxService::class);
$stabili = $this->selectedStabileId
? Stabile::where('id', $this->selectedStabileId)->get()
: StabileContext::accessibleStabili($user);
$totImported = 0;
foreach ($stabili as $stabile) {
$config = (array) ($stabile->configurazione_avanzata ?? []);
$posta = (array) ($config['posta'] ?? []);
$caselle = array_values(array_filter((array) ($posta['caselle'] ?? []), 'is_array'));
foreach ($caselle as $mailbox) {
if (empty($mailbox['enabled'])) {
continue;
}
$mTipo = strtolower((string) ($mailbox['tipo'] ?? 'imap'));
if ($mTipo !== 'pec') {
continue;
if ($this->selectedStabileId === 'studio') {
$amministratore = $user->amministratore;
if ($amministratore) {
$config = (array) ($amministratore->impostazioni ?? []);
$caselle = array_values(array_filter((array) ($config['posta']['caselle'] ?? []), 'is_array'));
foreach ($caselle as $mailbox) {
if (empty($mailbox['enabled'])) {
continue;
}
$mTipo = strtolower((string) ($mailbox['tipo'] ?? 'imap'));
if ($mTipo !== 'pec') {
continue;
}
if (! empty($mailbox['host']) && ! empty($mailbox['username'])) {
$res = $service->fetchStudioMailbox($amministratore, $mailbox, 20);
$totImported += (int) ($res['imported'] ?? 0);
}
}
}
} else {
$stabili = (is_numeric($this->selectedStabileId) && (int) $this->selectedStabileId > 0)
? Stabile::where('id', (int) $this->selectedStabileId)->get()
: StabileContext::accessibleStabili($user);
if (! empty($mailbox['host']) && ! empty($mailbox['username'])) {
$res = $service->fetchMailbox($stabile, $mailbox, 20);
$totImported += (int) ($res['imported'] ?? 0);
foreach ($stabili as $stabile) {
$config = (array) ($stabile->configurazione_avanzata ?? []);
$posta = (array) ($config['posta'] ?? []);
$caselle = array_values(array_filter((array) ($posta['caselle'] ?? []), 'is_array'));
foreach ($caselle as $mailbox) {
if (empty($mailbox['enabled'])) {
continue;
}
$mTipo = strtolower((string) ($mailbox['tipo'] ?? 'imap'));
if ($mTipo !== 'pec') {
continue;
}
if (! empty($mailbox['host']) && ! empty($mailbox['username'])) {
$res = $service->fetchMailbox($stabile, $mailbox, 20);
$totImported += (int) ($res['imported'] ?? 0);
}
}
}
}
@ -283,11 +364,17 @@ public function getMessagesProperty()
: [];
$query = CommunicationMessage::with(['stabile', 'ticket'])
->whereIn('stabile_id', $allowedStabileIds)
->where('channel', 'pec');
if ($this->selectedStabileId) {
$query->where('stabile_id', $this->selectedStabileId);
if ($this->selectedStabileId === 'studio') {
$query->whereNull('stabile_id');
} elseif (is_numeric($this->selectedStabileId) && (int) $this->selectedStabileId > 0) {
$query->where('stabile_id', (int) $this->selectedStabileId);
} else {
$query->where(function ($q) use ($allowedStabileIds) {
$q->whereIn('stabile_id', $allowedStabileIds)
->orWhereNull('stabile_id');
});
}
if ($this->currentFolder === 'sent') {
@ -329,10 +416,17 @@ public function getFolderCountsProperty(): array
? StabileContext::accessibleStabili($user)->pluck('id')->toArray()
: [];
$base = CommunicationMessage::whereIn('stabile_id', $allowedStabileIds)->where('channel', 'pec');
$base = CommunicationMessage::query()->where('channel', 'pec');
if ($this->selectedStabileId) {
$base->where('stabile_id', $this->selectedStabileId);
if ($this->selectedStabileId === 'studio') {
$base->whereNull('stabile_id');
} elseif (is_numeric($this->selectedStabileId) && (int) $this->selectedStabileId > 0) {
$base->where('stabile_id', (int) $this->selectedStabileId);
} else {
$base->where(function ($q) use ($allowedStabileIds) {
$q->whereIn('stabile_id', $allowedStabileIds)
->orWhereNull('stabile_id');
});
}
return [

View File

@ -1186,6 +1186,7 @@ public function getProtocolloComunicazioniProperty(): array
$codStabile = (string) $this->unita->stabile->codice_stabile;
$scala = trim((string) $this->unita->scala);
$interno = trim((string) $this->unita->interno);
$condIds = $this->resolveLegacyCondIdsForUnita();
$results = [];
@ -1193,16 +1194,19 @@ public function getProtocolloComunicazioniProperty(): array
if (DbSchema::connection('gescon_import')->hasTable('protoc_ec')) {
$ecQuery = DB::connection('gescon_import')->table('protoc_ec')
->where('cod_stabile', $codStabile)
->where(function ($q) use ($scala, $interno) {
->where(function ($q) use ($scala, $interno, $condIds) {
$q->where(function ($sub) use ($scala, $interno) {
$sub->where('scala', $scala)->where('interno', $interno);
});
if (! empty($condIds)) {
$q->orWhereIn('id_condomino', $condIds);
}
})
->orderByDesc('id')
->get();
foreach ($ecQuery as $r) {
$dtRaw = $r->data_invio ?? null;
$dtRaw = $r->data_invio ?? ($r->data ?? null);
$ts = 0;
$dtFmt = '—';
if ($dtRaw) {
@ -1215,24 +1219,34 @@ public function getProtocolloComunicazioniProperty(): array
}
}
$pdfName = trim((string) ($r->nome_pdf ?? ''));
$pdfPath = $pdfName !== '' ? "/mnt/gescon-archives/gescon/{$codStabile}/E_C/{$pdfName}" : null;
$pdfExists = $pdfPath && file_exists($pdfPath);
$candidateNames = array_values(array_unique(array_filter([
trim((string) ($r->nome_pdf ?? '')),
trim((string) ($r->alleg_4 ?? '')),
trim((string) ($r->lettera_tipo_caricata ?? '')),
])));
$allegati = [];
foreach ($candidateNames as $fn) {
$allegati[] = \App\Services\Documenti\ArchiveFileResolver::resolve($codStabile, $fn, 'E_C');
}
$firstAtt = $allegati[0] ?? null;
$results[] = [
'id' => 'ec_' . $r->id,
'protocollo' => $r->protocollo ?: ('EC #' . $r->id_corrisp),
'protocollo' => $r->protocollo ?: ('EC #' . ($r->id_corrisp ?? $r->id)),
'tipo' => 'Estratto Conto',
'canale' => $r->tipo_documento ?: 'E-Mail',
'data_invio' => $dtFmt,
'destinatario' => $r->destinatario ?: $r->nome,
'ruolo' => $r->c_i === 'I' ? 'Inquilino' : 'Condomino',
'destinatario' => $r->destinatario ?: ($r->nome ?? '—'),
'ruolo' => ($r->c_i ?? '') === 'I' ? 'Inquilino' : 'Condomino',
'oggetto' => $r->oggetto ?: 'Invio Estratto Conto',
'importo' => (float) ($r->totale ?? 0),
'nome_file' => $pdfName,
'file_path' => $pdfPath,
'file_exists' => $pdfExists,
'note' => $r->note,
'allegati' => $allegati,
'nome_file' => $firstAtt['filename'] ?? null,
'file_path' => $firstAtt['path'] ?? null,
'file_exists' => $firstAtt['exists'] ?? false,
'note' => $r->note ?? null,
'timestamp_sort' => $ts,
];
}
@ -1240,7 +1254,6 @@ public function getProtocolloComunicazioniProperty(): array
// 2. Dati da corrisp_inviata (Lettere, circolari)
if (DbSchema::connection('gescon_import')->hasTable('corrisp_inviata')) {
$condIds = $this->resolveLegacyCondIdsForUnita();
$corrQuery = DB::connection('gescon_import')->table('corrisp_inviata')
->where('cod_stabile', $codStabile)
->where(function ($q) use ($condIds) {
@ -1253,7 +1266,7 @@ public function getProtocolloComunicazioniProperty(): array
->get();
foreach ($corrQuery as $r) {
$dtRaw = $r->data_invio ?? null;
$dtRaw = $r->data_invio ?? ($r->data ?? null);
$ts = 0;
$dtFmt = '—';
if ($dtRaw) {
@ -1266,30 +1279,112 @@ public function getProtocolloComunicazioniProperty(): array
}
}
$docName = trim((string) ($r->lettera_tipo_caricata ?? ''));
$docPath = $docName !== '' ? "/mnt/gescon-archives/gescon/{$codStabile}/{$docName}" : null;
$docExists = $docPath && file_exists($docPath);
$candidateNames = array_values(array_unique(array_filter([
trim((string) ($r->lettera_tipo_caricata ?? '')),
trim((string) ($r->nome_alleg_1 ?? '')),
trim((string) ($r->nome_alleg_2 ?? '')),
trim((string) ($r->nome_alleg_3 ?? '')),
trim((string) ($r->nome_alleg_4 ?? '')),
])));
$allegati = [];
foreach ($candidateNames as $fn) {
$allegati[] = \App\Services\Documenti\ArchiveFileResolver::resolve($codStabile, $fn, 'allegati');
}
$firstAtt = $allegati[0] ?? null;
$results[] = [
'id' => 'corr_' . $r->id,
'protocollo' => $r->protocollo ?: ('PROT #' . $r->id_corrisp),
'protocollo' => $r->protocollo ?: ('PROT #' . ($r->id_corrisp ?? $r->id)),
'tipo' => 'Lettera / Circolare',
'canale' => $r->tipo_documento ?: 'Posta',
'data_invio' => $dtFmt,
'destinatario' => $r->destinatario ?: 'Condomini',
'destinatario' => $r->destinatario ?: ($r->nome ?? 'Condomini'),
'ruolo' => 'Condomino',
'oggetto' => $r->oggetto ?: 'Comunicazione Amministrazione',
'importo' => null,
'nome_file' => $docName,
'file_path' => $docPath,
'file_exists' => $docExists,
'note' => $r->note,
'allegati' => $allegati,
'nome_file' => $firstAtt['filename'] ?? null,
'file_path' => $firstAtt['path'] ?? null,
'file_exists' => $firstAtt['exists'] ?? false,
'note' => $r->note ?? null,
'timestamp_sort' => $ts,
];
}
}
// 3. Dati da CommunicationMessage (Posta IMAP, PEC, EML)
// 3. Dati da posta_dett (Raccomandate, visure, postalizzazione)
if (DbSchema::connection('gescon_import')->hasTable('posta_dett')) {
$postaQuery = DB::connection('gescon_import')->table('posta_dett')
->where(function ($q) use ($scala, $interno, $condIds) {
$hasCond = false;
if ($scala !== '' && $interno !== '') {
$q->where(function ($sub) use ($scala, $interno) {
$sub->where('scala', $scala)->where('interno', $interno);
});
$hasCond = true;
}
if (! empty($condIds)) {
if ($hasCond) {
$q->orWhereIn('id_cond', $condIds);
} else {
$q->whereIn('id_cond', $condIds);
}
}
})
->orderByDesc('id')
->get();
foreach ($postaQuery as $r) {
$dtRaw = $r->ora_invio_richiesta ?? ($r->data ?? null);
$ts = 0;
$dtFmt = '—';
if ($dtRaw) {
try {
$c = Carbon::parse((string) $dtRaw);
$ts = $c->timestamp;
$dtFmt = $c->format('d/m/Y');
} catch (\Throwable $e) {
$dtFmt = (string) $dtRaw;
}
}
$candidateNames = array_values(array_unique(array_filter([
trim((string) ($r->nome_alleg_1 ?? '')),
trim((string) ($r->nome_alleg_4 ?? '')),
trim((string) ($r->documento ?? '')),
trim((string) ($r->risposta ?? '')),
])));
$allegati = [];
foreach ($candidateNames as $fn) {
$allegati[] = \App\Services\Documenti\ArchiveFileResolver::resolve($codStabile, $fn);
}
$firstAtt = $allegati[0] ?? null;
$results[] = [
'id' => 'posta_dett_' . $r->id,
'protocollo' => $r->num_raccomandata ? ('RAC #' . $r->num_raccomandata) : ('POSTA #' . ($r->id_richiesta ?? $r->id)),
'tipo' => $r->tipo_richiesta ?: 'Spedizione Postale / Visura',
'canale' => 'Posta / Raccomandata',
'data_invio' => $dtFmt,
'destinatario' => $r->nome ?: ($r->destinatario ?? '—'),
'ruolo' => ($r->c_i ?? '') === 'I' ? 'Inquilino' : 'Condomino',
'oggetto' => $r->stato_richiesta ? ('Stato invio: ' . $r->stato_richiesta) : 'Invio Raccomandata / Documento',
'importo' => (float) ($r->costo ?? ($r->importo_ec ?? 0)),
'allegati' => $allegati,
'nome_file' => $firstAtt['filename'] ?? null,
'file_path' => $firstAtt['path'] ?? null,
'file_exists' => $firstAtt['exists'] ?? false,
'note' => $r->stato_richiesta ?? null,
'timestamp_sort' => $ts,
];
}
}
// 4. Dati da CommunicationMessage (Posta IMAP, PEC, EML)
if (DbSchema::hasTable('communication_messages')) {
$unitEmails = [];
$recapiti = $this->getRecapitiMulticanaleTableProperty();
@ -1317,8 +1412,39 @@ public function getProtocolloComunicazioniProperty(): array
$dt = $cm->received_at ? Carbon::parse($cm->received_at) : null;
$dtFmt = $dt ? $dt->format('d/m/Y H:i') : '—';
$ts = $dt ? $dt->timestamp : 0;
$attachments = is_array($cm->attachments) ? $cm->attachments : [];
$firstAtt = $attachments[0] ?? null;
$rawAttachments = is_array($cm->attachments) ? $cm->attachments : [];
$allegati = [];
foreach ($rawAttachments as $att) {
$fn = (string) ($att['filename'] ?? 'allegato');
$fp = (string) ($att['path'] ?? '');
$exists = $fp !== '' && file_exists($fp);
$allegati[] = [
'filename' => $fn,
'path' => $fp,
'exists' => $exists,
'size' => (int) ($att['size'] ?? 0),
'mime' => (string) ($att['content_type'] ?? 'application/octet-stream'),
'is_pdf' => str_ends_with(strtolower($fn), '.pdf'),
'is_image' => (bool) preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $fn),
];
}
if (empty($allegati) && ! empty($meta['eml_path'])) {
$emlPath = (string) $meta['eml_path'];
$emlName = basename($emlPath);
$allegati[] = [
'filename' => $emlName,
'path' => $emlPath,
'exists' => file_exists($emlPath),
'size' => file_exists($emlPath) ? (int) filesize($emlPath) : 0,
'mime' => 'message/rfc822',
'is_pdf' => false,
'is_image' => false,
];
}
$firstAtt = $allegati[0] ?? null;
$results[] = [
'id' => 'comm_' . $cm->id,
@ -1330,9 +1456,10 @@ public function getProtocolloComunicazioniProperty(): array
'ruolo' => $cm->channel === 'pec' ? 'PEC' : 'Email',
'oggetto' => $meta['subject'] ?? \Illuminate\Support\Str::limit($cm->message_text, 60),
'importo' => null,
'nome_file' => $firstAtt['filename'] ?? (! empty($meta['eml_path']) ? basename($meta['eml_path']) : null),
'file_path' => $firstAtt['path'] ?? ($meta['eml_path'] ?? null),
'file_exists' => ($firstAtt['path'] ?? null) ? file_exists($firstAtt['path']) : (! empty($meta['eml_path']) && file_exists($meta['eml_path'])),
'allegati' => $allegati,
'nome_file' => $firstAtt['filename'] ?? null,
'file_path' => $firstAtt['path'] ?? null,
'file_exists' => $firstAtt['exists'] ?? false,
'note' => $cm->message_text ? \Illuminate\Support\Str::limit($cm->message_text, 120) : null,
'timestamp_sort' => $ts,
];

View File

@ -0,0 +1,134 @@
<?php
namespace App\Services\Documenti;
class ArchiveFileResolver
{
/**
* Risolve il percorso assoluto di un file allegato o documento dello stabile,
* effettuando una ricerca euristica nelle cartelle standard (E_C, INC_EC, WA, XML, allegati, posta, etc.).
*
* @param string|int $codStabile
* @param string $filename
* @param string|null $preferredSubdir
* @return array{
* filename: string,
* path: ?string,
* exists: bool,
* size: int,
* mime: string,
* is_pdf: bool,
* is_image: bool
* }
*/
public static function resolve(string|int $codStabile, string $filename, ?string $preferredSubdir = null): array
{
$filename = trim($filename);
if ($filename === '') {
return [
'filename' => '',
'path' => null,
'exists' => false,
'size' => 0,
'mime' => 'application/octet-stream',
'is_pdf' => false,
'is_image' => false,
];
}
$cod = str_pad((string) $codStabile, 4, '0', STR_PAD_LEFT);
$candidateSubdirs = [
$preferredSubdir,
'E_C', 'e_c',
'INC_EC', 'inc_ec',
'allegati', 'Allegati',
'WA', 'wa',
'XML', 'xml',
'posta_ordinaria', 'posta_pec', 'posta',
'documenti', 'Documenti',
'',
];
$candidateSubdirs = array_values(array_unique(array_filter($candidateSubdirs, fn($v) => $v !== null)));
$baseRoots = [
"/mnt/gescon-archives/gescon/{$cod}",
"/mnt/gescon-archives/gescon/" . ltrim((string) $codStabile, '0'),
storage_path("app/private/amministratori/ADMX3PD9/stabili/{$cod}"),
storage_path("app/private/amministratori/ADMX3PD9/stabili/" . ltrim((string) $codStabile, '0')),
storage_path("app/public/stabili/{$cod}"),
storage_path("app/public/stabili/" . ltrim((string) $codStabile, '0')),
];
$foundPath = null;
// 1. Cerca percorsi esatti
foreach ($baseRoots as $root) {
if (! is_dir($root)) {
continue;
}
foreach ($candidateSubdirs as $sub) {
$dir = $sub !== '' ? "{$root}/{$sub}" : $root;
$p = "{$dir}/{$filename}";
if (file_exists($p)) {
$foundPath = $p;
break 2;
}
}
}
// 2. Se non trovato e il file ha un'estensione, cerca case-insensitive
if (! $foundPath) {
$lowerName = strtolower($filename);
foreach ($baseRoots as $root) {
if (! is_dir($root)) {
continue;
}
foreach ($candidateSubdirs as $sub) {
$dir = $sub !== '' ? "{$root}/{$sub}" : $root;
if (is_dir($dir)) {
$files = @scandir($dir);
if (is_array($files)) {
foreach ($files as $f) {
if (strtolower($f) === $lowerName) {
$foundPath = "{$dir}/{$f}";
break 3;
}
}
}
}
}
}
}
$exists = $foundPath !== null && file_exists($foundPath);
$size = $exists ? (int) @filesize($foundPath) : 0;
$mime = 'application/octet-stream';
$isPdf = str_ends_with(strtolower($filename), '.pdf');
$isImg = (bool) preg_match('/\.(jpg|jpeg|png|webp|gif|bmp)$/i', $filename);
if ($exists) {
$finfo = @finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$detected = @finfo_file($finfo, $foundPath);
if ($detected) {
$mime = $detected;
}
@finfo_close($finfo);
}
}
if ($isPdf) {
$mime = 'application/pdf';
}
return [
'filename' => $filename,
'path' => $foundPath,
'exists' => $exists,
'size' => $size,
'mime' => $mime,
'is_pdf' => $isPdf,
'is_image' => $isImg,
];
}
}

View File

@ -107,6 +107,175 @@ public function fetchMailbox(Stabile $stabile, array $mailbox, int $maxMessages
return $stats;
}
/**
* Scarica e memorizza le email/PEC dello studio amministratore in formato .EML
*
* @param \App\Models\Amministratore $amministratore
* @param array $mailbox
* @param int $maxMessages
* @return array{imported: int, skipped: int, errors: int, messages: array}
*/
public function fetchStudioMailbox(\App\Models\Amministratore $amministratore, array $mailbox, int $maxMessages = 50): array
{
$host = trim((string) ($mailbox['host'] ?? ''));
$port = (int) ($mailbox['port'] ?? 993);
$username = trim((string) ($mailbox['username'] ?? ($mailbox['email'] ?? '')));
$password = (string) ($mailbox['password'] ?? '');
$encryption = trim((string) ($mailbox['encryption'] ?? 'ssl')) ?: 'ssl';
$folder = trim((string) ($mailbox['folder'] ?? 'INBOX')) ?: 'INBOX';
$isPec = strtolower(trim((string) ($mailbox['tipo'] ?? ''))) === 'pec';
if ($host === '' || $username === '') {
return [
'imported' => 0,
'skipped' => 0,
'errors' => 1,
'message' => 'Parametri IMAP studio incompleti',
'messages' => [],
];
}
$stats = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'messages' => []];
try {
$this->imapClient->connect($host, $port, $username, $password, $encryption);
$this->imapClient->selectFolder($folder);
$msgIds = $this->imapClient->search('ALL');
$recentIds = array_slice(array_reverse($msgIds), 0, $maxMessages);
foreach ($recentIds as $msgId) {
try {
$rawEml = $this->imapClient->fetchRawEml($msgId);
if (trim($rawEml) === '') {
continue;
}
$res = $this->ingestStudioEmlString($rawEml, $amministratore, $mailbox, $isPec);
if ($res['status'] === 'imported') {
$stats['imported']++;
$stats['messages'][] = $res;
} elseif ($res['status'] === 'duplicate') {
$stats['skipped']++;
} else {
$stats['errors']++;
}
} catch (\Throwable $e) {
$stats['errors']++;
}
}
$this->imapClient->disconnect();
} catch (\Throwable $e) {
$stats['errors']++;
$stats['error_message'] = $e->getMessage();
}
return $stats;
}
/**
* Ingesta un messaggio EML dello studio amministratore
*/
public function ingestStudioEmlString(
string $rawEml,
\App\Models\Amministratore $amministratore,
array $mailboxConfig = [],
bool $forcePec = false
): array {
$parsed = $this->emlParser->parse($rawEml);
$messageId = $parsed['message_id'] ?: ('studio_' . md5($rawEml));
$existing = CommunicationMessage::whereNull('stabile_id')
->where(function ($q) use ($messageId) {
$q->where('external_message_id', $messageId)
->orWhere('metadata->message_id', $messageId);
})
->first();
if ($existing) {
return ['status' => 'duplicate', 'id' => $existing->id];
}
$channel = ($forcePec || $parsed['is_pec']) ? 'pec' : 'email';
$year = $parsed['date'] ? $parsed['date']->format('Y') : date('Y');
$postaSubdir = $channel === 'pec' ? 'posta_pec' : 'posta_ordinaria';
$storageDir = $this->pathService->amministratoreAbsolutePath($amministratore, "studio/{$postaSubdir}/{$year}");
if (! is_dir($storageDir)) {
@mkdir($storageDir, 0755, true);
}
$safeMsgId = preg_replace('/[^a-zA-Z0-9_-]/', '_', $messageId);
$emlFilename = "{$safeMsgId}.eml";
$emlFullPath = "{$storageDir}/{$emlFilename}";
@file_put_contents($emlFullPath, $rawEml);
$savedAttachments = [];
if (! empty($parsed['attachments'])) {
$attachmentsDir = "{$storageDir}/allegati/{$safeMsgId}";
if (! is_dir($attachmentsDir)) {
@mkdir($attachmentsDir, 0755, true);
}
foreach ($parsed['attachments'] as $idx => $att) {
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', (string) $att['filename']);
if ($safeName === '') {
$safeName = "allegato_{$idx}.dat";
}
$attPath = "{$attachmentsDir}/{$safeName}";
@file_put_contents($attPath, $att['content']);
$savedAttachments[] = [
'filename' => $att['filename'],
'path' => $attPath,
'content_type' => $att['content_type'],
'size' => $att['size'],
'is_inline' => $att['is_inline'],
];
}
}
$senderDisplay = $parsed['from']['name'] ? "{$parsed['from']['name']} <{$parsed['from']['email']}>" : $parsed['from']['email'];
$comm = CommunicationMessage::create([
'channel' => $channel,
'direction' => 'inbound',
'external_message_id' => $messageId,
'stabile_id' => null,
'sender_name' => $senderDisplay,
'phone_number' => null,
'message_text' => $parsed['body_text'] ?: strip_tags((string) $parsed['body_html']),
'attachments' => $savedAttachments,
'ticket_id' => null,
'status' => 'received',
'received_at' => $parsed['date'] ?: now(),
'metadata' => [
'subject' => $parsed['subject'],
'sender_email' => $parsed['from']['email'],
'sender_name' => $parsed['from']['name'],
'to' => $parsed['to'],
'cc' => $parsed['cc'],
'body_html' => $parsed['body_html'],
'eml_path' => $emlFullPath,
'is_pec' => $channel === 'pec',
'is_studio' => true,
'mailbox_label' => $mailboxConfig['label'] ?? 'Studio',
'mailbox_email' => $mailboxConfig['email'] ?? '',
],
]);
return [
'status' => 'imported',
'id' => $comm->id,
'channel' => $channel,
'subject' => $parsed['subject'],
'from' => $senderDisplay,
'date' => $parsed['date']?->format('d/m/Y H:i'),
'has_attachments' => count($savedAttachments) > 0,
];
}
/**
* Ingesta un messaggio da stringa EML grezza
*/

View File

@ -1,18 +1,202 @@
<div class="space-y-3">
<div class="rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700">
Questa area serve a verificare via web la prontezza dell'integrazione Google e della posta studio. La lettura automatica caselle non e ancora attiva come daemon applicativo, ma puoi validare configurazione OAuth e collegamento account senza entrare in shell.
</div>
<div class="space-y-6">
{{-- Google OAuth & General Studio Actions --}}
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm space-y-3">
<div class="flex items-center justify-between">
<div class="font-bold text-sm text-slate-900 flex items-center gap-2">
<span>🌐</span>
<span>Integrazione Google Workspace & Posta Studio</span>
</div>
<span class="text-xs text-slate-500">Convalida rapida OAuth e sincronizzazione IMAP</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<x-filament::button type="button" color="success" wire:click="connectGoogle">Collega Google</x-filament::button>
<x-filament::button type="button" color="info" wire:click="runGoogleOAuthReadinessCheck">Verifica Google OAuth</x-filament::button>
<x-filament::button type="button" color="danger" wire:click="disconnectGoogle">Scollega Google</x-filament::button>
<div class="text-xs text-slate-600 leading-relaxed">
Verifica via web la prontezza dell'integrazione Google e delle caselle IMAP/PEC dello studio. Le credenziali salvate sopra vengono utilizzate per scaricare le comunicazioni in formato canonico <code class="bg-slate-100 px-1 py-0.5 rounded text-indigo-600 font-mono">.EML</code>.
</div>
<div class="flex flex-wrap items-center gap-2 pt-1">
<x-filament::button type="button" color="success" wire:click="connectGoogle">
<span class="flex items-center gap-1.5">
<span>🔑</span>
<span>Collega Account Google</span>
</span>
</x-filament::button>
<x-filament::button type="button" color="info" wire:click="runGoogleOAuthReadinessCheck">
<span class="flex items-center gap-1.5">
<span>🔍</span>
<span>Verifica Google OAuth</span>
</span>
</x-filament::button>
<x-filament::button type="button" color="danger" wire:click="disconnectGoogle">
<span class="flex items-center gap-1.5">
<span></span>
<span>Scollega Google</span>
</span>
</x-filament::button>
<a
href="{{ url('/admin-filament/comunicazioni/posta-ordinaria?stabile_id=studio') }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 text-blue-700 hover:bg-blue-100 border border-blue-200 text-xs font-semibold rounded-lg shadow-xs transition"
>
<span>✉️</span>
<span>Webmail Ordinaria Studio</span>
</a>
<a
href="{{ url('/admin-filament/comunicazioni/posta-pec?stabile_id=studio') }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 text-xs font-semibold rounded-lg shadow-xs transition"
>
<span>🛡️</span>
<span>Webmail PEC Studio</span>
</a>
</div>
</div>
@if(filled($this->opsLastOutput))
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Ultimo output verifica posta / Google</div>
<pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs text-gray-800">{{ $this->opsLastOutput }}</pre>
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
<div class="mb-2 text-xs font-bold text-slate-700 uppercase tracking-wider">Ultimo output verifica posta / Google</div>
<pre class="max-h-64 overflow-auto whitespace-pre-wrap text-xs font-mono text-slate-800 bg-white p-3 rounded-xl border border-slate-200">{{ $this->opsLastOutput }}</pre>
</div>
@endif
{{-- Stabili Mailboxes Overview Table (All 16 Stabili) --}}
@php
$stabiliStatus = $this->stabiliMailboxStatus;
@endphp
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden space-y-0">
<div class="border-b border-slate-200 bg-slate-50/80 px-4 py-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div class="flex items-center gap-2.5">
<span class="text-lg">📬</span>
<div>
<div class="font-bold text-sm text-slate-900">Stato Posta e PEC degli Stabili Gestiti</div>
<div class="text-xs text-slate-500">Quadro di configurazione e monitoraggio messaggi per tutti i {{ count($stabiliStatus) }} stabili</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="rounded-full bg-indigo-50 border border-indigo-200 px-3 py-1 text-xs font-bold text-indigo-800">
{{ count($stabiliStatus) }} Stabili Attivi
</span>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead class="bg-slate-100/70 text-slate-700 font-semibold border-b border-slate-200">
<tr>
<th class="px-4 py-3">Stabile</th>
<th class="px-4 py-3">Codice Fiscale</th>
<th class="px-4 py-3">Posta Ordinaria (IMAP)</th>
<th class="px-4 py-3">Posta Certificata (PEC)</th>
<th class="px-4 py-3 text-center">Messaggi</th>
<th class="px-4 py-3 text-right">Azioni Rapide</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse($stabiliStatus as $stb)
<tr class="hover:bg-slate-50/60 transition">
<td class="px-4 py-3">
<div class="font-bold text-slate-900 text-sm flex items-center gap-1.5">
<span class="px-1.5 py-0.5 rounded bg-slate-200 text-slate-800 font-mono text-[11px]">{{ $stb['codice_stabile'] }}</span>
<span>{{ $stb['denominazione'] }}</span>
</div>
<div class="text-[11px] text-slate-500 mt-0.5">{{ $stb['comune'] ?: '—' }}</div>
</td>
<td class="px-4 py-3 font-mono text-[11px] text-slate-700">
{{ $stb['codice_fiscale'] ?: '—' }}
</td>
<td class="px-4 py-3">
@if($stb['email_configured'])
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-800 font-semibold text-[11px]">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
<span>Configurata</span>
</div>
@if($stb['email_info'])
<div class="text-[10px] text-slate-400 font-mono truncate max-w-xs mt-1" title="{{ $stb['email_info'] }}">
{{ $stb['email_info'] }}
</div>
@endif
@else
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-slate-100 border border-slate-200 text-slate-500 font-medium text-[11px]">
<span class="w-2 h-2 rounded-full bg-slate-400"></span>
<span>Non configurata</span>
</span>
@endif
</td>
<td class="px-4 py-3">
@if($stb['pec_configured'])
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-800 font-semibold text-[11px]">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
<span>Configurata</span>
</div>
@if($stb['pec_info'])
<div class="text-[10px] text-slate-400 font-mono truncate max-w-xs mt-1" title="{{ $stb['pec_info'] }}">
{{ $stb['pec_info'] }}
</div>
@endif
@else
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-slate-100 border border-slate-200 text-slate-500 font-medium text-[11px]">
<span class="w-2 h-2 rounded-full bg-slate-400"></span>
<span>Non configurata</span>
</span>
@endif
</td>
<td class="px-4 py-3 text-center">
@if($stb['msg_count'] > 0)
<span class="px-2 py-0.5 rounded-full bg-blue-100 text-blue-800 font-bold font-mono text-xs">
{{ $stb['msg_count'] }}
</span>
@else
<span class="text-slate-400 font-mono text-xs">0</span>
@endif
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-1.5">
<a
href="{{ $stb['config_url'] }}"
class="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-200 transition"
title="Configura casella IMAP/PEC dello stabile"
>
<x-heroicon-m-cog-6-tooth class="w-3.5 h-3.5 text-slate-500" />
<span>Configura</span>
</a>
<a
href="{{ $stb['webmail_email_url'] }}"
class="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold rounded-lg bg-blue-50 hover:bg-blue-100 text-blue-700 border border-blue-200 transition"
title="Apri Webmail Posta Ordinaria"
>
<span>✉️</span>
<span>Posta</span>
</a>
<a
href="{{ $stb['webmail_pec_url'] }}"
class="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold rounded-lg bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border border-emerald-200 transition"
title="Apri Webmail PEC"
>
<span>🛡️</span>
<span>PEC</span>
</a>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-8 text-center text-slate-400">
Nessuno stabile accessibile censito.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>

View File

@ -39,6 +39,15 @@ class="w-full py-2 px-3 text-xs rounded-xl bg-gray-50 dark:bg-gray-900 border bo
</div>
<div class="flex items-center gap-2 justify-end">
<a
href="{{ $this->configMailboxUrl }}"
class="inline-flex items-center gap-1.5 px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 text-xs font-semibold rounded-xl shadow-xs transition"
title="Configura parametri IMAP e credenziali della casella"
>
<x-heroicon-m-cog-6-tooth class="w-4 h-4 text-gray-500 dark:text-gray-300" />
<span>Configura IMAP</span>
</a>
<button
wire:click="syncNow"
wire:loading.attr="disabled"
@ -427,12 +436,28 @@ class="text-gray-400 hover:text-amber-500 transition"
</div>
</div>
@empty
<div class="p-12 text-center text-gray-500 dark:text-gray-400 space-y-2">
<div class="p-12 text-center text-gray-500 dark:text-gray-400 space-y-3">
<x-heroicon-o-envelope-open class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600" />
<div class="font-medium text-base">Nessun messaggio presente in questa cartella</div>
<p class="text-xs text-gray-400 max-w-sm mx-auto">
Usa il pulsante "Sincronizza IMAP (.EML)" in alto per scaricare la posta dalle caselle ufficiali degli stabili.
<div class="font-medium text-base text-gray-800 dark:text-gray-200">Nessun messaggio presente in questa cartella</div>
<p class="text-xs text-gray-400 max-w-md mx-auto">
Se non hai ancora configurato la casella IMAP o PEC, puoi inserire parametri e credenziali per abilitare lo scaricamento e la visualizzazione automatica.
</p>
<div class="flex flex-wrap items-center justify-center gap-2 pt-2">
<a
href="{{ $this->configMailboxUrl }}"
class="inline-flex items-center gap-1.5 px-3.5 py-2 bg-primary-600 hover:bg-primary-700 text-white text-xs font-semibold rounded-xl shadow-sm transition"
>
<x-heroicon-m-cog-6-tooth class="w-4 h-4" />
<span>Configura Casella Stabile</span>
</a>
<a
href="{{ url('/admin-filament/impostazioni/scheda-amministratore?tab=posta-operativita') }}"
class="inline-flex items-center gap-1.5 px-3.5 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 text-xs font-semibold rounded-xl shadow-xs transition"
>
<x-heroicon-m-building-office-2 class="w-4 h-4" />
<span>Configura Caselle Studio</span>
</a>
</div>
</div>
@endforelse
</div>

View File

@ -1,132 +1,288 @@
<x-filament-panels::page>
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center gap-2">
<a href="{{ $this->getTicketInserimentoUrl() }}" class="inline-flex items-center rounded-md bg-indigo-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-600">Vai a Inserimento Ticket</a>
<div class="space-y-6">
{{-- KPI Summary Stats Header --}}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="rounded-2xl border border-blue-100 dark:border-blue-900/50 bg-gradient-to-br from-blue-50/80 to-white dark:from-blue-950/40 dark:to-gray-800 p-4 shadow-xs">
<div class="flex items-center justify-between text-blue-600 dark:text-blue-400">
<span class="text-xs font-bold uppercase tracking-wider">Aperti</span>
<span class="p-1.5 rounded-xl bg-blue-100/80 dark:bg-blue-900/50">🎟️</span>
</div>
<div class="mt-2 text-2xl font-bold text-gray-900 dark:text-white font-mono">{{ $ticketCounters['open'] ?? 0 }}</div>
<div class="mt-1 text-[11px] text-gray-500">Ticket in attesa o assegnati</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Aperti: <span class="font-semibold">{{ $ticketCounters['open'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Urgenti: <span class="font-semibold">{{ $ticketCounters['urgent'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Chiusi: <span class="font-semibold">{{ $ticketCounters['closed'] ?? 0 }}</span></div>
<div class="rounded-lg border bg-gray-50 px-3 py-2 text-xs">Totali: <span class="font-semibold">{{ $ticketCounters['all'] ?? 0 }}</span></div>
<div class="rounded-2xl border border-rose-100 dark:border-rose-900/50 bg-gradient-to-br from-rose-50/80 to-white dark:from-rose-950/40 dark:to-gray-800 p-4 shadow-xs">
<div class="flex items-center justify-between text-rose-600 dark:text-rose-400">
<span class="text-xs font-bold uppercase tracking-wider">Urgenti</span>
<span class="p-1.5 rounded-xl bg-rose-100/80 dark:bg-rose-900/50">🚨</span>
</div>
<div class="mt-2 text-2xl font-bold text-rose-600 dark:text-rose-400 font-mono">{{ $ticketCounters['urgent'] ?? 0 }}</div>
<div class="mt-1 text-[11px] text-gray-500">Priorità alta o emergenza</div>
</div>
<div class="rounded-2xl border border-emerald-100 dark:border-emerald-900/50 bg-gradient-to-br from-emerald-50/80 to-white dark:from-emerald-950/40 dark:to-gray-800 p-4 shadow-xs">
<div class="flex items-center justify-between text-emerald-600 dark:text-emerald-400">
<span class="text-xs font-bold uppercase tracking-wider">Chiusi</span>
<span class="p-1.5 rounded-xl bg-emerald-100/80 dark:bg-emerald-900/50"></span>
</div>
<div class="mt-2 text-2xl font-bold text-gray-900 dark:text-white font-mono">{{ $ticketCounters['closed'] ?? 0 }}</div>
<div class="mt-1 text-[11px] text-gray-500">Interventi completati e risolti</div>
</div>
<div class="rounded-2xl border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-xs">
<div class="flex items-center justify-between text-slate-600 dark:text-gray-400">
<span class="text-xs font-bold uppercase tracking-wider">Totali</span>
<span class="p-1.5 rounded-xl bg-slate-100 dark:bg-gray-700">📊</span>
</div>
<div class="mt-2 text-2xl font-bold text-gray-900 dark:text-white font-mono">{{ $ticketCounters['all'] ?? 0 }}</div>
<div class="mt-1 text-[11px] text-gray-500">Volume storico complessivo</div>
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<button type="button" wire:click="apriElenco" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'elenco' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}">
Tab 1 - Elenco (stile excel)
{{-- Main Content Container --}}
<div class="rounded-2xl border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm space-y-4">
{{-- Navigation Bar & Filter --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-gray-100 dark:border-gray-700">
<div class="flex flex-wrap items-center gap-1.5 bg-gray-100 dark:bg-gray-900/60 p-1 rounded-xl">
<button
type="button"
wire:click="apriElenco"
class="inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition {{ $activeTab === 'elenco' ? 'bg-white dark:bg-gray-800 text-primary-700 dark:text-primary-300 shadow-xs' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' }}"
>
<span>📋</span>
<span>Elenco Ticket</span>
</button>
<button type="button" wire:click="$set('activeTab', 'scheda')" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'scheda' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}">
Tab 2 - Scheda ticket
<button
type="button"
wire:click="$set('activeTab', 'scheda')"
class="inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition {{ $activeTab === 'scheda' ? 'bg-white dark:bg-gray-800 text-primary-700 dark:text-primary-300 shadow-xs' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' }}"
>
<span>📑</span>
<span>Scheda Dettaglio</span>
</button>
<button type="button" wire:click="$set('activeTab', 'fornitori')" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'fornitori' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}">
Tab 3 - Fornitori attivi
<button
type="button"
wire:click="$set('activeTab', 'fornitori')"
class="inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition {{ $activeTab === 'fornitori' ? 'bg-white dark:bg-gray-800 text-primary-700 dark:text-primary-300 shadow-xs' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' }}"
>
<span>👷</span>
<span>Fornitori Attivi</span>
</button>
<button type="button" wire:click="apriAssicurazioneTicket" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'assicurazione' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}">
Tab 4 - Assicurazioni
<button
type="button"
wire:click="apriAssicurazioneTicket"
class="inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition {{ $activeTab === 'assicurazione' ? 'bg-white dark:bg-gray-800 text-primary-700 dark:text-primary-300 shadow-xs' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' }}"
>
<span>🛡️</span>
<span>Sinistri & Assicurazioni</span>
</button>
</div>
@if($activeTab === 'elenco')
<select wire:model.live="status" class="rounded-lg border-gray-300 text-sm">
<option value="open">Aperti</option>
<option value="urgent">Urgenti</option>
<option value="closed">Chiusi</option>
<option value="all">Tutti</option>
</select>
@endif
<div class="flex items-center gap-2">
@if($activeTab === 'elenco')
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 font-medium">Stato:</span>
<select wire:model.live="status" class="py-1.5 px-3 text-xs font-semibold rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-gray-800 dark:text-gray-200">
<option value="open">Aperti (Tutti)</option>
<option value="urgent">Solo Urgenti</option>
<option value="closed">Chiusi</option>
<option value="all">Tutti gli stati</option>
</select>
</div>
@endif
<a
href="{{ $this->getTicketInserimentoUrl() }}"
class="inline-flex items-center gap-1.5 rounded-xl bg-primary-600 hover:bg-primary-700 px-3.5 py-1.5 text-xs font-semibold text-white shadow-xs transition"
>
<x-heroicon-m-plus class="w-4 h-4" />
<span>Nuovo Ticket</span>
</a>
</div>
</div>
@if($activeTab === 'elenco')
<div class="mt-3 rounded-lg border bg-slate-50 p-3">
<div class="mb-2 text-xs font-semibold text-slate-700">Categorie ticket</div>
<div class="grid gap-2 md:grid-cols-3">
<label class="block text-xs">
<span class="mb-1 block font-medium">Categoria esistente</span>
<select wire:model.live="selectedCategoriaId" class="w-full rounded-lg border-gray-300 text-sm">
<option value="">Seleziona categoria</option>
{{-- Categories Quick Management Accordion/Panel --}}
<div class="rounded-xl border border-slate-200 dark:border-gray-700 bg-slate-50/60 dark:bg-gray-900/30 p-3 space-y-2">
<div class="flex items-center justify-between">
<div class="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-gray-300 flex items-center gap-1.5">
<span>🏷️</span>
<span>Gestione Categorie Ticket</span>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-3 items-end">
<div>
<label class="block text-[11px] font-semibold text-gray-600 dark:text-gray-400 mb-1">Categoria Esistente</label>
<select wire:model.live="selectedCategoriaId" class="w-full py-1.5 px-2.5 rounded-lg border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-xs text-gray-800 dark:text-gray-200">
<option value="">-- Seleziona per modificare --</option>
@foreach($categorieOptions as $cat)
<option value="{{ (int) $cat['id'] }}">{{ $cat['nome'] }}</option>
@endforeach
</select>
</label>
<label class="block text-xs">
<span class="mb-1 block font-medium">Nome categoria</span>
<input type="text" wire:model.defer="categoriaNome" class="w-full rounded-lg border-gray-300 text-sm" placeholder="Es. Ascensore" />
</label>
<label class="block text-xs">
<span class="mb-1 block font-medium">Descrizione</span>
<input type="text" wire:model.defer="categoriaDescrizione" class="w-full rounded-lg border-gray-300 text-sm" placeholder="Descrizione breve" />
</label>
</div>
<div>
<label class="block text-[11px] font-semibold text-gray-600 dark:text-gray-400 mb-1">Nome Categoria</label>
<input type="text" wire:model.defer="categoriaNome" class="w-full py-1.5 px-2.5 rounded-lg border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-xs text-gray-800 dark:text-gray-200" placeholder="Es. Ascensore, Autoclave..." />
</div>
<div>
<label class="block text-[11px] font-semibold text-gray-600 dark:text-gray-400 mb-1">Descrizione</label>
<input type="text" wire:model.defer="categoriaDescrizione" class="w-full py-1.5 px-2.5 rounded-lg border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-xs text-gray-800 dark:text-gray-200" placeholder="Breve descrizione opzionale" />
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-filament::button size="sm" color="success" wire:click="creaCategoriaTicket">Aggiungi categoria</x-filament::button>
<x-filament::button size="sm" color="warning" wire:click="aggiornaCategoriaTicket">Modifica categoria selezionata</x-filament::button>
<div class="flex items-center gap-2 pt-1">
<button type="button" wire:click="creaCategoriaTicket" class="px-3 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-semibold shadow-xs transition">
+ Aggiungi Categoria
</button>
@if($selectedCategoriaId)
<button type="button" wire:click="aggiornaCategoriaTicket" class="px-3 py-1 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-xs font-semibold shadow-xs transition">
Modifica Selezionata
</button>
@endif
</div>
</div>
<div class="mt-3 overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-2 text-left">ID</th>
<th class="border px-2 py-2 text-left">Titolo</th>
<th class="border px-2 py-2 text-left">Categoria</th>
<th class="border px-2 py-2 text-left">Tipo intervento</th>
<th class="border px-2 py-2 text-left">Assegnato a</th>
<th class="border px-2 py-2 text-left">Priorita</th>
<th class="border px-2 py-2 text-left">Stato</th>
<th class="border px-2 py-2 text-left">Apertura</th>
<th class="border px-2 py-2 text-left">Azioni</th>
{{-- Table View --}}
<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-gray-700">
<table class="w-full text-left text-xs">
<thead class="bg-slate-100/80 dark:bg-gray-900 text-slate-700 dark:text-gray-300 font-semibold border-b border-slate-200 dark:border-gray-700">
<tr>
<th class="px-3 py-2.5">ID</th>
<th class="px-3 py-2.5">Titolo & Allegati</th>
<th class="px-3 py-2.5">Categoria</th>
<th class="px-3 py-2.5">Assegnato a</th>
<th class="px-3 py-2.5 text-center">Priorità</th>
<th class="px-3 py-2.5 text-center">Stato</th>
<th class="px-3 py-2.5">Data Apertura</th>
<th class="px-3 py-2.5 text-right">Azioni</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-slate-100 dark:divide-gray-700">
@forelse($tickets as $ticket)
<tr id="ticket-{{ (int) $ticket->id }}" class="hover:bg-slate-50">
<td class="border px-2 py-2">#{{ (int) $ticket->id }}</td>
<td class="border px-2 py-2">
<div class="flex items-center gap-1">
@php
$prio = strtolower((string) $ticket->priorita);
$prioBadge = match($prio) {
'urgente', 'emergenza' => 'bg-rose-100 text-rose-800 dark:bg-rose-950/60 dark:text-rose-300 border-rose-200',
'alta' => 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 border-amber-200',
'media' => 'bg-sky-100 text-sky-800 dark:bg-sky-950/60 dark:text-sky-300 border-sky-200',
default => 'bg-slate-100 text-slate-700 dark:bg-gray-800 dark:text-gray-300 border-slate-200',
};
$stato = strtolower((string) $ticket->stato);
$statoBadge = match($stato) {
'aperto' => 'bg-blue-100 text-blue-800 dark:bg-blue-950/60 dark:text-blue-300 border-blue-200',
'preso in carico', 'in lavorazione' => 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300 border-amber-200',
'risolto', 'completato' => 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300 border-emerald-200',
'chiuso' => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400 border-gray-300',
default => 'bg-slate-100 text-slate-800 border-slate-200',
};
@endphp
<tr id="ticket-{{ (int) $ticket->id }}" class="hover:bg-slate-50/60 dark:hover:bg-gray-700/40 transition">
<td class="px-3 py-2.5 font-mono font-bold text-slate-900 dark:text-gray-100">
#{{ (int) $ticket->id }}
</td>
<td class="px-3 py-2.5">
<div class="font-semibold text-slate-900 dark:text-gray-100 flex items-center gap-1.5">
<span>{{ $ticket->titolo }}</span>
@if(((int) ($ticket->attachments_count ?? 0)) > 0)
<span title="Presenza allegati" class="inline-flex items-center rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-800">
Allegati {{ (int) $ticket->attachments_count }}
<span class="inline-flex items-center gap-0.5 rounded-full bg-purple-50 text-purple-700 dark:bg-purple-950/60 dark:text-purple-300 border border-purple-200 px-1.5 py-0.2 text-[10px] font-bold" title="Presenza allegati">
📎 {{ (int) $ticket->attachments_count }}
</span>
@endif
</div>
@if($ticket->stabile)
<div class="text-[10px] text-slate-500 font-mono mt-0.5">
{{ $ticket->stabile->codice_stabile }} - {{ $ticket->stabile->denominazione }}
</div>
@endif
</td>
<td class="border px-2 py-2">{{ optional($ticket->categoriaTicket)->nome ?: '-' }}</td>
<td class="border px-2 py-2">{{ $this->getTipoInterventoLabel($ticket) }}</td>
<td class="border px-2 py-2">{{ optional($ticket->assegnatoAUser)->name ?: '-' }}</td>
<td class="border px-2 py-2">{{ $ticket->priorita }}</td>
<td class="border px-2 py-2">{{ $ticket->stato }}</td>
<td class="border px-2 py-2">{{ optional($ticket->data_apertura)->format('d/m/Y H:i') }}</td>
<td class="border px-2 py-2">
<div class="flex flex-wrap items-center gap-1">
<button type="button" wire:click="apriScheda({{ (int) $ticket->id }})" class="inline-flex items-center rounded-md bg-slate-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-slate-600">Scheda</button>
@if((int) ($ticket->assegnato_a_fornitore_id ?? 0) > 0)
<button type="button" wire:click="apriScheda({{ (int) $ticket->id }})" class="inline-flex items-center rounded-md bg-fuchsia-700 px-2 py-1 text-[11px] font-medium text-white hover:bg-fuchsia-600">Assegnata</button>
@endif
<td class="px-3 py-2.5 text-slate-700 dark:text-gray-300">
{{ optional($ticket->categoriaTicket)->nome ?: '—' }}
</td>
<td class="px-3 py-2.5 text-slate-700 dark:text-gray-300">
{{ optional($ticket->assegnatoAUser)->name ?: (optional($ticket->assegnatoAFornitore)->ragione_sociale ?: '—') }}
</td>
<td class="px-3 py-2.5 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border {{ $prioBadge }}">
{{ $ticket->priorita }}
</span>
</td>
<td class="px-3 py-2.5 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border {{ $statoBadge }}">
{{ $ticket->stato }}
</span>
</td>
<td class="px-3 py-2.5 text-slate-600 dark:text-gray-400 whitespace-nowrap">
{{ optional($ticket->data_apertura)->format('d/m/Y H:i') ?: '—' }}
</td>
<td class="px-3 py-2.5 text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-1">
<button
type="button"
wire:click="apriScheda({{ (int) $ticket->id }})"
class="px-2 py-1 text-[11px] font-semibold rounded-lg bg-slate-800 hover:bg-slate-700 text-white transition"
>
Scheda
</button>
@if(in_array($ticket->stato, ['Aperto'], true))
<button type="button" wire:click="prendiInCarico({{ (int) $ticket->id }})" class="inline-flex items-center rounded-md bg-blue-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-blue-500">Carico</button>
<button
type="button"
wire:click="prendiInCarico({{ (int) $ticket->id }})"
class="px-2 py-1 text-[11px] font-semibold rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition"
>
Carico
</button>
@endif
@if(in_array($ticket->stato, ['Aperto', 'Preso in Carico'], true))
<button type="button" wire:click="avviaLavorazione({{ (int) $ticket->id }})" class="inline-flex items-center rounded-md bg-amber-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-amber-500">Lavora</button>
<button
type="button"
wire:click="avviaLavorazione({{ (int) $ticket->id }})"
class="px-2 py-1 text-[11px] font-semibold rounded-lg bg-amber-600 hover:bg-amber-700 text-white transition"
>
Lavora
</button>
@endif
@if(in_array($ticket->stato, ['Aperto', 'Preso in Carico', 'In Lavorazione', 'In Attesa Approvazione', 'In Attesa Ricambi'], true))
<button type="button" wire:click="risolviTicket({{ (int) $ticket->id }})" class="inline-flex items-center rounded-md bg-emerald-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-emerald-500">Risolto</button>
<button
type="button"
wire:click="risolviTicket({{ (int) $ticket->id }})"
class="px-2 py-1 text-[11px] font-semibold rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white transition"
>
Risolvi
</button>
@endif
@if(in_array($ticket->stato, ['Risolto'], true))
<button type="button" wire:click="chiudiTicket({{ (int) $ticket->id }})" class="inline-flex items-center rounded-md bg-gray-800 px-2 py-1 text-[11px] font-medium text-white hover:bg-gray-700">Chiudi</button>
<button
type="button"
wire:click="chiudiTicket({{ (int) $ticket->id }})"
class="px-2 py-1 text-[11px] font-semibold rounded-lg bg-gray-700 hover:bg-gray-600 text-white transition"
>
Chiudi
</button>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="9" class="border px-2 py-4 text-center text-gray-500">Nessun ticket per il filtro selezionato.</td>
<td colspan="8" class="px-4 py-8 text-center text-slate-400">
Nessun ticket presente per il filtro selezionato.
</td>
</tr>
@endforelse
</tbody>

View File

@ -1119,17 +1119,31 @@ class="inline-flex items-center rounded-lg border px-4 py-2 text-xs font-semibol
<td class="px-4 py-2.5 text-right font-mono font-bold {{ ($prot['importo'] ?? 0) > 0 ? 'text-slate-900' : 'text-slate-400' }}">
{{ $prot['importo'] !== null ? '€ ' . number_format($prot['importo'], 2, ',', '.') : '—' }}
</td>
<td class="px-4 py-2.5 text-center whitespace-nowrap">
@if(!empty($prot['nome_file']))
<button
type="button"
wire:click="previewProtocolDoc('{{ $prot['id'] }}', '{{ addslashes($prot['oggetto'] ?? 'Documento') }}', '{{ addslashes($prot['file_path'] ?? '') }}')"
class="inline-flex items-center gap-1 rounded bg-slate-100 hover:bg-indigo-100 hover:text-indigo-800 border border-slate-200 px-2 py-0.5 text-[11px] font-mono text-slate-700 transition cursor-pointer"
title="Visualizza anteprima documento"
>
<span>📄</span>
<span class="underline">{{ $prot['nome_file'] }}</span>
</button>
<td class="px-4 py-2.5 text-center">
@php
$allegatiList = !empty($prot['allegati']) ? $prot['allegati'] : (!empty($prot['nome_file']) ? [['filename' => $prot['nome_file'], 'path' => $prot['file_path'], 'exists' => $prot['file_exists'], 'is_pdf' => str_ends_with(strtolower($prot['nome_file']), '.pdf'), 'is_image' => preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $prot['nome_file'])]] : []);
@endphp
@if(!empty($allegatiList))
<div class="flex flex-wrap items-center justify-center gap-1.5 max-w-xs mx-auto">
@foreach($allegatiList as $att)
@php
$attName = $att['filename'] ?? 'file';
$attPath = $att['path'] ?? '';
$attExists = $att['exists'] ?? false;
$isPdf = $att['is_pdf'] ?? str_ends_with(strtolower($attName), '.pdf');
$isImg = $att['is_image'] ?? preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $attName);
@endphp
<button
type="button"
wire:click="previewProtocolDoc('{{ $prot['id'] }}', '{{ addslashes($attName) }}', '{{ addslashes($attPath) }}')"
class="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-mono transition shadow-2xs {{ $attExists ? 'bg-slate-100 hover:bg-indigo-100 hover:text-indigo-800 text-slate-700 border border-slate-200 cursor-pointer' : 'bg-slate-50 text-slate-400 border border-dashed border-slate-200 cursor-pointer' }}"
title="{{ $attExists ? 'Visualizza anteprima (' . $attName . ')' : 'File non presente localmente (' . $attName . ')' }}"
>
<span>{{ $isPdf ? '📄' : ($isImg ? '🖼️' : '📎') }}</span>
<span class="truncate max-w-[120px] {{ $attExists ? 'underline font-medium' : 'line-through' }}">{{ $attName }}</span>
</button>
@endforeach
</div>
@else
<span class="text-slate-400"></span>
@endif

View File

@ -6,30 +6,22 @@ # CURRENT-205
## Obiettivo Completato
1. **Gestione Fornitore Nethome SAS (`10055221005`) & Collegamento Compensi Stabili**:
- Fornitore ID 236 (`NETHOME sas di BARONE M. & C.`, P.IVA / CF `10055221005`) censito e collegato trasversalmente a tutti i 16 stabili in `fornitore_stabile_impostazioni`.
- Verificate le 5 fatture elettroniche già presenti a sistema con Nethome come fornitore/cedente.
1. **Gestione Posta Studio & 16 Stabili (IMAP, Webmail, Scheda Amministratore)**:
- Sviluppato supporto per caselle di posta e PEC dello **Studio Amministratore** (`stabile_id = null`, storage in `storage/app/private/amministratori/{cod_adm}/studio/posta_{ordinaria|pec}/{anno}/`).
- Webmail Posta Ordinaria e PEC dotate di filtro rapido per Stabile o Studio, pulsante per configurazione diretta IMAP e gestione stati cartelle vuote.
- Nella Scheda Amministratore (Tab Posta / API): implementata la tabella di monitoraggio live di tutti i 16 stabili con conteggi messaggi `.eml`, stato configurazione IMAP/PEC, link diretti alla configurazione e pulsanti di test/sincronizzazione per le caselle di studio.
2. **Codice Mnemonico `cod_stabile` da `Stabili.mdb`**:
- Riallineati e popolati i codici mnemonici su tutti i 16 stabili target (`0002` -> `908`, `0008` -> `909`, `0009` -> `912`, `0010` -> `13`, `0011` -> `910`, `0012` -> `920`, `0013` -> `17`, `0016` -> `145`, `0017` -> `907`, `0018` -> `18`, `0019` -> `146`, `0021` -> `148`, `0022` -> `928`, `0023` -> `8`, `0024` -> `147`, `0025` -> `223`).
- Visibile e ricercabile nella tabella `http://192.168.0.205:8000/admin-filament/gescon/anagrafica/stabili`.
2. **Risoluzione Multi-Allegato & Timeline Protocollo (`ArchiveFileResolver`)**:
- Creato il resolver euristico `ArchiveFileResolver` per individuare file legacy e moderni tra radici storage e cartelle (`E_C/`, `INC_EC/`, `WA/`, `XML/`, `allegati/`, `posta/`, `documenti/`).
- Aggiornata la scheda Unità Immobiliare (Tab Comunicazioni & Protocollo) con supporto a `posta_dett`, `corrisp_inviata`, `protoc_ec` e `communication_messages`, con rendering di chip allegati dedicati con icone, badge di presenza ed anteprima modale istantanea.
3. **Integrazione IMAP e Archiviazione Locale `.EML` (`php artisan gescon:fetch-mail`)**:
- Sviluppato `ImapClient` in puro PHP SSL/TLS su porta 993 (zero dipendenze binarie libc-client) con supporto comandi IMAP standard (LOGIN, SELECT, SEARCH, FETCH BODY).
- Sviluppato `EmlParser` per parsing MIME conforme RFC 822/2822/5322 con decodifica multipart, body HTML/testo e salvataggio allegati.
- Salvataggio automatico del file grezzo `.eml` in `storage/app/private/amministratori/{cod_adm}/stabili/{cod_stabile}/posta_{ordinaria|pec}/{anno}/`.
- Creazione del record in `communication_messages` e auto-matching dell'Unità Immobiliare per email del mittente.
- Supporto configurazione e test connessione IMAP direttamente dalla scheda stabile (`/admin-filament/condomini/stabile?tab=posta-ufficiale`).
3. **Modernizzazione Ticket Gestione & Hub Documentale**:
- `TicketGestione`: KPI statistiche in testata, selettore filtri pill-bar, gestione rapida categorie, badge priorità/stato, filtri per fornitore operativo e gestione pratiche assicurative.
- `DocumentiArchivio`: Hub documentale centralizzato digitale e fisico, con anteprime QR, gestione template Drive e codici mnemonici `DOC-XXXX` / `AF-X-XXXX`.
4. **Due Nuove Blade Webmail Stile Gmail (Posta Ordinaria e Posta PEC)**:
- `PostaOrdinariaWebmail` (`http://192.168.0.205:8000/admin-filament/comunicazioni/posta-ordinaria`)
- `PostaPecWebmail` (`http://192.168.0.205:8000/admin-filament/comunicazioni/posta-pec`)
- Layout responsive modellato fedelmente sullo screenshot Gmail: barra di ricerca in tempo reale con debounce, filtro per stabile singolo o aggregato, pillole allegati (PDF, immagini, documenti), contrassegno speciale ⭐, apertura 1-click di ticket condominiale, composizione modale ed anteprima allegati rapida.
5. **Scheda Unità (`unita-immobiliare`) - Tab Comunicazioni & Protocollo**:
- Aggregazione completa di corrispondenza storica (`protoc_ec`, `corrisp_inviata`) e messaggi email/PEC associati all'unità (`communication_messages`).
- Ordinamento cronologico rigorosamente decrescente basato su Unix timestamp per evitare anomalie di ordinamento lessicografico.
- Modal di anteprima visiva istantanea di PDF, immagini e documenti allegati cliccando sul nome del file.
4. **Fornitore Nethome SAS (`10055221005`) & Codici Stabili**:
- Censito e associato trasversalmente per compensi e fatturazione su tutti i 16 stabili.
- Codici mnemonici `cod_stabile` sincronizzati da `Stabili.mdb`.
## Output del Giro Operativo
@ -37,8 +29,9 @@ ## Output del Giro Operativo
TASK_ID: task-nethome-imap-webmail-protocollo
REPOSITORY: ssh://git@git.netgescon.it:2222/michele/netgescon-day0.git
BRANCH: stabilization/205-zero
COMMIT: d7f8346
COMMIT: 3265562
FILE_O_AREE_TOCCATE:
- app/Services/Documenti/ArchiveFileResolver.php
- app/Services/Posta/EmlParser.php
- app/Services/Posta/ImapClient.php
- app/Services/Posta/ImapMailboxService.php
@ -46,23 +39,24 @@ ## Output del Giro Operativo
- app/Filament/Pages/Posta/PostaOrdinariaWebmail.php
- app/Filament/Pages/Posta/PostaPecWebmail.php
- resources/views/filament/pages/posta/webmail.blade.php
- app/Filament/Pages/Impostazioni/SchedaAmministratore.php
- resources/views/filament/pages/impostazioni/partials/scheda-amministratore-posta-operativita.blade.php
- app/Filament/Pages/Condomini/StabilePage.php
- resources/views/filament/pages/condomini/tabs/posta-ufficiale.blade.php
- app/Filament/Pages/UnitaImmobiliarePage.php
- resources/views/filament/pages/unita-immobiliare.blade.php
- app/Models/PersonaUnitaRelazione.php
- skill-netgescon/directives/gestione-posta-imap-webmail-protocollo.md
- resources/views/filament/pages/supporto/ticket-gestione.blade.php
- tests/Feature/PostaImapWebmailAndProtocolloTest.php
- skill-netgescon/control-tower/CURRENT-205.md
TEST_ESEGUITI:
- ./vendor/bin/pest tests/Feature/PostaImapWebmailAndProtocolloTest.php tests/Feature/NominativiAndFeMatchingTest.php tests/Feature/ContabilitaRelazionaleOrdinarieTest.php tests/Feature/BpmBankParserAndImporterTest.php tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/CatastoHubDbDrivenTest.php tests/Feature/UnitaCondominoInquilinoRoleToggleTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/AnagraficaUnicaCanonicaTest.php tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/ControlTowerPollCommandTest.php (34 passed, 210 assertions)
- ./vendor/bin/pest tests/Feature/PostaImapWebmailAndProtocolloTest.php tests/Feature/NominativiAndFeMatchingTest.php tests/Feature/ContabilitaRelazionaleOrdinarieTest.php tests/Feature/BpmBankParserAndImporterTest.php tests/Feature/UnitaGestioneTemporaleTest.php tests/Feature/CatastoHubDbDrivenTest.php tests/Feature/UnitaCondominoInquilinoRoleToggleTest.php tests/Feature/UnitaImmobiliarePageTest.php tests/Feature/AnagraficaUnicaCanonicaTest.php tests/Feature/BenedettoBonificaIdempotenteTest.php tests/Feature/ControlTowerPollCommandTest.php (35 passed, 223 assertions)
GATE_STATISTICS:
- NETHOME_SAS_FORNITORE: Fornitore 236 configurato e collegato a tutti i 16 stabili.
- COD_STABILE_MNEMONICO: 16 stabili aggiornati con cod_stabile da Stabili.mdb.
- IMAP_EML_SYNC: Client socket puro PHP SSL/TLS + Parser RFC 822 + archiviazione .eml in storage per anno/stabile.
- GMAIL_WEBMAIL_UI: Due blade dedicate (Posta Ordinaria e PEC) conformi allo screenshot di riferimento.
- UNITA_PROTOCOLLO_MODAL: Ordinamento cronologico Unix decrescente + modal anteprima allegati/documenti.
- TEST_SUITE: 34 test Feature passati con successo (210 asserzioni, 100% pass).
- STUDIO_MAILBOX_INGESTION: Gestione caselle posta/PEC studio e storage locale dedicato.
- STABILI_MAILBOX_MONITORING: Monitoraggio tabellare dei 16 stabili nella Scheda Amministratore con link diretti.
- MULTI_ATTACHMENT_RESOLVER: `ArchiveFileResolver` operativo su 7 cartelle e storage.
- WEBMAIL_UI_QUICK_ACTIONS: Bottone Configura IMAP, badge e filtri responsive.
- TICKET_GESTIONE_MODERNIZATION: KPI cards, filtri di stato, gestione categorie e assegnazioni.
- TEST_SUITE: 35 test Feature passati con successo (223 asserzioni, 100% pass).
BLOCCO_DATI: no
BLOCCO_CONTRATTO: no
RISCHI_APERTI: nessuno
@ -70,5 +64,5 @@ ## Output del Giro Operativo
## Prossimo Passo per .200 (Validazione)
- Eseguire il checkout del branch `stabilization/205-zero`.
- Eseguire la suite di test Pest (34 passed, 210 assertions).
- Verificare la Webmail Posta Ordinaria su `http://192.168.0.205:8000/admin-filament/comunicazioni/posta-ordinaria`, la Webmail PEC su `http://192.168.0.205:8000/admin-filament/comunicazioni/posta-pec` e la Scheda Unità (tab Comunicazioni & Protocollo) su `http://192.168.0.205:8000/admin-filament/unita-immobiliare?tab=preferenze`.
- Eseguire la suite di test Pest (35 passed, 223 assertions).
- Verificare la Webmail Posta Ordinaria (`/admin-filament/comunicazioni/posta-ordinaria`), PEC (`/admin-filament/comunicazioni/posta-pec`), Scheda Amministratore (`/admin-filament/impostazioni/amministratore?tab=posta-api`), Ticket Gestione (`/admin-filament/supporto/ticket-gestione`) e Scheda Unità (tab Comunicazioni & Protocollo).

View File

@ -165,3 +165,44 @@
expect($stabile0016->fresh()->cod_stabile)->toBe('145');
}
});
it('ingests studio raw eml without stabile_id and resolves via ArchiveFileResolver', function () {
$user = User::factory()->create();
Role::firstOrCreate(['name' => 'amministratore', 'guard_name' => 'web']);
$user->assignRole('amministratore');
$adm = \App\Models\Amministratore::firstOrCreate(
['codice_amministratore' => 'ADMX3PD9'],
['user_id' => $user->id, 'nome' => 'Cecilia', 'cognome' => 'Tordini']
);
$service = app(ImapMailboxService::class);
$rawEml = "From: Studio Legale <avvocato@example.com>\r\n"
. "To: studio@amministrazione.it\r\n"
. "Subject: Notifica studio generale\r\n"
. "Date: Fri, 04 Sep 2026 16:00:00 +0200\r\n"
. "Message-ID: <studio-msg-777@example.com>\r\n"
. "MIME-Version: 1.0\r\n"
. "Content-Type: text/plain; charset=UTF-8\r\n"
. "\r\n"
. "Comunicazione indirizzata direttamente allo studio amministrativo.";
$res = $service->ingestStudioEmlString($rawEml, $adm, [
'tipo' => 'imap',
'folder' => 'INBOX',
'crea_ticket_automatico' => false,
], false);
expect($res['status'])->toBe('imported');
$msg = CommunicationMessage::where('external_message_id', 'studio-msg-777@example.com')->first();
expect($msg)->not->toBeNull();
expect($msg->stabile_id)->toBeNull();
expect($msg->metadata['is_studio'] ?? false)->toBeTrue();
// Test ArchiveFileResolver
$resolved = \App\Services\Documenti\ArchiveFileResolver::resolve('0016', 'sample_non_existing.pdf');
expect($resolved)->toHaveKeys(['filename', 'exists', 'path', 'size', 'mime', 'is_pdf', 'is_image']);
expect($resolved['filename'])->toBe('sample_non_existing.pdf');
expect($resolved['exists'])->toBeFalse();
});