feat: complete supplier filament operations and mail config

This commit is contained in:
michele 2026-03-17 14:12:19 +00:00
parent fe16ef481b
commit a6dbc5d696
28 changed files with 2701 additions and 1 deletions

View File

@ -0,0 +1,204 @@
<?php
namespace App\Filament\Pages\Condomini;
use App\Models\Stabile;
use App\Models\User;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class StabilePosta extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $title = 'Posta stabile';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-envelope';
protected static UnitEnum|string|null $navigationGroup = 'Stabile';
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'condomini/stabile-posta';
protected string $view = 'filament.pages.condomini.stabile-posta';
public ?array $data = [];
public ?Stabile $stabile = null;
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void
{
$user = Auth::user();
if (! $user instanceof User) {
abort(403);
}
$stabileId = request()->integer('stabile_id') ?: StabileContext::resolveActiveStabileId($user);
abort_unless($stabileId > 0, 404, 'Nessuno stabile attivo selezionato.');
$stabile = StabileContext::accessibleStabili($user)->firstWhere('id', $stabileId);
abort_unless($stabile instanceof Stabile, 404);
$this->stabile = $stabile;
$posta = (array) Arr::get($stabile->configurazione_avanzata ?? [], 'posta', []);
$this->getSchema('form')?->fill([
'pec_condominio' => (string) ($stabile->pec_condominio ?? ''),
'pec_amministratore' => (string) ($stabile->pec_amministratore ?? ''),
'posta' => array_merge([
'caselle' => [],
'acquisizione' => [
'salva_documenti' => true,
'salva_contabilita' => true,
'mittenti_assicurazione' => '',
'descrizione_default' => 'Documento acquisito da casella stabile',
],
], $posta),
]);
}
public function form(Schema $schema): Schema
{
return $schema
->statePath('data')
->components([
Section::make('PEC e recapiti stabili')
->columns(2)
->schema([
TextInput::make('pec_condominio')
->label('PEC condominio')
->email()
->maxLength(255),
TextInput::make('pec_amministratore')
->label('PEC amministratore per lo stabile')
->email()
->maxLength(255),
]),
Section::make('Caselle da leggere per questo stabile')
->description('Qui prepariamo Gmail, IMAP o PEC dello stabile. La lettura automatica importerà messaggi e allegati nei prossimi step.')
->schema([
Repeater::make('posta.caselle')
->label('Caselle collegate')
->default([])
->schema([
Toggle::make('enabled')
->label('Attiva')
->default(true),
Select::make('tipo')
->label('Tipo casella')
->options([
'gmail' => 'Gmail / Google Workspace',
'imap' => 'IMAP ordinaria',
'pec' => 'PEC via IMAP',
])
->default('imap')
->required(),
TextInput::make('label')
->label('Etichetta')
->required()
->maxLength(120),
TextInput::make('email')
->label('Email casella')
->email()
->maxLength(255),
TextInput::make('host')
->label('Host IMAP')
->maxLength(255),
TextInput::make('port')
->label('Porta')
->numeric(),
Select::make('encryption')
->label('Cifratura')
->options([
'ssl' => 'SSL',
'tls' => 'TLS',
'none' => 'Nessuna',
])
->default('ssl'),
TextInput::make('username')
->label('Username')
->maxLength(255),
TextInput::make('password')
->label('Password')
->password()
->revealable()
->maxLength(255),
TextInput::make('folder')
->label('Cartella da leggere')
->maxLength(120)
->default('INBOX'),
TextInput::make('gmail_query')
->label('Filtro Gmail / query')
->maxLength(255),
TextInput::make('mittenti_autorizzati')
->label('Mittenti ammessi')
->maxLength(500)
->helperText('Email separate da virgola; utile per assicurazioni o FE.'),
])
->columns(2)
->columnSpanFull(),
]),
Section::make('Acquisizione allegati')
->columns(2)
->schema([
Toggle::make('posta.acquisizione.salva_documenti')
->label('Archivia allegati nei documenti')
->default(true),
Toggle::make('posta.acquisizione.salva_contabilita')
->label('Rendi disponibili gli allegati per la contabilità')
->default(true),
TextInput::make('posta.acquisizione.mittenti_assicurazione')
->label('Mittenti assicurazione')
->maxLength(500)
->helperText('Elenco email separate da virgola per identificare i messaggi dellassicuratore.'),
TextInput::make('posta.acquisizione.descrizione_default')
->label('Descrizione default allegato')
->maxLength(255),
]),
]);
}
public function save(): void
{
abort_unless($this->stabile instanceof Stabile, 404);
$state = is_array($this->data ?? null) ? $this->data : [];
$config = (array) ($this->stabile->configurazione_avanzata ?? []);
$config['posta'] = is_array($state['posta'] ?? null) ? $state['posta'] : [];
$this->stabile->pec_condominio = trim((string) ($state['pec_condominio'] ?? '')) ?: null;
$this->stabile->pec_amministratore = trim((string) ($state['pec_amministratore'] ?? '')) ?: null;
$this->stabile->configurazione_avanzata = $config;
$this->stabile->save();
Notification::make()
->title('Configurazione posta stabile salvata')
->success()
->send();
}
}

View File

@ -0,0 +1,462 @@
<?php
namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\User;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use UnitEnum;
class Collaboratori extends Page
{
use ResolvesOperatoreContext;
protected static ?string $navigationLabel = 'Collaboratori';
protected static ?string $title = 'Collaboratori fornitore';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-users';
protected static UnitEnum|string|null $navigationGroup = 'Fornitore';
protected static ?int $navigationSort = 2;
protected static ?string $slug = 'fornitore/collaboratori';
protected string $view = 'filament.pages.fornitore.collaboratori';
public ?Fornitore $fornitore = null;
public bool $missingAdminContext = false;
public string $nome = '';
public string $cognome = '';
public string $email = '';
public string $telefono = '';
public string $collaboratoreTipo = FornitoreDipendente::TIPO_INTERNO;
public string $fornitoreSearch = '';
public ?int $fornitoreEsternoId = null;
public string $note = '';
public bool $createUserAccess = false;
public string $accessPassword = '';
public ?string $lastGeneratedPassword = null;
/** @var array<int, array<string, mixed>> */
public array $rows = [];
/** @var array<int, array<string, mixed>> */
public array $fornitoreOptions = [];
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']);
}
public function mount(): void
{
[$fornitore] = $this->resolveOperatoreContext(allowAdminWithoutSupplier: true);
if (! $fornitore instanceof Fornitore) {
$this->missingAdminContext = true;
return;
}
$this->fornitore = $fornitore;
$this->refreshFornitoreOptions();
$this->refreshRows();
}
public function updatedCollaboratoreTipo(): void
{
if ($this->collaboratoreTipo !== FornitoreDipendente::TIPO_FORNITORE_ESTERNO) {
$this->fornitoreEsternoId = null;
$this->fornitoreSearch = '';
$this->createUserAccess = false;
}
$this->refreshFornitoreOptions();
}
public function updatedFornitoreSearch(): void
{
$this->refreshFornitoreOptions();
}
public function createCollaboratore(): void
{
if (! $this->fornitore instanceof Fornitore) {
return;
}
$validated = $this->validate([
'collaboratoreTipo' => ['required', 'string', 'in:' . implode(',', [FornitoreDipendente::TIPO_INTERNO, FornitoreDipendente::TIPO_FORNITORE_ESTERNO])],
'nome' => ['nullable', 'string', 'max:100'],
'cognome' => ['nullable', 'string', 'max:100'],
'email' => ['nullable', 'email', 'max:255'],
'telefono' => ['nullable', 'string', 'max:50'],
'note' => ['nullable', 'string', 'max:2000'],
'createUserAccess' => ['nullable', 'boolean'],
'accessPassword' => ['nullable', 'string', 'min:8', 'max:100'],
'fornitoreEsternoId' => ['nullable', 'integer'],
]);
$tipo = (string) $validated['collaboratoreTipo'];
if ($tipo === FornitoreDipendente::TIPO_INTERNO && trim((string) ($validated['nome'] ?? '')) === '') {
Notification::make()->title('Inserisci il nome del collaboratore')->warning()->send();
return;
}
$fornitoreEsterno = null;
if ($tipo === FornitoreDipendente::TIPO_FORNITORE_ESTERNO) {
$fornitoreEsterno = $this->resolveFornitoreEsterno((int) ($validated['fornitoreEsternoId'] ?? 0));
if (! $fornitoreEsterno instanceof Fornitore) {
Notification::make()->title('Seleziona un altro fornitore come collaboratore')->warning()->send();
return;
}
if ((int) $fornitoreEsterno->id === (int) $this->fornitore->id) {
Notification::make()->title('Il fornitore principale non puo essere aggiunto come subfornitore')->warning()->send();
return;
}
}
$email = $tipo === FornitoreDipendente::TIPO_FORNITORE_ESTERNO
? trim((string) ($fornitoreEsterno?->email ?? ''))
: trim((string) ($validated['email'] ?? ''));
if ($email !== '') {
$existsQuery = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->whereRaw('LOWER(email) = ?', [mb_strtolower($email)]);
if ($tipo === FornitoreDipendente::TIPO_FORNITORE_ESTERNO && $fornitoreEsterno instanceof Fornitore) {
$existsQuery->orWhere('fornitore_esterno_id', (int) $fornitoreEsterno->id);
}
$exists = $existsQuery->exists();
if ($exists) {
Notification::make()->title('Esiste gia un collaboratore con questa email')->warning()->send();
return;
}
}
$dipendente = new FornitoreDipendente();
$dipendente->fornitore_id = (int) $this->fornitore->id;
$dipendente->fornitore_esterno_id = $fornitoreEsterno?->id;
$dipendente->rubrica_id = $fornitoreEsterno?->rubrica_id;
$dipendente->tipo_collaboratore = $tipo;
$dipendente->nome = $tipo === FornitoreDipendente::TIPO_FORNITORE_ESTERNO
? (string) ($fornitoreEsterno?->ragione_sociale ?: $fornitoreEsterno?->nome ?: 'Subfornitore')
: trim((string) $validated['nome']);
$dipendente->cognome = $tipo === FornitoreDipendente::TIPO_FORNITORE_ESTERNO
? null
: (trim((string) ($validated['cognome'] ?? '')) ?: null);
$dipendente->email = $email !== '' ? $email : null;
$dipendente->telefono = $tipo === FornitoreDipendente::TIPO_FORNITORE_ESTERNO
? (trim((string) ($fornitoreEsterno?->telefono ?: $fornitoreEsterno?->cellulare ?: '')) ?: null)
: (trim((string) ($validated['telefono'] ?? '')) ?: null);
$dipendente->note = trim((string) ($validated['note'] ?? '')) ?: null;
$dipendente->attivo = true;
$dipendente->created_by_user_id = Auth::id();
$dipendente->updated_by_user_id = Auth::id();
$createdPassword = null;
$plainPassword = trim((string) ($validated['accessPassword'] ?? ''));
if ($tipo === FornitoreDipendente::TIPO_FORNITORE_ESTERNO && $fornitoreEsterno instanceof Fornitore) {
$linkedUser = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower((string) ($fornitoreEsterno->email ?? ''))])->first();
if ($linkedUser instanceof User) {
$dipendente->user_id = (int) $linkedUser->id;
}
}
if ($tipo === FornitoreDipendente::TIPO_INTERNO && (bool) ($validated['createUserAccess'] ?? false) && $email !== '') {
$user = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first();
if (! $user instanceof User) {
$createdPassword = $plainPassword !== '' ? $plainPassword : Str::random(12);
$user = User::query()->create([
'name' => trim($dipendente->nome . ' ' . ($dipendente->cognome ?? '')),
'email' => $email,
'password' => Hash::make($createdPassword),
'email_verified_at' => now(),
'is_active' => true,
]);
} elseif ($plainPassword !== '') {
$user->password = Hash::make($plainPassword);
$user->save();
$createdPassword = $plainPassword;
}
$user->assignRole('fornitore');
$dipendente->user_id = (int) $user->id;
}
$dipendente->save();
$this->resetForm();
$this->refreshRows();
$notification = Notification::make()->title('Collaboratore salvato')->success();
if ($createdPassword !== null && $email !== '') {
$this->lastGeneratedPassword = $createdPassword;
$notification->body('Accesso creato per ' . $email . '. Password temporanea: ' . $createdPassword);
}
$notification->send();
}
public function abilitaAccesso(int $dipendenteId): void
{
if (! $this->fornitore instanceof Fornitore) {
return;
}
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->find($dipendenteId);
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Collaboratore non trovato')->danger()->send();
return;
}
if ((string) $dipendente->tipo_collaboratore === FornitoreDipendente::TIPO_FORNITORE_ESTERNO) {
Notification::make()->title('Per un altro fornitore si usa il suo accesso gia esistente')->warning()->send();
return;
}
$email = trim((string) ($dipendente->email ?? ''));
if ($email === '') {
Notification::make()->title('Email collaboratore mancante')->warning()->send();
return;
}
$targetUser = User::query()->whereRaw('LOWER(email) = ?', [mb_strtolower($email)])->first();
$created = false;
if (! $targetUser instanceof User) {
$newPassword = Str::random(12);
$targetUser = User::query()->create([
'name' => trim((string) ($dipendente->nome_completo ?: 'Utente fornitore')),
'email' => $email,
'password' => Hash::make($newPassword),
'email_verified_at' => now(),
'is_active' => true,
]);
$this->lastGeneratedPassword = $newPassword;
$created = true;
}
$targetUser->assignRole('fornitore');
$dipendente->user_id = (int) $targetUser->id;
$dipendente->attivo = true;
$dipendente->updated_by_user_id = Auth::id();
$dipendente->save();
$this->refreshRows();
Notification::make()
->title('Accesso collaboratore abilitato')
->body($created
? 'Nuova password temporanea: ' . ($this->lastGeneratedPassword ?? '(non disponibile)')
: 'Accesso collegato ad utente esistente.')
->success()
->send();
}
public function resetPasswordDipendenteUser(int $userId): void
{
if (! $this->fornitore instanceof Fornitore) {
return;
}
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->where('user_id', $userId)
->first();
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Utente non collegato a questo fornitore')->danger()->send();
return;
}
$target = User::query()->find($userId);
if (! $target instanceof User) {
Notification::make()->title('Utente non trovato')->danger()->send();
return;
}
$newPassword = Str::random(12);
$target->password = Hash::make($newPassword);
$target->save();
$this->lastGeneratedPassword = $newPassword;
Notification::make()
->title('Password collaboratore resettata')
->body('Nuova password temporanea: ' . $newPassword)
->success()
->send();
}
public function toggleDipendenteAttivo(int $dipendenteId): void
{
if (! $this->fornitore instanceof Fornitore) {
return;
}
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->find($dipendenteId);
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Collaboratore non trovato')->danger()->send();
return;
}
$dipendente->attivo = ! (bool) $dipendente->attivo;
$dipendente->updated_by_user_id = Auth::id();
$dipendente->save();
$this->refreshRows();
Notification::make()->title('Stato collaboratore aggiornato')->success()->send();
}
public function getTicketsUrl(): string
{
if ($this->fornitore instanceof Fornitore) {
return TicketOperativi::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament');
}
return TicketOperativi::getUrl(panel: 'admin-filament');
}
public function getContabilitaUrl(): string
{
if ($this->fornitore instanceof Fornitore) {
return Contabilita::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament');
}
return Contabilita::getUrl(panel: 'admin-filament');
}
protected function refreshRows(): void
{
if (! $this->fornitore instanceof Fornitore) {
$this->rows = [];
return;
}
$this->rows = FornitoreDipendente::query()
->with(['user', 'fornitoreEsterno'])
->where('fornitore_id', (int) $this->fornitore->id)
->orderBy('attivo', 'desc')
->orderBy('tipo_collaboratore')
->orderBy('cognome')
->orderBy('nome')
->get()
->map(fn(FornitoreDipendente $dipendente) => [
'id' => (int) $dipendente->id,
'nome' => (string) $dipendente->nome_completo,
'email' => (string) ($dipendente->email ?? ''),
'telefono' => (string) ($dipendente->telefono ?? ''),
'note' => (string) ($dipendente->note ?? ''),
'attivo' => (bool) $dipendente->attivo,
'user_id' => $dipendente->user_id ? (int) $dipendente->user_id : null,
'tipo' => (string) $dipendente->tipo_collaboratore,
'tipo_label' => (string) $dipendente->tipo_label,
'fornitore_esterno_nome' => (string) ($dipendente->fornitoreEsterno?->ragione_sociale ?: trim((string) (($dipendente->fornitoreEsterno?->nome ?? '') . ' ' . ($dipendente->fornitoreEsterno?->cognome ?? '')))),
])
->all();
}
protected function refreshFornitoreOptions(): void
{
if (! $this->fornitore instanceof Fornitore || $this->collaboratoreTipo !== FornitoreDipendente::TIPO_FORNITORE_ESTERNO) {
$this->fornitoreOptions = [];
return;
}
$search = trim($this->fornitoreSearch);
$this->fornitoreOptions = Fornitore::query()
->whereKeyNot((int) $this->fornitore->id)
->when(
$search !== '',
function (Builder $query) use ($search): void {
$query->where(function (Builder $builder) use ($search): void {
$builder->where('ragione_sociale', 'like', '%' . $search . '%')
->orWhere('nome', 'like', '%' . $search . '%')
->orWhere('cognome', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%')
->orWhere('telefono', 'like', '%' . $search . '%')
->orWhere('cellulare', 'like', '%' . $search . '%')
->orWhere('tags', 'like', '%' . $search . '%');
});
}
)
->orderByRaw('CASE WHEN amministratore_id = ? THEN 0 ELSE 1 END', [(int) ($this->fornitore->amministratore_id ?? 0)])
->orderByRaw('COALESCE(ragione_sociale, nome, cognome)')
->limit(20)
->get(['id', 'ragione_sociale', 'nome', 'cognome', 'email', 'telefono', 'cellulare', 'tags'])
->map(fn(Fornitore $fornitore) => [
'id' => (int) $fornitore->id,
'label' => $this->getFornitoreLabel($fornitore),
'meta' => trim(collect([
$fornitore->email,
$fornitore->telefono ?: $fornitore->cellulare,
$fornitore->tags,
])->filter()->implode(' · ')),
])
->all();
}
protected function resolveFornitoreEsterno(int $fornitoreId): ?Fornitore
{
if ($fornitoreId <= 0) {
return null;
}
return Fornitore::query()->find($fornitoreId);
}
protected function resetForm(): void
{
$this->nome = '';
$this->cognome = '';
$this->email = '';
$this->telefono = '';
$this->collaboratoreTipo = FornitoreDipendente::TIPO_INTERNO;
$this->fornitoreSearch = '';
$this->fornitoreEsternoId = null;
$this->note = '';
$this->createUserAccess = false;
$this->accessPassword = '';
$this->refreshFornitoreOptions();
}
}

View File

@ -0,0 +1,221 @@
<?php
namespace App\Filament\Pages\Fornitore\Concerns;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\TicketIntervento;
use Illuminate\Support\Facades\Auth;
trait ResolvesOperatoreContext
{
/**
* @return array{0:?Fornitore,1:?FornitoreDipendente}
*/
protected function resolveOperatoreContext(?int $forcedFornitoreId = null, bool $allowAdminWithoutSupplier = false): array
{
$user = Auth::user();
if ($user && $user->hasAnyRole(['super-admin', 'admin', 'amministratore'])) {
$fornitoreId = $forcedFornitoreId ?: (int) request()->query('fornitore', 0);
if ($fornitoreId <= 0) {
$interventoRoute = request()->route('intervento');
if ($interventoRoute instanceof TicketIntervento) {
$fornitoreId = (int) ($interventoRoute->fornitore_id ?? 0);
}
}
if ($fornitoreId > 0) {
$fornitore = Fornitore::query()->find($fornitoreId);
abort_unless($fornitore instanceof Fornitore, 404);
return [$fornitore, null];
}
if ($allowAdminWithoutSupplier) {
return [null, null];
}
}
$requestedFornitoreId = $forcedFornitoreId ?: (int) request()->query('fornitore', 0);
$currentSupplier = $this->resolveCurrentUserSupplier($user);
if ($requestedFornitoreId > 0) {
$collaboratore = $this->resolveCollaboratoreForUser($requestedFornitoreId, $currentSupplier?->id);
if ($collaboratore instanceof FornitoreDipendente) {
$fornitore = Fornitore::query()->find($requestedFornitoreId);
abort_unless($fornitore instanceof Fornitore, 404);
$collaboratore->ultimo_accesso_at = now();
$collaboratore->save();
return [$fornitore, $collaboratore];
}
if ($currentSupplier instanceof Fornitore && (int) $currentSupplier->id === $requestedFornitoreId) {
return [$currentSupplier, null];
}
}
$dipendente = null;
$fornitore = $currentSupplier;
if (! $fornitore && $user) {
$dipendente = $this->resolveCollaboratoreForUser(null, null);
if ($dipendente instanceof FornitoreDipendente) {
$fornitore = Fornitore::query()->find((int) $dipendente->fornitore_id);
$dipendente->ultimo_accesso_at = now();
$dipendente->save();
}
}
abort_unless($fornitore instanceof Fornitore, 403, 'Profilo fornitore non collegato.');
return [$fornitore, $dipendente];
}
protected function resolveCurrentUserSupplier($user): ?Fornitore
{
if (! $user) {
return null;
}
$email = mb_strtolower(trim((string) $user->email));
if ($email === '') {
return null;
}
return Fornitore::query()
->whereRaw('LOWER(email) = ?', [$email])
->withCount(['ticketInterventi', 'dipendenti'])
->orderByDesc('ticket_interventi_count')
->orderByDesc('dipendenti_count')
->orderByDesc('id')
->first();
}
protected function resolveCollaboratoreForUser(?int $fornitoreId = null, ?int $currentSupplierId = null): ?FornitoreDipendente
{
$user = Auth::user();
if (! $user) {
return null;
}
$query = FornitoreDipendente::query()
->where('attivo', true)
->when($fornitoreId, fn($builder) => $builder->where('fornitore_id', $fornitoreId))
->where(function ($builder) use ($user, $currentSupplierId): void {
$builder->where('user_id', (int) $user->id)
->orWhereRaw('LOWER(email) = ?', [mb_strtolower((string) $user->email)]);
if (($currentSupplierId ?? 0) > 0) {
$builder->orWhere('fornitore_esterno_id', (int) $currentSupplierId);
}
})
->orderByRaw('CASE WHEN user_id = ? THEN 0 ELSE 1 END', [(int) $user->id])
->orderByRaw('CASE WHEN fornitore_esterno_id IS NULL THEN 1 ELSE 0 END')
->orderByDesc('id');
return $query->first();
}
protected function authorizeIntervento(TicketIntervento $intervento, Fornitore $fornitore): void
{
abort_unless((int) $intervento->fornitore_id === (int) $fornitore->id, 403);
}
protected function authorizeDipendenteIntervento(TicketIntervento $intervento, ?FornitoreDipendente $dipendente): void
{
if (! $dipendente instanceof FornitoreDipendente) {
return;
}
$assignedDipendenteId = (int) ($intervento->eseguito_da_dipendente_id ?? 0);
abort_if($assignedDipendenteId > 0 && $assignedDipendenteId !== (int) $dipendente->id, 403, 'Intervento assegnato a un altro operatore del fornitore.');
}
/**
* @return array{ingresso:string,contatto:string,telefono:string,problema:string,apparato:string}
*/
protected function buildInterventoRow(TicketIntervento $intervento): array
{
$descrizione = (string) ($intervento->ticket->descrizione ?? '');
$caller = $this->extractCallerData($descrizione);
return [
'ingresso' => optional($intervento->created_at)->format('d/m/Y H:i') ?: '-',
'contatto' => $caller['contatto'],
'telefono' => $caller['telefono'],
'problema' => $caller['problema'] !== '' ? $caller['problema'] : ((string) ($intervento->ticket->titolo ?? '-')),
'apparato' => $this->extractApparato((string) ($intervento->rapporto_fornitore ?? '')),
];
}
/**
* @return array{contatto:string,telefono:string,problema:string}
*/
protected function extractCallerData(string $descrizione): array
{
$contatto = '-';
$telefono = '';
$problema = '';
$lines = preg_split('/\r\n|\r|\n/', $descrizione) ?: [];
foreach ($lines as $line) {
$trim = trim($line);
if ($problema === '' && $trim !== '') {
$problema = $trim;
}
if (str_starts_with($trim, 'Chiamante selezionato:')) {
$contatto = trim((string) str_replace('Chiamante selezionato:', '', $trim));
}
if (str_starts_with($trim, 'Telefono:')) {
$telefono = trim((string) str_replace('Telefono:', '', $trim));
}
}
return [
'contatto' => $contatto,
'telefono' => $telefono,
'problema' => $problema,
];
}
protected function extractApparato(string $rapporto): string
{
foreach ((preg_split('/\r\n|\r|\n/', $rapporto) ?: []) as $line) {
$trim = trim($line);
if (str_starts_with(mb_strtolower($trim), 'apparato:')) {
return trim((string) substr($trim, strlen('apparato:')));
}
}
return '-';
}
protected function buildApparatoSummary(string $marca, string $modello, string $seriale): string
{
$marca = trim($marca);
$modello = trim($modello);
$seriale = trim($seriale);
if ($marca === '' && $modello === '' && $seriale === '') {
return '';
}
return 'Apparato: '
. ($marca !== '' ? ('marca=' . $marca . ' ') : '')
. ($modello !== '' ? ('modello=' . $modello . ' ') : '')
. ($seriale !== '' ? ('seriale=' . $seriale) : '');
}
protected function getFornitoreLabel(Fornitore $fornitore): string
{
$label = trim((string) ($fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? ''))));
return $label !== '' ? $label : ('Fornitore #' . (int) $fornitore->id);
}
}

View File

@ -0,0 +1,158 @@
<?php
namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Fornitore;
use App\Models\TicketIntervento;
use App\Models\User;
use App\Modules\Contabilita\Models\FatturaFornitore;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use UnitEnum;
class Contabilita extends Page
{
use ResolvesOperatoreContext;
protected static ?string $navigationLabel = 'Contabilita';
protected static ?string $title = 'Contabilita fornitore';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-banknotes';
protected static UnitEnum|string|null $navigationGroup = 'Fornitore';
protected static ?int $navigationSort = 3;
protected static ?string $slug = 'fornitore/contabilita';
protected string $view = 'filament.pages.fornitore.contabilita';
public ?Fornitore $fornitore = null;
public ?string $fornitoreLabel = null;
public bool $missingAdminContext = false;
public bool $contabilitaReady = false;
/** @var array{aperte:int,pagate:int,registrate:int,totale_aperto:float,totale_registrato:float} */
public array $stats = [
'aperte' => 0,
'pagate' => 0,
'registrate' => 0,
'totale_aperto' => 0.0,
'totale_registrato' => 0.0,
];
/** @var array<int,array<string,mixed>> */
public array $fattureRows = [];
/** @var array<int,array<string,mixed>> */
public array $queueRows = [];
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']);
}
public function mount(): void
{
[$fornitore] = $this->resolveOperatoreContext(allowAdminWithoutSupplier: true);
if (! $fornitore instanceof Fornitore) {
$this->missingAdminContext = true;
return;
}
$this->fornitore = $fornitore;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->loadData();
}
public function getTicketsUrl(): string
{
if ($this->fornitore instanceof Fornitore) {
return TicketOperativi::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament');
}
return TicketOperativi::getUrl(panel: 'admin-filament');
}
public function getCollaboratoriUrl(): string
{
if ($this->fornitore instanceof Fornitore) {
return Collaboratori::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament');
}
return Collaboratori::getUrl(panel: 'admin-filament');
}
protected function loadData(): void
{
if (! $this->fornitore instanceof Fornitore) {
return;
}
$this->queueRows = TicketIntervento::query()
->with(['ticket.stabile.amministratore'])
->where('fornitore_id', (int) $this->fornitore->id)
->whereIn('stato', ['in_verifica', 'fatturabile', 'fatturato'])
->orderByDesc('updated_at')
->limit(40)
->get()
->map(fn(TicketIntervento $intervento) => [
'ticket_id' => (int) $intervento->ticket_id,
'intervento_id' => (int) $intervento->id,
'stato' => (string) $intervento->stato,
'stabile' => (string) ($intervento->ticket?->stabile?->denominazione ?? '-'),
'amministratore' => (string) ($intervento->ticket?->stabile?->amministratore?->denominazione_studio ?? '-'),
'titolo' => (string) ($intervento->ticket?->titolo ?? '-'),
'aggiornato' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-',
'url' => TicketInterventoScheda::getUrl(['record' => (int) $intervento->id], panel: 'admin-filament'),
])
->all();
if (! Schema::hasTable('contabilita_fatture_fornitori')) {
$this->contabilitaReady = false;
return;
}
$this->contabilitaReady = true;
$fatture = FatturaFornitore::query()
->with(['stabile.amministratore'])
->where('fornitore_id', (int) $this->fornitore->id)
->orderByDesc('data_documento')
->orderByDesc('id')
->limit(100)
->get();
$this->stats = [
'aperte' => $fatture->whereIn('stato', ['inserito', 'contabilizzato'])->count(),
'pagate' => $fatture->where('stato', 'pagato')->count(),
'registrate' => $fatture->count(),
'totale_aperto' => (float) $fatture->whereIn('stato', ['inserito', 'contabilizzato'])->sum('netto_da_pagare'),
'totale_registrato' => (float) $fatture->sum('totale'),
];
$this->fattureRows = $fatture
->map(fn(FatturaFornitore $fattura) => [
'id' => (int) $fattura->id,
'data_documento' => optional($fattura->data_documento)->format('d/m/Y') ?: '-',
'numero_documento' => (string) ($fattura->numero_documento ?? '-'),
'stato' => (string) ($fattura->stato ?? 'inserito'),
'stabile' => (string) ($fattura->stabile?->denominazione ?? '-'),
'amministratore' => (string) ($fattura->stabile?->amministratore?->denominazione_studio ?? '-'),
'descrizione' => (string) ($fattura->descrizione ?? ''),
'totale' => (float) ($fattura->totale ?? 0),
'netto' => (float) ($fattura->netto_da_pagare ?? 0),
])
->all();
}
}

View File

@ -0,0 +1,404 @@
<?php
namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\TicketAttachment;
use App\Models\TicketIntervento;
use App\Models\User;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Livewire\WithFileUploads;
use UnitEnum;
class TicketInterventoScheda extends Page
{
use ResolvesOperatoreContext;
use WithFileUploads;
protected static ?string $title = 'Scheda intervento';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static UnitEnum|string|null $navigationGroup = 'Fornitore';
protected static ?string $slug = 'fornitore/tickets/{record}';
protected static bool $shouldRegisterNavigation = false;
protected string $view = 'filament.pages.fornitore.ticket-intervento-scheda';
public TicketIntervento $intervento;
public Fornitore $fornitore;
public ?FornitoreDipendente $dipendente = null;
/** @var array{contatto:string,telefono:string,problema:string} */
public array $caller = [
'contatto' => '-',
'telefono' => '',
'problema' => '',
];
/** @var array<int, array<string, mixed>> */
public array $storicoRows = [];
/** @var array<int, array<string, mixed>> */
public array $dipendentiOptions = [];
public ?int $dipendenteId = null;
public string $rapportoFornitore = '';
public ?int $tempoMinuti = null;
/** @var array<int, string> */
public array $articoliUtilizzati = ['', '', ''];
public string $apparatoMarca = '';
public string $apparatoModello = '';
public string $apparatoSeriale = '';
/** @var array<int, mixed> */
public array $fotoLavoro = [];
/** @var array<int, mixed> */
public array $documentiLavoro = [];
/** @var array<int, string> */
public array $descrizioneFile = ['', '', '', '', '', ''];
public string $messaggioVeloce = '';
public bool $lavoroStandard = false;
public bool $richiestaProforma = false;
public string $proformaCodice = '';
public string $proformaNote = '';
public string $qrToken = '';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']);
}
public function mount(int | string $record): void
{
$this->intervento = TicketIntervento::query()->findOrFail((int) $record);
[$fornitore, $dipendente] = $this->resolveOperatoreContext((int) $this->intervento->fornitore_id);
abort_unless($fornitore instanceof Fornitore, 404);
$this->fornitore = $fornitore;
$this->dipendente = $dipendente;
$this->authorizeIntervento($this->intervento, $this->fornitore);
$this->authorizeDipendenteIntervento($this->intervento, $this->dipendente);
$this->reloadIntervento();
}
public function assignDipendente(): void
{
if ($this->dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Assegnazione consentita solo al coordinatore fornitore')->warning()->send();
return;
}
$validated = $this->validate([
'dipendenteId' => ['nullable', 'integer'],
]);
$dipendente = null;
$selectedId = (int) ($validated['dipendenteId'] ?? 0);
if ($selectedId > 0) {
$dipendente = FornitoreDipendente::query()
->where('fornitore_id', (int) $this->fornitore->id)
->where('attivo', true)
->find($selectedId);
if (! $dipendente instanceof FornitoreDipendente) {
Notification::make()->title('Collaboratore non valido per questo fornitore')->danger()->send();
return;
}
}
$this->intervento->eseguito_da_dipendente_id = $dipendente?->id;
$this->intervento->save();
$stamp = now()->format('d/m/Y H:i');
$message = $dipendente
? '[' . $stamp . '] Intervento assegnato al collaboratore fornitore: ' . $dipendente->ruolo_operativo_label . '.'
: '[' . $stamp . '] Assegnazione collaboratore rimossa: intervento riportato al coordinamento del fornitore.';
$this->intervento->ticket->messages()->create([
'user_id' => Auth::id(),
'messaggio' => $message,
'canale' => 'interno',
'direzione' => 'inbound',
'inviato_il' => now(),
]);
$this->reloadIntervento();
Notification::make()->title('Assegnazione aggiornata')->success()->send();
}
public function startIntervento(): void
{
$this->intervento->update([
'stato' => 'in_corso',
'preso_in_carico_da_user_id' => Auth::id(),
'eseguito_da_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id,
'iniziato_at' => $this->intervento->iniziato_at ?? now(),
]);
$this->intervento->ticket->update(['stato' => 'In Lavorazione']);
$this->reloadIntervento();
Notification::make()->title('Intervento preso in carico')->success()->send();
}
public function saveRapporto(): void
{
$validated = $this->validate([
'rapportoFornitore' => ['required', 'string', 'max:5000'],
'tempoMinuti' => ['nullable', 'integer', 'min:1', 'max:1440'],
'articoliUtilizzati' => ['nullable', 'array'],
'articoliUtilizzati.*' => ['nullable', 'string', 'max:120'],
'fotoLavoro' => ['nullable', 'array', 'max:10'],
'fotoLavoro.*' => ['nullable', 'image', 'max:10240'],
'documentiLavoro' => ['nullable', 'array', 'max:10'],
'documentiLavoro.*' => ['nullable', 'file', 'max:10240', 'mimes:pdf,doc,docx,xls,xlsx,txt,jpg,jpeg,png,webp'],
'descrizioneFile' => ['nullable', 'array'],
'descrizioneFile.*' => ['nullable', 'string', 'max:255'],
'apparatoMarca' => ['nullable', 'string', 'max:120'],
'apparatoModello' => ['nullable', 'string', 'max:120'],
'apparatoSeriale' => ['nullable', 'string', 'max:120'],
'messaggioVeloce' => ['nullable', 'string', 'max:1500'],
'lavoroStandard' => ['nullable', 'boolean'],
'richiestaProforma' => ['nullable', 'boolean'],
'proformaCodice' => ['nullable', 'string', 'max:80'],
'proformaNote' => ['nullable', 'string', 'max:2000'],
'qrToken' => ['nullable', 'string', 'max:40'],
]);
$rapporto = trim((string) $validated['rapportoFornitore']);
$apparato = $this->buildApparatoSummary(
(string) ($validated['apparatoMarca'] ?? ''),
(string) ($validated['apparatoModello'] ?? ''),
(string) ($validated['apparatoSeriale'] ?? '')
);
if ($apparato !== '') {
$rapporto .= "\n\n" . $apparato;
}
$payload = [
'rapporto_fornitore' => $rapporto,
'tempo_minuti' => $validated['tempoMinuti'] ?? null,
'terminato_at' => now(),
'stato' => ! empty($validated['lavoroStandard']) ? 'fatturabile' : 'in_verifica',
'eseguito_da_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id,
];
$articoli = collect($validated['articoliUtilizzati'] ?? [])
->map(fn($item) => trim((string) $item))
->filter()
->values()
->all();
if ($articoli !== []) {
$payload['articoli_utilizzati'] = $articoli;
}
if ($this->fotoLavoro !== []) {
$firstPhoto = $this->fotoLavoro[0] ?? null;
if ($firstPhoto) {
$payload['foto_path'] = $firstPhoto->store('ticket-interventi/foto/' . $this->intervento->id, 'public');
}
}
$allFiles = array_merge($this->fotoLavoro, $this->documentiLavoro);
if ($allFiles !== [] && Schema::hasTable('ticket_attachments')) {
foreach ($allFiles as $index => $file) {
if (! $file) {
continue;
}
$path = $file->store('ticket-interventi/allegati/' . $this->intervento->id, 'public');
TicketAttachment::query()->create([
'ticket_id' => (int) $this->intervento->ticket_id,
'ticket_update_id' => null,
'user_id' => (int) Auth::id(),
'file_path' => $path,
'original_file_name' => (string) $file->getClientOriginalName(),
'mime_type' => (string) $file->getClientMimeType(),
'size' => (int) $file->getSize(),
'description' => trim((string) ($this->descrizioneFile[$index] ?? '')) ?: 'Allegato rapporto fornitore',
]);
}
}
$tokenInserito = trim((string) ($validated['qrToken'] ?? ''));
if ($tokenInserito !== '' && strcasecmp($tokenInserito, (string) $this->intervento->qr_token) === 0) {
$payload['qr_scansionato_at'] = now();
}
$this->intervento->update($payload);
$stamp = now()->format('d/m/Y H:i');
$this->intervento->ticket->messages()->create([
'user_id' => Auth::id(),
'messaggio' => '[' . $stamp . '] Rapporto intervento caricato dal fornitore.',
'canale' => 'interno',
'direzione' => 'inbound',
'inviato_il' => now(),
]);
$proformaCodice = trim((string) ($validated['proformaCodice'] ?? ''));
$proformaNote = trim((string) ($validated['proformaNote'] ?? ''));
if (! empty($validated['richiestaProforma']) || $proformaCodice !== '' || $proformaNote !== '') {
$this->intervento->ticket->messages()->create([
'user_id' => Auth::id(),
'messaggio' => '[' . $stamp . '] Proforma fornitore ' . ($proformaCodice !== '' ? ('codice ' . $proformaCodice) : '(senza codice)') . ($proformaNote !== '' ? (' - ' . $proformaNote) : ''),
'canale' => 'interno',
'direzione' => 'inbound',
'inviato_il' => now(),
]);
}
$quickMessage = trim((string) ($validated['messaggioVeloce'] ?? ''));
if ($quickMessage !== '') {
$this->intervento->ticket->messages()->create([
'user_id' => Auth::id(),
'messaggio' => '[' . $stamp . '] Messaggio veloce fornitore: ' . $quickMessage,
'canale' => 'interno',
'direzione' => 'inbound',
'inviato_il' => now(),
]);
}
$this->fotoLavoro = [];
$this->documentiLavoro = [];
$this->descrizioneFile = ['', '', '', '', '', ''];
$this->messaggioVeloce = '';
$this->proformaCodice = '';
$this->proformaNote = '';
$this->reloadIntervento();
Notification::make()->title('Rapporto inviato in verifica amministratore')->success()->send();
}
public function inviaSollecito(): void
{
$stamp = now()->format('d/m/Y H:i');
$this->intervento->ticket->messages()->create([
'user_id' => Auth::id(),
'messaggio' => '[' . $stamp . '] Sollecito fornitore su ticket/intervento in corso.',
'canale' => 'interno',
'direzione' => 'inbound',
'inviato_il' => now(),
]);
$this->reloadIntervento();
Notification::make()->title('Sollecito inviato allo studio amministrativo')->success()->send();
}
public function getTicketsUrl(): string
{
return TicketOperativi::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament');
}
public function getCollaboratoriUrl(): string
{
return Collaboratori::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament');
}
public function canAssignDipendente(): bool
{
return ! ($this->dipendente instanceof FornitoreDipendente) && count($this->dipendentiOptions) > 0;
}
protected function reloadIntervento(): void
{
$this->intervento->load([
'ticket.stabile',
'ticket.messages.user',
'ticket.attachments.user',
'ticket.apertoDaUser',
'ticket.assegnatoAUser',
'ticket.assegnatoAFornitore',
'eseguitoDaDipendente',
]);
$this->intervento->refresh();
$this->intervento->load([
'ticket.stabile',
'ticket.messages.user',
'ticket.attachments.user',
'ticket.apertoDaUser',
'ticket.assegnatoAUser',
'ticket.assegnatoAFornitore',
'eseguitoDaDipendente',
]);
$this->caller = $this->extractCallerData((string) ($this->intervento->ticket?->descrizione ?? ''));
$this->storicoRows = TicketIntervento::query()
->with(['ticket'])
->where('fornitore_id', (int) $this->fornitore->id)
->where('id', '!=', (int) $this->intervento->id)
->when(
(int) ($this->intervento->ticket?->soggetto_richiedente_id ?? 0) > 0,
fn($query) => $query->whereHas('ticket', fn($ticketQuery) => $ticketQuery->where('soggetto_richiedente_id', (int) $this->intervento->ticket->soggetto_richiedente_id)),
fn($query) => $query->whereHas('ticket', fn($ticketQuery) => $ticketQuery->where('stabile_id', (int) ($this->intervento->ticket?->stabile_id ?? 0)))
)
->latest('id')
->limit(12)
->get()
->map(fn(TicketIntervento $old) => [
'ticket_id' => (int) $old->ticket_id,
'titolo' => (string) ($old->ticket->titolo ?? '-'),
'stato' => (string) $old->stato,
'updated_at' => optional($old->updated_at)->format('d/m/Y H:i') ?: '-',
'note' => (string) ($old->note_amministratore ?? ''),
])
->all();
$this->dipendentiOptions = $this->fornitore->dipendenti()
->where('attivo', true)
->with('fornitoreEsterno')
->orderBy('cognome')
->orderBy('nome')
->get()
->map(fn(FornitoreDipendente $dipendente) => [
'id' => (int) $dipendente->id,
'label' => $dipendente->ruolo_operativo_label . ($dipendente->email ? ' · ' . $dipendente->email : ''),
])
->all();
$this->dipendenteId = (int) ($this->intervento->eseguito_da_dipendente_id ?? 0) ?: null;
$this->rapportoFornitore = (string) ($this->intervento->rapporto_fornitore ?? '');
$this->tempoMinuti = $this->intervento->tempo_minuti ? (int) $this->intervento->tempo_minuti : null;
$this->articoliUtilizzati = array_pad((array) ($this->intervento->articoli_utilizzati ?? []), 3, '');
$this->qrToken = '';
}
}

View File

@ -0,0 +1,182 @@
<?php
namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\TicketIntervento;
use App\Models\User;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class TicketOperativi extends Page
{
use ResolvesOperatoreContext;
protected static ?string $navigationLabel = 'Ticket operativi';
protected static ?string $title = 'Ticket fornitore';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static UnitEnum|string|null $navigationGroup = 'Fornitore';
protected static ?int $navigationSort = 1;
protected static ?string $slug = 'fornitore/tickets';
protected string $view = 'filament.pages.fornitore.ticket-operativi';
public ?int $fornitoreId = null;
public ?string $fornitoreLabel = null;
public string $status = 'aperti';
public bool $missingAdminContext = false;
/** @var array<int, array<string, mixed>> */
public array $rows = [];
/** @var array<string, int> */
public array $totals = [
'aperti' => 0,
'chiusi' => 0,
'fatturabili' => 0,
];
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']);
}
public function mount(): void
{
$this->status = (string) request()->query('stato', 'aperti');
[$fornitore] = $this->resolveOperatoreContext(allowAdminWithoutSupplier: true);
if (! $fornitore instanceof Fornitore) {
$this->missingAdminContext = true;
$this->rows = [];
return;
}
$this->fornitoreId = (int) $fornitore->id;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->refreshData();
}
public function updatedStatus(): void
{
$this->refreshData();
}
public function refreshData(): void
{
if (! $this->fornitoreId) {
$this->rows = [];
$this->totals = ['aperti' => 0, 'chiusi' => 0, 'fatturabili' => 0];
return;
}
[$fornitore, $dipendente] = $this->resolveOperatoreContext($this->fornitoreId);
$query = $this->buildBaseQuery($fornitore, $dipendente);
$this->applyStatusFilter($query, $this->status);
$this->rows = $query
->limit(100)
->get()
->map(function (TicketIntervento $intervento): array {
$base = $this->buildInterventoRow($intervento);
return array_merge($base, [
'id' => (int) $intervento->id,
'ticket_id' => (int) $intervento->ticket_id,
'titolo' => (string) ($intervento->ticket->titolo ?? '-'),
'stato' => (string) $intervento->stato,
'stabile' => (string) ($intervento->ticket->stabile->denominazione ?? '-'),
'operatore' => (string) $intervento->operatore_assegnato_label,
'tempo_minuti' => (int) ($intervento->tempo_minuti ?? 0),
'updated_at' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-',
'url' => TicketInterventoScheda::getUrl(['record' => $intervento->id], panel: 'admin-filament'),
]);
})
->all();
$this->totals = [
'aperti' => (clone $this->buildBaseQuery($fornitore, $dipendente))
->whereNotIn('stato', ['chiuso'])
->count(),
'chiusi' => (clone $this->buildBaseQuery($fornitore, $dipendente))
->whereIn('stato', ['chiuso', 'fatturato'])
->count(),
'fatturabili' => (clone $this->buildBaseQuery($fornitore, $dipendente))
->whereIn('stato', ['fatturabile', 'fatturato'])
->count(),
];
}
public function setStatus(string $status): void
{
$this->status = $status;
$this->refreshData();
}
public function getCollaboratoriUrl(): string
{
if ($this->fornitoreId && Auth::user()?->hasAnyRole(['super-admin', 'admin', 'amministratore'])) {
return Collaboratori::getUrl(['fornitore' => $this->fornitoreId], panel: 'admin-filament');
}
return Collaboratori::getUrl(panel: 'admin-filament');
}
public function getContabilitaUrl(): string
{
if ($this->fornitoreId && Auth::user()?->hasAnyRole(['super-admin', 'admin', 'amministratore'])) {
return Contabilita::getUrl(['fornitore' => $this->fornitoreId], panel: 'admin-filament');
}
return Contabilita::getUrl(panel: 'admin-filament');
}
protected function buildBaseQuery(Fornitore $fornitore, ?FornitoreDipendente $dipendente): Builder
{
$query = TicketIntervento::query()
->with(['ticket.stabile', 'eseguitoDaDipendente'])
->where('fornitore_id', (int) $fornitore->id)
->orderByDesc('created_at');
if ($dipendente instanceof FornitoreDipendente) {
$query->where(function (Builder $builder) use ($dipendente): void {
$builder->whereNull('eseguito_da_dipendente_id')
->orWhere('eseguito_da_dipendente_id', (int) $dipendente->id);
});
}
return $query;
}
protected function applyStatusFilter(Builder $query, string $status): void
{
if ($status === 'chiusi') {
$query->whereIn('stato', ['chiuso', 'fatturato']);
return;
}
if ($status === 'fatturabili') {
$query->whereIn('stato', ['fatturabile', 'fatturato']);
return;
}
$query->whereNotIn('stato', ['chiuso']);
}
}

View File

@ -2,8 +2,10 @@
namespace App\Filament\Pages\Gescon; namespace App\Filament\Pages\Gescon;
use App\Models\User;
use BackedEnum; use BackedEnum;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum; use UnitEnum;
class Anagrafica extends Page class Anagrafica extends Page
@ -23,4 +25,12 @@ class Anagrafica extends Page
protected string $view = 'filament.pages.gescon.section'; protected string $view = 'filament.pages.gescon.section';
public string $sectionKey = 'anagrafica'; public string $sectionKey = 'anagrafica';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
} }

View File

@ -2,8 +2,10 @@
namespace App\Filament\Pages\Gescon; namespace App\Filament\Pages\Gescon;
use App\Models\User;
use BackedEnum; use BackedEnum;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum; use UnitEnum;
class GesconHome extends Page class GesconHome extends Page
@ -21,4 +23,12 @@ class GesconHome extends Page
protected static ?string $slug = 'gescon'; protected static ?string $slug = 'gescon';
protected string $view = 'filament.pages.gescon.home'; protected string $view = 'filament.pages.gescon.home';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
} }

View File

@ -1,6 +1,7 @@
<?php <?php
namespace App\Filament\Pages\Gescon; namespace App\Filament\Pages\Gescon;
use App\Models\User;
use App\Models\UserSetting; use App\Models\UserSetting;
use App\Support\StabileContext; use App\Support\StabileContext;
use BackedEnum; use BackedEnum;
@ -50,6 +51,14 @@ class Ordinarie extends Page
public bool $onlyStraordinarieMode = false; public bool $onlyStraordinarieMode = false;
public ?int $selectedStraordinaria = null; public ?int $selectedStraordinaria = null;
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public ?string $mastrinoTabella = null; public ?string $mastrinoTabella = null;
public ?string $mastrinoCodSpe = null; public ?string $mastrinoCodSpe = null;
public array $mastrinoRows = []; public array $mastrinoRows = [];

View File

@ -2,8 +2,10 @@
namespace App\Filament\Pages\Gescon; namespace App\Filament\Pages\Gescon;
use App\Models\User;
use BackedEnum; use BackedEnum;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum; use UnitEnum;
class Riscaldamento extends Page class Riscaldamento extends Page
@ -23,4 +25,12 @@ class Riscaldamento extends Page
protected string $view = 'filament.pages.gescon.section'; protected string $view = 'filament.pages.gescon.section';
public string $sectionKey = 'riscaldamento'; public string $sectionKey = 'riscaldamento';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
} }

View File

@ -2,8 +2,10 @@
namespace App\Filament\Pages\Gescon; namespace App\Filament\Pages\Gescon;
use App\Models\User;
use BackedEnum; use BackedEnum;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum; use UnitEnum;
class Utilita extends Page class Utilita extends Page
@ -23,4 +25,12 @@ class Utilita extends Page
protected string $view = 'filament.pages.gescon.section'; protected string $view = 'filament.pages.gescon.section';
public string $sectionKey = 'utilita'; public string $sectionKey = 'utilita';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
} }

View File

@ -2,8 +2,10 @@
namespace App\Filament\Pages\Gescon; namespace App\Filament\Pages\Gescon;
use App\Models\User;
use BackedEnum; use BackedEnum;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum; use UnitEnum;
class Varie extends Page class Varie extends Page
@ -23,4 +25,12 @@ class Varie extends Page
protected string $view = 'filament.pages.gescon.section'; protected string $view = 'filament.pages.gescon.section';
public string $sectionKey = 'varie'; public string $sectionKey = 'varie';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
} }

View File

@ -11,6 +11,7 @@
use BackedEnum; use BackedEnum;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -342,6 +343,103 @@ public function form(Schema $schema): Schema
->label('Gruppo rubrica Google') ->label('Gruppo rubrica Google')
->maxLength(255) ->maxLength(255)
->helperText('Es: NetGesCon / Condomini'), ->helperText('Es: NetGesCon / Condomini'),
Toggle::make('impostazioni.google.mail_enabled')
->label('Abilita lettura Gmail')
->default(false),
TextInput::make('impostazioni.google.gmail_label')
->label('Label Gmail da leggere')
->maxLength(255)
->helperText('Es: NetGescon/Assicurazioni'),
TextInput::make('impostazioni.google.gmail_query')
->label('Query Gmail')
->maxLength(255)
->helperText('Es: from:assicurazione@example.it has:attachment newer_than:30d'),
Toggle::make('impostazioni.google.archive_after_import')
->label('Archivia dopo l\'acquisizione')
->default(false),
Toggle::make('impostazioni.google.import_attachments')
->label('Importa allegati')
->default(true),
TextInput::make('impostazioni.google.allowed_senders')
->label('Mittenti ammessi')
->maxLength(500)
->helperText('Email separate da virgola; utile per assicurazioni o inoltri FE.'),
]),
Section::make('Caselle studio / PEC da collegare')
->description('Caselle dello studio amministratore. Per le caselle specifiche del condominio usa la nuova voce Posta stabile nella scheda Stabile.')
->schema([
Repeater::make('impostazioni.posta.caselle')
->label('Caselle collegate')
->default([])
->schema([
Toggle::make('enabled')
->label('Attiva')
->default(true),
Select::make('tipo')
->label('Tipo')
->options([
'gmail' => 'Gmail / Google Workspace',
'imap' => 'IMAP ordinaria',
'pec' => 'PEC via IMAP',
])
->default('imap')
->required(),
TextInput::make('label')
->label('Etichetta')
->required()
->maxLength(120),
TextInput::make('email')
->label('Email casella')
->email()
->maxLength(255),
TextInput::make('host')
->label('Host IMAP')
->maxLength(255),
TextInput::make('port')
->label('Porta')
->numeric(),
Select::make('encryption')
->label('Cifratura')
->options([
'ssl' => 'SSL',
'tls' => 'TLS',
'none' => 'Nessuna',
])
->default('ssl'),
TextInput::make('username')
->label('Username')
->maxLength(255),
TextInput::make('password')
->label('Password')
->password()
->revealable()
->maxLength(255),
TextInput::make('folder')
->label('Cartella da leggere')
->maxLength(120)
->default('INBOX'),
])
->columns(2)
->columnSpanFull(),
]),
Section::make('Acquisizione messaggi e allegati')
->columns(2)
->schema([
Toggle::make('impostazioni.posta.routing.salva_documenti')
->label('Archivia allegati nei documenti')
->default(true),
Toggle::make('impostazioni.posta.routing.salva_contabilita')
->label('Rendi disponibili gli allegati in contabilità')
->default(true),
TextInput::make('impostazioni.posta.routing.mittenti_assicurazione')
->label('Mittenti assicurazione')
->maxLength(500)
->helperText('Email separate da virgola per filtrare i messaggi dellassicuratore.'),
TextInput::make('impostazioni.posta.routing.descrizione_default')
->label('Descrizione default allegato')
->maxLength(255),
]), ]),
Section::make('WhatsApp Cloud API (Meta)') Section::make('WhatsApp Cloud API (Meta)')

View File

@ -2,8 +2,10 @@
namespace App\Filament\Pages\Strumenti; namespace App\Filament\Pages\Strumenti;
use App\Models\User;
use BackedEnum; use BackedEnum;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum; use UnitEnum;
class PassaggiDiConsegne extends Page class PassaggiDiConsegne extends Page
@ -21,4 +23,12 @@ class PassaggiDiConsegne extends Page
protected static ?string $slug = 'strumenti/passaggi-di-consegne'; protected static ?string $slug = 'strumenti/passaggi-di-consegne';
protected string $view = 'filament.pages.strumenti.passaggi-di-consegne'; protected string $view = 'filament.pages.strumenti.passaggi-di-consegne';
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
} }

View File

@ -38,6 +38,14 @@ class UnitaImmobiliarePage extends Page implements HasForms
{ {
use InteractsWithForms; use InteractsWithForms;
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
protected int $stabileId = 0; protected int $stabileId = 0;
protected ?string $backUrl = null; protected ?string $backUrl = null;

View File

@ -2,6 +2,7 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Models\User;
use BackedEnum; use BackedEnum;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -23,6 +24,14 @@ class UnitaImmobiliareV2 extends UnitaImmobiliarePage
protected static bool $shouldRegisterNavigation = false; protected static bool $shouldRegisterNavigation = false;
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void public function mount(): void
{ {
// Manteniamo la route storica ma riportiamo alla pagina unica. // Manteniamo la route storica ma riportiamo alla pagina unica.

View File

@ -2,8 +2,10 @@
namespace App\Filament\Pages; namespace App\Filament\Pages;
use App\Models\User;
use BackedEnum; use BackedEnum;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Auth;
use UnitEnum; use UnitEnum;
class UnitaImmobiliareV3 extends UnitaImmobiliarePage class UnitaImmobiliareV3 extends UnitaImmobiliarePage
@ -22,6 +24,14 @@ class UnitaImmobiliareV3 extends UnitaImmobiliarePage
protected static bool $shouldRegisterNavigation = false; protected static bool $shouldRegisterNavigation = false;
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void public function mount(): void
{ {
// Manteniamo la route storica ma riportiamo alla pagina unica. // Manteniamo la route storica ma riportiamo alla pagina unica.

View File

@ -8,11 +8,18 @@ class FornitoreDipendente extends Model
{ {
use HasFactory; use HasFactory;
public const TIPO_INTERNO = 'interno';
public const TIPO_FORNITORE_ESTERNO = 'fornitore_esterno';
protected $table = 'fornitore_dipendenti'; protected $table = 'fornitore_dipendenti';
protected $fillable = [ protected $fillable = [
'fornitore_id', 'fornitore_id',
'fornitore_esterno_id',
'rubrica_id',
'user_id', 'user_id',
'tipo_collaboratore',
'nome', 'nome',
'cognome', 'cognome',
'email', 'email',
@ -39,6 +46,16 @@ public function user()
return $this->belongsTo(User::class, 'user_id'); return $this->belongsTo(User::class, 'user_id');
} }
public function fornitoreEsterno()
{
return $this->belongsTo(Fornitore::class, 'fornitore_esterno_id');
}
public function rubrica()
{
return $this->belongsTo(RubricaUniversale::class, 'rubrica_id');
}
public function creatoDaUser() public function creatoDaUser()
{ {
return $this->belongsTo(User::class, 'created_by_user_id'); return $this->belongsTo(User::class, 'created_by_user_id');
@ -51,6 +68,33 @@ public function aggiornatoDaUser()
public function getNomeCompletoAttribute(): string public function getNomeCompletoAttribute(): string
{ {
return trim((string) ($this->nome . ' ' . ($this->cognome ?? ''))); $label = trim((string) ($this->nome . ' ' . ($this->cognome ?? '')));
if ($label !== '') {
return $label;
}
$supplierLabel = trim((string) ($this->fornitoreEsterno?->ragione_sociale ?: trim((string) (($this->fornitoreEsterno?->nome ?? '') . ' ' . ($this->fornitoreEsterno?->cognome ?? '')))));
return $supplierLabel !== '' ? $supplierLabel : 'Collaboratore fornitore';
}
public function getTipoLabelAttribute(): string
{
return match ((string) $this->tipo_collaboratore) {
self::TIPO_FORNITORE_ESTERNO => 'Altro fornitore',
default => 'Collaboratore interno',
};
}
public function getRuoloOperativoLabelAttribute(): string
{
if ((string) $this->tipo_collaboratore === self::TIPO_FORNITORE_ESTERNO) {
$supplierLabel = trim((string) ($this->fornitoreEsterno?->ragione_sociale ?: trim((string) (($this->fornitoreEsterno?->nome ?? '') . ' ' . ($this->fornitoreEsterno?->cognome ?? '')))));
return $supplierLabel !== '' ? 'Subfornitore: ' . $supplierLabel : 'Subfornitore esterno';
}
return $this->nome_completo !== '' ? $this->nome_completo : 'Collaboratore interno';
} }
} }

View File

@ -83,4 +83,9 @@ public function getCompletabileAttribute(): bool
{ {
return $this->check_foto_ok && $this->check_qr_ok && $this->check_admin_ok; return $this->check_foto_ok && $this->check_qr_ok && $this->check_admin_ok;
} }
public function getOperatoreAssegnatoLabelAttribute(): string
{
return $this->eseguitoDaDipendente?->ruolo_operativo_label ?: 'Responsabile fornitore';
}
} }

View File

@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('fornitore_dipendenti')) {
return;
}
Schema::table('fornitore_dipendenti', function (Blueprint $table): void {
if (! Schema::hasColumn('fornitore_dipendenti', 'tipo_collaboratore')) {
$table->string('tipo_collaboratore', 40)
->default('interno')
->after('user_id');
}
if (! Schema::hasColumn('fornitore_dipendenti', 'fornitore_esterno_id')) {
$table->foreignId('fornitore_esterno_id')
->nullable()
->after('fornitore_id')
->constrained('fornitori')
->nullOnDelete();
}
if (! Schema::hasColumn('fornitore_dipendenti', 'rubrica_id')) {
$table->foreignId('rubrica_id')
->nullable()
->after('fornitore_esterno_id')
->constrained('rubrica_universale')
->nullOnDelete();
}
$table->index(['fornitore_id', 'tipo_collaboratore'], 'forn_dip_tipo_idx');
$table->index(['fornitore_esterno_id'], 'forn_dip_forn_est_idx');
});
}
public function down(): void
{
if (! Schema::hasTable('fornitore_dipendenti')) {
return;
}
Schema::table('fornitore_dipendenti', function (Blueprint $table): void {
$table->dropIndex('forn_dip_tipo_idx');
$table->dropIndex('forn_dip_forn_est_idx');
if (Schema::hasColumn('fornitore_dipendenti', 'rubrica_id')) {
$table->dropConstrainedForeignId('rubrica_id');
}
if (Schema::hasColumn('fornitore_dipendenti', 'fornitore_esterno_id')) {
$table->dropConstrainedForeignId('fornitore_esterno_id');
}
if (Schema::hasColumn('fornitore_dipendenti', 'tipo_collaboratore')) {
$table->dropColumn('tipo_collaboratore');
}
});
}
};

View File

@ -0,0 +1,66 @@
# Fornitore Operativo
## Obiettivo
La sezione fornitore deve avere due livelli d'uso distinti:
- Mobile e tablet: accesso rapido ai ticket assegnati, presa in carico, assegnazione collaboratore, rapportino operativo, allegati, sollecito, conferma apparato o seriale.
- Desktop: gestione estesa del fornitore, rubrica propria, collaboratori e subfornitori, preventivi, proforma, inventario seriali, ciclo di fatturazione e chiusura amministrativa.
## Stato attuale dopo questo step
Il fornitore oggi puo vedere e gestire:
- I ticket/interventi assegnati al proprio profilo fornitore.
- Lo stato operativo dell'intervento.
- La presa in carico del ticket.
- L'assegnazione dell'intervento a un collaboratore interno oppure a un altro fornitore registrato come subfornitore.
- Il rapportino di intervento.
- Tempo impiegato, ricambi o attivita, allegati, messaggio veloce allo studio.
- Marca, modello e seriale del bene lavorato dentro il rapporto operativo.
- La richiesta di proforma e le note operative di chiusura.
- Lo storico correlato e le comunicazioni del ticket.
Il fornitore non puo modificare:
- I dati originari del ticket inseriti o gestiti dall'amministratore.
- Il contenuto sorgente della segnalazione del chiamante.
- L'anagrafica amministrativa assegnata dal ticket.
## Collaboratori fornitore
La tabella collaboratori del fornitore ora distingue:
- Collaboratore interno: operatore o dipendente del fornitore.
- Altro fornitore: subfornitore esterno usato per delegare l'esecuzione.
Regole:
- Il collaboratore interno puo avere un account dedicato.
- Il subfornitore usa il proprio account esistente, non va duplicato.
- L'intervento resta del fornitore principale, ma l'esecutore operativo puo essere un subfornitore collegato.
## Strategia Mobile vs Desktop
### Mobile e tablet
- Lista ticket compatta.
- Scheda intervento compatta.
- Pulsanti grandi per presa in carico, assegnazione, allegati e rapporto.
- Nessuna schermata contabile o gestionale pesante.
### Desktop
- Rubrica fornitore.
- Gestione seriali e magazzino minimo.
- Preventivi e listini descrittivi.
- Proforma e collegamento alla fatturazione.
- Vista multi-amministratore e storico lavori.
## Prossimi step consigliati
1. Creare una rubrica proprietaria del fornitore, separata dai dati del ticket amministratore.
2. Estrarre apparato, seriale, marca e modello in entita dedicate invece di lasciarli solo nel testo rapporto.
3. Introdurre un oggetto rapportino con righe lavorazione e materiali.
4. Introdurre preventivo fornitore e conversione in proforma.
5. Introdurre visibilita multi-amministratore filtrata per studio e governo globale da super-admin.

View File

@ -0,0 +1,40 @@
# Posta Elettronica
## Stato attuale
In questo step la gestione posta è stata impostata come configurazione operativa, pronta per la lettura reale nei prossimi passaggi.
### Studio amministratore
La scheda amministratore ora può contenere:
- configurazione Google Workspace per Gmail
- label e query Gmail per la lettura selettiva
- import allegati e archivio post-import
- caselle aggiuntive studio in modalità Gmail, IMAP o PEC via IMAP
- regole base di instradamento allegati verso documenti e contabilità
### Stabile
Ogni stabile ora può avere:
- PEC condominio
- PEC amministratore riferita allo stabile
- una o più caselle email/PEC/IMAP dedicate allo stabile
- regole base per acquisire allegati da assicurazioni o altri mittenti rilevanti
## Obiettivo operativo
La pipeline prevista è:
1. leggere Gmail o IMAP dello studio o dello stabile
2. individuare i messaggi rilevanti con filtri mittente/query/cartella
3. acquisire allegati e descrizione
4. archiviare nei documenti
5. esporre gli allegati anche alla contabilità quando servono FE, PEC o documenti assicurativi
## Note implementative
- Le configurazioni dello studio sono salvate in `amministratori.impostazioni`.
- Le configurazioni dello stabile sono salvate in `stabili.configurazione_avanzata['posta']`.
- La lettura automatica dei messaggi non è ancora attiva in questo step: è pronto il perimetro di configurazione.

View File

@ -0,0 +1,25 @@
<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 justify-between gap-3">
<div>
<div class="text-lg font-semibold">Posta stabile</div>
<div class="text-sm text-gray-600">
@if($stabile)
Caselle email e PEC collegate a {{ $stabile->denominazione ?: ('Stabile #' . $stabile->id) }}.
@endif
</div>
</div>
<a href="{{ \App\Filament\Pages\Condomini\StabilePage::getUrl(['stabile_id' => (int) ($stabile?->id ?? 0)], panel: 'admin-filament') }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Torna allo stabile</a>
</div>
</div>
<form wire:submit="save" class="space-y-4">
{{ $this->getSchema('form') }}
<div class="flex flex-wrap items-center gap-3">
<x-filament::button type="submit">Salva configurazione</x-filament::button>
</div>
</form>
</div>
</x-filament-panels::page>

View File

@ -30,6 +30,9 @@
<a class="fi-btn fi-btn-color-gray fi-btn-size-sm" href="{{ \App\Filament\Pages\Contabilita\CasseBancheMovimenti::getUrl(panel: 'admin-filament') }}"> <a class="fi-btn fi-btn-color-gray fi-btn-size-sm" href="{{ \App\Filament\Pages\Contabilita\CasseBancheMovimenti::getUrl(panel: 'admin-filament') }}">
<span class="fi-btn-label">Movimenti / Import estratto</span> <span class="fi-btn-label">Movimenti / Import estratto</span>
</a> </a>
<a class="fi-btn fi-btn-color-gray fi-btn-size-sm" href="{{ \App\Filament\Pages\Condomini\StabilePosta::getUrl(['stabile_id' => (int) $stabile->id], panel: 'admin-filament') }}">
<span class="fi-btn-label">Posta stabile</span>
</a>
</div> </div>
</div> </div>

View File

@ -0,0 +1,162 @@
<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 justify-between gap-3">
<div>
<div class="text-lg font-semibold">Collaboratori fornitore</div>
<div class="text-sm text-gray-600">
@if($fornitore)
Gestione operatori, subfornitori e accessi per {{ $fornitore->ragione_sociale ?: trim(($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? '')) }}.
@else
Seleziona un fornitore dalla gestione ticket per aprire questa vista.
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $this->getTicketsUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Torna ai ticket</a>
<a href="{{ $this->getContabilitaUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Contabilita</a>
</div>
</div>
</div>
@if($this->missingAdminContext)
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-800">
Questa vista per amministratori richiede un fornitore selezionato.
</div>
@else
<div class="grid gap-4 xl:grid-cols-5">
<div class="rounded-xl border bg-white p-4 xl:col-span-2">
<div class="text-sm font-semibold">Nuovo collaboratore</div>
<div class="mt-1 text-xs text-gray-500">Puoi registrare sia un collaboratore interno sia un altro fornitore da usare come subfornitore operativo.</div>
<div class="mt-4 grid gap-3">
<label class="block text-sm">
<span class="mb-1 block font-medium">Tipo collaboratore</span>
<select wire:model.live="collaboratoreTipo" class="w-full rounded-lg border-gray-300">
<option value="interno">Collaboratore interno</option>
<option value="fornitore_esterno">Altro fornitore / subfornitore</option>
</select>
</label>
@if($collaboratoreTipo === 'fornitore_esterno')
<label class="block text-sm">
<span class="mb-1 block font-medium">Cerca fornitore</span>
<input type="text" wire:model.live.debounce.300ms="fornitoreSearch" class="w-full rounded-lg border-gray-300" placeholder="Ragione sociale, telefono, email, tag" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Fornitore da collegare</span>
<select wire:model="fornitoreEsternoId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona un fornitore</option>
@foreach($fornitoreOptions as $option)
<option value="{{ $option['id'] }}">{{ $option['label'] }}{{ $option['meta'] !== '' ? ' · ' . $option['meta'] : '' }}</option>
@endforeach
</select>
</label>
@else
<label class="block text-sm">
<span class="mb-1 block font-medium">Nome</span>
<input type="text" wire:model.defer="nome" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Cognome</span>
<input type="text" wire:model.defer="cognome" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Email</span>
<input type="email" wire:model.defer="email" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Telefono</span>
<input type="text" wire:model.defer="telefono" class="w-full rounded-lg border-gray-300" />
</label>
@endif
<label class="block text-sm">
<span class="mb-1 block font-medium">Note</span>
<textarea wire:model.defer="note" rows="3" class="w-full rounded-lg border-gray-300"></textarea>
</label>
@if($collaboratoreTipo === 'interno')
<label class="inline-flex items-center gap-2 text-sm">
<input type="checkbox" wire:model="createUserAccess" class="rounded border-gray-300" />
<span>Crea o collega account utente</span>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Password accesso</span>
<input type="text" wire:model.defer="accessPassword" class="w-full rounded-lg border-gray-300" placeholder="Minimo 8 caratteri" />
</label>
@else
<div class="rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-800">
Il subfornitore usa il suo account esistente. Qui salvi solo il collegamento operativo.
</div>
@endif
<x-filament::button wire:click="createCollaboratore">Salva collaboratore</x-filament::button>
</div>
</div>
<div class="rounded-xl border bg-white p-0 xl:col-span-3">
<div class="border-b px-4 py-3 text-sm font-semibold">Elenco collaboratori</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Nome</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Tipo</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Contatti</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Accesso</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Stato</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
@forelse($rows as $row)
<tr>
<td class="px-4 py-3 align-top">
<div class="font-medium">{{ $row['nome'] }}</div>
@if($row['note'] !== '')
<div class="text-xs text-gray-500">{{ $row['note'] }}</div>
@endif
</td>
<td class="px-4 py-3 align-top">
<span class="inline-flex rounded-full bg-sky-100 px-2 py-1 text-xs font-medium text-sky-700">{{ $row['tipo_label'] }}</span>
@if($row['fornitore_esterno_nome'] !== '')
<div class="mt-1 text-xs text-gray-500">{{ $row['fornitore_esterno_nome'] }}</div>
@endif
</td>
<td class="px-4 py-3 align-top">
<div>{{ $row['email'] !== '' ? $row['email'] : '-' }}</div>
<div class="text-xs text-gray-500">{{ $row['telefono'] !== '' ? $row['telefono'] : '-' }}</div>
</td>
<td class="px-4 py-3 align-top">
@if($row['user_id'])
<span class="inline-flex rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Account collegato</span>
@else
<span class="inline-flex rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Nessun account</span>
@endif
</td>
<td class="px-4 py-3 align-top">
<span class="inline-flex rounded-full px-2 py-1 text-xs font-medium {{ $row['attivo'] ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-700' }}">{{ $row['attivo'] ? 'Attivo' : 'Disattivo' }}</span>
</td>
<td class="px-4 py-3 text-right align-top">
<div class="flex flex-wrap justify-end gap-2">
@if($row['tipo'] === 'interno' && ! $row['user_id'])
<button type="button" wire:click="abilitaAccesso({{ $row['id'] }})" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Abilita accesso</button>
@elseif($row['tipo'] === 'interno' && $row['user_id'])
<button type="button" wire:click="resetPasswordDipendenteUser({{ $row['user_id'] }})" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Reset password</button>
@else
<span class="inline-flex items-center rounded-md bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600">Accesso dal fornitore collegato</span>
@endif
<button type="button" wire:click="toggleDipendenteAttivo({{ $row['id'] }})" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">{{ $row['attivo'] ? 'Disattiva' : 'Attiva' }}</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-10 text-center text-sm text-gray-500">Nessun collaboratore registrato.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@endif
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,138 @@
<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 justify-between gap-3">
<div>
<div class="text-lg font-semibold">Contabilita fornitore</div>
<div class="text-sm text-gray-600">
@if($fornitoreLabel)
Vista contabile sintetica per {{ $fornitoreLabel }}.
@else
Seleziona un fornitore per aprire questa vista.
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $this->getTicketsUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Ticket</a>
<a href="{{ $this->getCollaboratoriUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Collaboratori</a>
</div>
</div>
</div>
@if($missingAdminContext)
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-800">
Questa vista per amministratori richiede un fornitore selezionato.
</div>
@else
<div class="grid gap-4 md:grid-cols-4">
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-gray-500">Fatture aperte</div>
<div class="mt-1 text-2xl font-semibold">{{ $stats['aperte'] }}</div>
<div class="mt-2 text-sm text-gray-600">Registrate ma non ancora pagate.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-gray-500">Fatture pagate</div>
<div class="mt-1 text-2xl font-semibold">{{ $stats['pagate'] }}</div>
<div class="mt-2 text-sm text-gray-600">Pagamenti risultanti in contabilità.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-gray-500">Netto aperto</div>
<div class="mt-1 text-2xl font-semibold">{{ number_format($stats['totale_aperto'], 2, ',', '.') }} </div>
<div class="mt-2 text-sm text-gray-600">Da incassare sui documenti registrati.</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-xs uppercase tracking-wide text-gray-500">Totale registrato</div>
<div class="mt-1 text-2xl font-semibold">{{ number_format($stats['totale_registrato'], 2, ',', '.') }} </div>
<div class="mt-2 text-sm text-gray-600">Storico fatture del fornitore su NetGescon.</div>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-5">
<div class="rounded-xl border bg-white p-0 xl:col-span-3">
<div class="border-b px-4 py-3 text-sm font-semibold">Lavori pronti per chiusura / fatturazione</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Ticket</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Amministratore</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Stato</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Aggiornato</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
@forelse($queueRows as $row)
<tr>
<td class="px-4 py-3 align-top">
<div class="font-medium">#{{ $row['ticket_id'] }} - {{ $row['titolo'] }}</div>
<div class="text-xs text-gray-500">{{ $row['stabile'] }}</div>
</td>
<td class="px-4 py-3 align-top">{{ $row['amministratore'] }}</td>
<td class="px-4 py-3 align-top">
<span class="inline-flex rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">{{ $row['stato'] }}</span>
</td>
<td class="px-4 py-3 align-top text-gray-600">{{ $row['aggiornato'] }}</td>
<td class="px-4 py-3 text-right align-top">
<a href="{{ $row['url'] }}" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Apri</a>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-10 text-center text-sm text-gray-500">Nessun lavoro in coda per la chiusura.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="rounded-xl border bg-white p-0 xl:col-span-2">
<div class="border-b px-4 py-3 text-sm font-semibold">Fatture registrate su NetGescon</div>
@if(! $contabilitaReady)
<div class="p-4 text-sm text-gray-600">
La contabilità fornitore dedicata non è ancora stata attivata su questo ambiente. Questa pagina è pronta a leggerla appena il modulo contabile è disponibile.
</div>
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Documento</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Studio</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Totale</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
@forelse($fattureRows as $row)
<tr>
<td class="px-4 py-3 align-top">
<div class="font-medium">{{ $row['numero_documento'] }}</div>
<div class="text-xs text-gray-500">{{ $row['data_documento'] }} · {{ $row['stato'] }}</div>
@if($row['descrizione'] !== '')
<div class="mt-1 text-xs text-gray-500">{{ $row['descrizione'] }}</div>
@endif
</td>
<td class="px-4 py-3 align-top">
<div>{{ $row['amministratore'] }}</div>
<div class="text-xs text-gray-500">{{ $row['stabile'] }}</div>
</td>
<td class="px-4 py-3 align-top">
<div class="font-medium">{{ number_format($row['totale'], 2, ',', '.') }} </div>
<div class="text-xs text-gray-500">Netto {{ number_format($row['netto'], 2, ',', '.') }} </div>
</td>
</tr>
@empty
<tr>
<td colspan="3" class="px-4 py-10 text-center text-sm text-gray-500">Nessuna fattura registrata per questo fornitore.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
</div>
</div>
@endif
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,232 @@
<x-filament-panels::page>
@php($ticket = $this->intervento->ticket)
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-lg font-semibold">Intervento ticket #{{ $this->intervento->ticket_id }}</div>
<div class="mt-1 text-sm text-gray-600">{{ $ticket->titolo ?? '-' }} · {{ $ticket->stabile->denominazione ?? '-' }}</div>
<div class="mt-2 text-xs text-gray-500">Fornitore: {{ $this->fornitore->ragione_sociale ?: trim(($this->fornitore->nome ?? '') . ' ' . ($this->fornitore->cognome ?? '')) }}</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $this->getTicketsUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Torna ai ticket</a>
<a href="{{ $this->getCollaboratoriUrl() }}" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Collaboratori</a>
</div>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-3">
<div class="space-y-4 xl:col-span-2">
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Scheda segnalazione</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs uppercase tracking-wide text-gray-500">Richiedente</div>
<div class="mt-1 text-sm">{{ $caller['contatto'] ?: '-' }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-gray-500">Telefono</div>
<div class="mt-1 text-sm">
@if($caller['telefono'] !== '')
<a href="tel:{{ preg_replace('/\s+/', '', (string) $caller['telefono']) }}" class="text-primary-600 hover:underline">{{ $caller['telefono'] }}</a>
@else
-
@endif
</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-gray-500">Stato intervento</div>
<div class="mt-1 text-sm">{{ $this->intervento->stato }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-gray-500">Operatore</div>
<div class="mt-1 text-sm">{{ $this->intervento->operatore_assegnato_label }}</div>
</div>
</div>
<div class="mt-4 whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-sm text-gray-700">{{ $ticket->descrizione }}</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-semibold">Operativo e rapportino</div>
@if($this->intervento->stato === 'assegnato')
<x-filament::button size="sm" wire:click="startIntervento">Prendi in carico</x-filament::button>
@endif
</div>
@if($this->canAssignDipendente())
<div class="mt-4 rounded-lg border border-dashed p-3">
<div class="text-xs uppercase tracking-wide text-gray-500">Assegnazione operatore</div>
<div class="mt-2 flex flex-wrap items-end gap-3">
<label class="block min-w-72 flex-1 text-sm">
<span class="mb-1 block font-medium">Collaboratore incaricato</span>
<select wire:model="dipendenteId" class="w-full rounded-lg border-gray-300">
<option value="">Responsabile fornitore / non assegnato</option>
@foreach($dipendentiOptions as $option)
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
</label>
<x-filament::button color="gray" wire:click="assignDipendente">Salva assegnazione</x-filament::button>
</div>
</div>
@endif
<div class="mt-4 grid gap-3 md:grid-cols-3">
<label class="block text-sm">
<span class="mb-1 block font-medium">Marca apparato</span>
<input type="text" wire:model.defer="apparatoMarca" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Modello</span>
<input type="text" wire:model.defer="apparatoModello" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Seriale</span>
<input type="text" wire:model.defer="apparatoSeriale" class="w-full rounded-lg border-gray-300" />
</label>
</div>
<label class="mt-4 block text-sm">
<span class="mb-1 block font-medium">Rapporto intervento</span>
<textarea wire:model.defer="rapportoFornitore" rows="6" class="w-full rounded-lg border-gray-300"></textarea>
</label>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Tempo impiegato (minuti)</span>
<input type="number" min="1" max="1440" wire:model.defer="tempoMinuti" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">QR token</span>
<input type="text" wire:model.defer="qrToken" class="w-full rounded-lg border-gray-300" placeholder="Inserisci il codice se scansionato" />
</label>
</div>
<div class="mt-4 grid gap-2 md:grid-cols-3">
@foreach($articoliUtilizzati as $index => $item)
<label class="block text-sm">
<span class="mb-1 block font-medium">Ricambio/attivita {{ $index + 1 }}</span>
<input type="text" wire:model.defer="articoliUtilizzati.{{ $index }}" class="w-full rounded-lg border-gray-300" />
</label>
@endforeach
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Foto lavoro</span>
<input type="file" wire:model="fotoLavoro" multiple accept="image/*" class="w-full rounded-lg border-gray-300 text-sm" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Documenti lavoro</span>
<input type="file" wire:model="documentiLavoro" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,image/*" class="w-full rounded-lg border-gray-300 text-sm" />
</label>
</div>
<div class="mt-4 grid gap-2 md:grid-cols-2">
@foreach($descrizioneFile as $index => $item)
<label class="block text-sm">
<span class="mb-1 block font-medium">Descrizione file {{ $index + 1 }}</span>
<input type="text" wire:model.defer="descrizioneFile.{{ $index }}" class="w-full rounded-lg border-gray-300" />
</label>
@endforeach
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Messaggio veloce allo studio</span>
<textarea wire:model.defer="messaggioVeloce" rows="3" class="w-full rounded-lg border-gray-300"></textarea>
</label>
<div class="grid gap-3">
<label class="block text-sm">
<span class="mb-1 block font-medium">Codice proforma</span>
<input type="text" wire:model.defer="proformaCodice" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Note proforma</span>
<textarea wire:model.defer="proformaNote" rows="3" class="w-full rounded-lg border-gray-300"></textarea>
</label>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-4 text-sm">
<label class="inline-flex items-center gap-2">
<input type="checkbox" wire:model="richiestaProforma" class="rounded border-gray-300" />
<span>Richiesta proforma</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" wire:model="lavoroStandard" class="rounded border-gray-300" />
<span>Lavoro standard gia autorizzato</span>
</label>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<x-filament::button wire:click="saveRapporto">Salva avanzamento</x-filament::button>
<x-filament::button color="gray" wire:click="inviaSollecito">Invia sollecito</x-filament::button>
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Comunicazioni ticket</div>
<div class="mt-3 space-y-2">
@forelse($ticket->messages as $msg)
<div class="rounded-lg border p-3 text-sm">
<div class="font-medium">{{ $msg->user->name ?? 'Sistema' }}</div>
<div class="mt-1 whitespace-pre-wrap text-gray-700">{{ $msg->messaggio }}</div>
<div class="mt-1 text-xs text-gray-500">{{ optional($msg->created_at)->format('d/m/Y H:i') }}</div>
</div>
@empty
<div class="text-sm text-gray-500">Nessuna comunicazione disponibile.</div>
@endforelse
</div>
</div>
</div>
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Stato operativo</div>
<div class="mt-3 space-y-2 text-sm">
<div><span class="font-medium">Stato:</span> {{ $this->intervento->stato }}</div>
<div><span class="font-medium">Iniziato:</span> {{ optional($this->intervento->iniziato_at)->format('d/m/Y H:i') ?: '-' }}</div>
<div><span class="font-medium">Terminato:</span> {{ optional($this->intervento->terminato_at)->format('d/m/Y H:i') ?: '-' }}</div>
<div><span class="font-medium">Rif. chiusura:</span> {{ $this->intervento->riferimento_chiusura ?: '-' }}</div>
<div><span class="font-medium">QR registrato:</span> {{ $this->intervento->qr_token ?: '-' }}</div>
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Allegati ticket</div>
<div class="mt-3 space-y-2">
@forelse($ticket->attachments as $attachment)
<div class="rounded-lg border p-3 text-sm">
<div class="font-medium">{{ $attachment->original_file_name }}</div>
<div class="text-xs text-gray-500">{{ $attachment->description ?: 'Nessuna descrizione' }}</div>
<a href="{{ route('filament.tickets.attachments.view', ['attachment' => (int) $attachment->id]) }}" target="_blank" class="mt-2 inline-flex text-xs font-medium text-primary-600 hover:underline">Apri allegato</a>
</div>
@empty
<div class="text-sm text-gray-500">Nessun allegato disponibile.</div>
@endforelse
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Storico correlato</div>
<div class="mt-3 space-y-2">
@forelse($storicoRows as $row)
<div class="rounded-lg border p-3 text-sm">
<div class="font-medium">#{{ $row['ticket_id'] }} - {{ $row['titolo'] }}</div>
<div class="text-xs text-gray-500">{{ $row['stato'] }} · {{ $row['updated_at'] }}</div>
@if($row['note'] !== '')
<div class="mt-1 text-xs text-gray-600">{{ \Illuminate\Support\Str::limit($row['note'], 90) }}</div>
@endif
</div>
@empty
<div class="text-sm text-gray-500">Nessuno storico correlato.</div>
@endforelse
</div>
</div>
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,94 @@
<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 justify-between gap-3">
<div>
<div class="text-lg font-semibold">Ticket operativi fornitore</div>
<div class="text-sm text-gray-600">
@if($this->fornitoreLabel)
Vista operativa attiva per {{ $this->fornitoreLabel }}.
@else
Seleziona un fornitore dalla gestione ticket per aprire questa vista.
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $this->getCollaboratoriUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Collaboratori</a>
<a href="{{ $this->getContabilitaUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Contabilita</a>
</div>
</div>
</div>
@if($this->missingAdminContext)
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-800">
Questa vista per amministratori richiede un fornitore selezionato. Aprila dalla gestione ticket oppure usa un link con il parametro fornitore.
</div>
@else
<div class="grid gap-4 md:grid-cols-3">
<button type="button" wire:click="setStatus('aperti')" class="rounded-xl border p-4 text-left {{ $status === 'aperti' ? 'border-primary-500 bg-primary-50' : 'bg-white' }}">
<div class="text-xs uppercase tracking-wide text-gray-500">Aperti</div>
<div class="mt-1 text-2xl font-semibold">{{ $totals['aperti'] }}</div>
<div class="mt-2 text-sm text-gray-600">Assegnati, in corso o in verifica.</div>
</button>
<button type="button" wire:click="setStatus('fatturabili')" class="rounded-xl border p-4 text-left {{ $status === 'fatturabili' ? 'border-primary-500 bg-primary-50' : 'bg-white' }}">
<div class="text-xs uppercase tracking-wide text-gray-500">Fatturabili</div>
<div class="mt-1 text-2xl font-semibold">{{ $totals['fatturabili'] }}</div>
<div class="mt-2 text-sm text-gray-600">Interventi pronti o gia fatturati.</div>
</button>
<button type="button" wire:click="setStatus('chiusi')" class="rounded-xl border p-4 text-left {{ $status === 'chiusi' ? 'border-primary-500 bg-primary-50' : 'bg-white' }}">
<div class="text-xs uppercase tracking-wide text-gray-500">Chiusi</div>
<div class="mt-1 text-2xl font-semibold">{{ $totals['chiusi'] }}</div>
<div class="mt-2 text-sm text-gray-600">Storico completato del fornitore.</div>
</button>
</div>
<div class="rounded-xl border bg-white p-0">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Ticket</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Contatto</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Problema</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Operatore</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Stato</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Aggiornato</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
@forelse($rows as $row)
<tr>
<td class="px-4 py-3 align-top">
<div class="font-medium">#{{ $row['ticket_id'] }} - {{ $row['titolo'] }}</div>
<div class="text-xs text-gray-500">{{ $row['stabile'] }} · ingresso {{ $row['ingresso'] }}</div>
</td>
<td class="px-4 py-3 align-top">
<div>{{ $row['contatto'] }}</div>
<div class="text-xs text-gray-500">{{ $row['telefono'] !== '' ? $row['telefono'] : 'Telefono non disponibile' }}</div>
</td>
<td class="px-4 py-3 align-top">
<div>{{ $row['problema'] }}</div>
<div class="text-xs text-gray-500">Apparato: {{ $row['apparato'] }}</div>
</td>
<td class="px-4 py-3 align-top">{{ $row['operatore'] }}</td>
<td class="px-4 py-3 align-top">
<span class="inline-flex rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">{{ $row['stato'] }}</span>
</td>
<td class="px-4 py-3 align-top text-gray-600">{{ $row['updated_at'] }}</td>
<td class="px-4 py-3 text-right align-top">
<a href="{{ $row['url'] }}" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Apri scheda</a>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-10 text-center text-sm text-gray-500">Nessun intervento trovato per questo filtro.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
</div>
</x-filament-panels::page>