feat: aggiorna supporto e area fornitore operative

This commit is contained in:
michele 2026-04-06 09:27:26 +00:00
parent 920f20cab1
commit e44a3f039c
18 changed files with 1115 additions and 58 deletions

View File

@ -5,6 +5,11 @@ # Changelog
## [Unreleased]
- Initial open-source release prep.
- Added immediate local preview for Ticket Mobile photos and attachments before final send on phone.
- Split insurance handling out of Ticket Gestione into dedicated Tab 4 and added centralized Supporto claims list.
- Fixed supplier ticket-detail access by resolving both intervention-id and ticket-id safely in supplier context.
- Added supplier quick modal from ticket list plus ticket-attachment thumbnails and modal preview in supplier scheda.
- Aligned supplier collaborator external-supplier search to the same result-selection pattern already used in ticket assignment.
- Added Git-first staging update guidance in Supporto > Modifiche, centered on the `netgescon:git-sync` command and UI sync flow from Gitea.
- Added materialization of `persone` and `persone_unita_relazioni` from imported unit nominatives, plus a dedicated `relazioni` step in `gescon:import-full`.
- Hardened Panasonic Windows live bridge scripts, runtime publish flow, and TAPI diagnostics for local-path deployment and aggregated Panasonic addresses.

View File

@ -97,6 +97,9 @@ class Collaboratori extends Page
/** @var array<int, array<string, mixed>> */
public array $fornitoreOptions = [];
/** @var array<int, array<string, mixed>> */
public array $fornitoreMatches = [];
public static function canAccess(): bool
{
$user = Auth::user();
@ -125,6 +128,7 @@ public function updatedCollaboratoreTipo(): void
if ($this->collaboratoreTipo !== FornitoreDipendente::TIPO_FORNITORE_ESTERNO) {
$this->fornitoreEsternoId = null;
$this->fornitoreSearch = '';
$this->fornitoreMatches = [];
$this->createUserAccess = false;
}
@ -136,6 +140,32 @@ public function updatedFornitoreSearch(): void
$this->refreshFornitoreOptions();
}
public function selezionaFornitoreEsterno(int $fornitoreId): void
{
$match = collect($this->fornitoreMatches)->firstWhere('id', $fornitoreId);
if (! is_array($match)) {
$fornitore = $this->resolveFornitoreEsterno($fornitoreId);
if (! $fornitore instanceof Fornitore) {
return;
}
$match = $this->mapFornitoreOption($fornitore);
}
$this->fornitoreEsternoId = (int) $match['id'];
$this->fornitoreSearch = (string) $match['label'];
$this->fornitoreMatches = [];
}
public function resetFornitoreEsternoSelection(): void
{
$this->fornitoreEsternoId = null;
$this->fornitoreSearch = '';
$this->fornitoreMatches = [];
$this->refreshFornitoreOptions();
}
public function createCollaboratore(): void
{
if (! $this->fornitore instanceof Fornitore) {
@ -467,6 +497,7 @@ protected function refreshFornitoreOptions(): void
{
if (! $this->fornitore instanceof Fornitore || $this->collaboratoreTipo !== FornitoreDipendente::TIPO_FORNITORE_ESTERNO) {
$this->fornitoreOptions = [];
$this->fornitoreMatches = [];
return;
}
@ -491,17 +522,19 @@ function (Builder $query) use ($search): void {
->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(' · ')),
])
->get(['id', 'ragione_sociale', 'nome', 'cognome', 'email', 'telefono', 'cellulare', 'tags', 'note'])
->map(fn(Fornitore $fornitore) => $this->mapFornitoreOption($fornitore))
->all();
$this->fornitoreMatches = $this->fornitoreOptions;
if ($this->fornitoreEsternoId && ! collect($this->fornitoreMatches)->contains('id', $this->fornitoreEsternoId)) {
$selected = $this->resolveFornitoreEsterno((int) $this->fornitoreEsternoId);
if ($selected instanceof Fornitore) {
array_unshift($this->fornitoreMatches, $this->mapFornitoreOption($selected));
$this->fornitoreMatches = array_slice($this->fornitoreMatches, 0, 20);
}
}
}
protected function resolveFornitoreEsterno(int $fornitoreId): ?Fornitore
@ -537,12 +570,30 @@ protected function resetForm(): void
$this->collaboratoreTipo = FornitoreDipendente::TIPO_INTERNO;
$this->fornitoreSearch = '';
$this->fornitoreEsternoId = null;
$this->fornitoreMatches = [];
$this->note = '';
$this->createUserAccess = false;
$this->accessPassword = '';
$this->refreshFornitoreOptions();
}
/**
* @return array<string,mixed>
*/
protected function mapFornitoreOption(Fornitore $fornitore): array
{
return [
'id' => (int) $fornitore->id,
'label' => $this->getFornitoreLabel($fornitore),
'meta' => trim(collect([
$fornitore->email,
$fornitore->telefono ?: $fornitore->cellulare,
$fornitore->tags,
])->filter()->implode(' · ')),
'note' => Str::limit(trim((string) ($fornitore->note ?? '')), 120),
];
}
protected function upsertRubricaInterna(array $validated, FornitoreDipendente $dipendente): int
{
$email = trim((string) ($validated['email'] ?? ''));

View File

@ -11,6 +11,7 @@
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Livewire\WithFileUploads;
@ -91,6 +92,8 @@ class TicketInterventoScheda extends Page
public string $qrToken = '';
public ?array $attachmentPreview = null;
public static function canAccess(): bool
{
$user = Auth::user();
@ -101,10 +104,26 @@ public static function canAccess(): bool
public function mount(int | string $record): void
{
$this->intervento = TicketIntervento::query()->findOrFail((int) $record);
$recordId = (int) $record;
abort_unless($recordId > 0, 404);
[$fornitore, $dipendente] = $this->resolveOperatoreContext((int) $this->intervento->fornitore_id);
abort_unless($fornitore instanceof Fornitore, 404);
$user = Auth::user();
$isAdmin = $user instanceof User && $user->hasAnyRole(['super-admin', 'admin', 'amministratore']);
if ($isAdmin) {
$intervento = $this->resolveInterventoRecord($recordId);
abort_unless($intervento instanceof TicketIntervento, 404);
[$fornitore, $dipendente] = $this->resolveOperatoreContext((int) $intervento->fornitore_id);
} else {
[$fornitore, $dipendente] = $this->resolveOperatoreContext();
abort_unless($fornitore instanceof Fornitore, 403, 'Profilo fornitore non collegato.');
$intervento = $this->resolveInterventoRecord($recordId, $fornitore, $dipendente);
abort_unless($intervento instanceof TicketIntervento, 404, 'Ticket/intervento non disponibile per questo fornitore.');
}
$this->intervento = $intervento;
$this->fornitore = $fornitore;
$this->dipendente = $dipendente;
@ -305,6 +324,7 @@ public function saveRapporto(): void
$this->messaggioVeloce = '';
$this->proformaCodice = '';
$this->proformaNote = '';
$this->dispatch('fornitore-local-previews-clear');
$this->reloadIntervento();
@ -332,6 +352,34 @@ public function getTicketsUrl(): string
return TicketOperativi::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament');
}
public function getAttachmentPublicUrl(TicketAttachment $attachment): string
{
return route('filament.tickets.attachments.view', ['attachment' => (int) $attachment->id]);
}
public function openAttachmentPreview(int $attachmentId): void
{
$attachment = $this->intervento->ticket->attachments->firstWhere('id', $attachmentId);
if (! $attachment instanceof TicketAttachment) {
return;
}
$this->attachmentPreview = [
'id' => (int) $attachment->id,
'name' => (string) ($attachment->original_file_name ?? 'allegato'),
'description' => (string) ($attachment->description ?? ''),
'url' => $this->getAttachmentPublicUrl($attachment),
'is_image' => $attachment->isImage(),
'is_pdf' => $attachment->isPdf(),
'mime' => $attachment->resolvedMimeType(),
];
}
public function closeAttachmentPreview(): void
{
$this->attachmentPreview = null;
}
public function getCollaboratoriUrl(): string
{
return Collaboratori::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament');
@ -342,6 +390,31 @@ public function canAssignDipendente(): bool
return ! ($this->dipendente instanceof FornitoreDipendente) && count($this->dipendentiOptions) > 0;
}
protected function resolveInterventoRecord(int $recordId, ?Fornitore $fornitore = null, ?FornitoreDipendente $dipendente = null): ?TicketIntervento
{
$query = TicketIntervento::query();
if ($fornitore instanceof Fornitore) {
$query->where('fornitore_id', (int) $fornitore->id);
}
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
->where(function (Builder $builder) use ($recordId): void {
$builder->whereKey($recordId)
->orWhere('ticket_id', $recordId);
})
->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [$recordId])
->latest('id')
->first();
}
protected function reloadIntervento(): void
{
$this->intervento->load([

View File

@ -4,9 +4,11 @@
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\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
@ -48,6 +50,8 @@ class TicketOperativi extends Page
'fatturabili' => 0,
];
public ?array $detailModal = null;
public static function canAccess(): bool
{
$user = Auth::user();
@ -148,6 +152,71 @@ public function getContabilitaUrl(): string
return Contabilita::getUrl(panel: 'admin-filament');
}
public function openInterventoModal(int $interventoId): void
{
if (! $this->fornitoreId) {
Notification::make()->title('Seleziona prima un fornitore')->warning()->send();
return;
}
[$fornitore, $dipendente] = $this->resolveOperatoreContext($this->fornitoreId);
$intervento = $this->buildBaseQuery($fornitore, $dipendente)
->with(['ticket.attachments.user', 'ticket.messages.user'])
->find($interventoId);
if (! $intervento instanceof TicketIntervento) {
Notification::make()->title('Intervento non disponibile per questo fornitore')->danger()->send();
return;
}
$base = $this->buildInterventoRow($intervento);
$attachments = $intervento->ticket->attachments
->map(function (TicketAttachment $attachment): array {
return [
'id' => (int) $attachment->id,
'name' => (string) ($attachment->original_file_name ?? 'allegato'),
'description' => (string) ($attachment->description ?? ''),
'url' => route('filament.tickets.attachments.view', ['attachment' => (int) $attachment->id]),
'is_image' => $attachment->isImage(),
'is_pdf' => $attachment->isPdf(),
'created_at' => optional($attachment->created_at)->format('d/m/Y H:i') ?: '-',
];
})
->all();
$messages = $intervento->ticket->messages
->sortByDesc('created_at')
->take(6)
->map(fn($message) => [
'author' => (string) ($message->user->name ?? 'Sistema'),
'body' => (string) ($message->messaggio ?? ''),
'created_at' => optional($message->created_at)->format('d/m/Y H:i') ?: '-',
])
->values()
->all();
$this->detailModal = [
'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 ?? '-'),
'contatto' => $base['contatto'],
'telefono' => $base['telefono'],
'problema' => $base['problema'],
'descrizione' => (string) ($intervento->ticket->descrizione ?? ''),
'url' => TicketInterventoScheda::getUrl(['record' => (int) $intervento->id], panel: 'admin-filament'),
'attachments' => $attachments,
'messages' => $messages,
];
}
public function closeInterventoModal(): void
{
$this->detailModal = null;
}
protected function buildBaseQuery(Fornitore $fornitore, ?FornitoreDipendente $dipendente): Builder
{
$query = TicketIntervento::query()

View File

@ -0,0 +1,145 @@
<?php
namespace App\Filament\Pages\Supporto;
use App\Models\InsuranceClaim;
use App\Models\User;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class GestioneAssicurazioni extends Page
{
protected static ?string $navigationLabel = 'Pratiche Assicurative';
protected static ?string $title = 'Gestione Pratiche Assicurative';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shield-check';
protected static UnitEnum|string|null $navigationGroup = 'Supporto';
protected static ?int $navigationSort = 27;
protected static ?string $slug = 'supporto/pratiche-assicurative';
protected string $view = 'filament.pages.supporto.gestione-assicurazioni';
public string $status = 'aperte';
public ?int $focusTicketId = null;
/** @var array<int,array<string,mixed>> */
public array $rows = [];
/** @var array<string,int> */
public array $totals = [
'aperte' => 0,
'chiuse' => 0,
'tutte' => 0,
];
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}
public function mount(): void
{
$this->status = (string) request()->query('status', 'aperte');
$this->focusTicketId = (int) request()->query('ticket', 0) ?: null;
$this->refreshData();
}
public function updatedStatus(): void
{
$this->refreshData();
}
public function setStatus(string $status): void
{
$this->status = in_array($status, ['aperte', 'chiuse', 'tutte'], true) ? $status : 'aperte';
$this->refreshData();
}
public function refreshData(): void
{
$stabileIds = $this->resolveAccessibleStabileIds();
if ($stabileIds === []) {
$this->rows = [];
$this->totals = ['aperte' => 0, 'chiuse' => 0, 'tutte' => 0];
return;
}
$base = InsuranceClaim::query()
->with(['ticket.stabile:id,denominazione', 'insurancePolicy:id,company_name,policy_number'])
->whereIn('stabile_id', $stabileIds);
if ($this->focusTicketId) {
$base->where('ticket_id', (int) $this->focusTicketId);
}
$query = clone $base;
if ($this->status === 'aperte') {
$query->whereNotIn('status', ['chiusa', 'chiuso', 'archiviata']);
} elseif ($this->status === 'chiuse') {
$query->whereIn('status', ['chiusa', 'chiuso', 'archiviata']);
}
$this->rows = $query
->latest('opened_at')
->latest('id')
->limit(120)
->get()
->map(function (InsuranceClaim $claim): array {
return [
'id' => (int) $claim->id,
'ticket_id' => (int) $claim->ticket_id,
'ticket_title' => (string) ($claim->ticket->titolo ?? '-'),
'stabile' => (string) ($claim->ticket->stabile->denominazione ?? '-'),
'claim_number' => (string) ($claim->claim_number ?? ''),
'policy_reference' => (string) ($claim->policy_reference ?? ''),
'policy_label' => trim(implode(' · ', array_filter([
(string) ($claim->insurancePolicy->company_name ?? ''),
(string) ($claim->insurancePolicy->policy_number ?? ''),
]))),
'status' => (string) ($claim->status ?? 'aperta'),
'opened_at' => optional($claim->opened_at)->format('d/m/Y H:i') ?: '-',
'next_action' => (string) (data_get($claim->metadata, 'next_action') ?? ''),
'documents_sent_at' => filled(data_get($claim->metadata, 'documents_sent_at')) ? (string) data_get($claim->metadata, 'documents_sent_at') : '',
'ticket_url' => TicketGestione::getUrl([
'ticket' => (int) $claim->ticket_id,
'tab' => 'assicurazione',
], panel: 'admin-filament'),
];
})
->all();
$this->totals = [
'aperte' => (clone $base)->whereNotIn('status', ['chiusa', 'chiuso', 'archiviata'])->count(),
'chiuse' => (clone $base)->whereIn('status', ['chiusa', 'chiuso', 'archiviata'])->count(),
'tutte' => (clone $base)->count(),
];
}
/**
* @return array<int,int>
*/
private function resolveAccessibleStabileIds(): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
return StabileContext::accessibleStabili($user)
->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn(int $value) => $value > 0)
->values()
->all();
}
}

View File

@ -141,6 +141,8 @@ public function mount(): void
$queryTab = (string) request()->query('tab', '');
if ($queryTab === 'scheda') {
$this->activeTab = 'scheda';
} elseif ($queryTab === 'assicurazione') {
$this->activeTab = 'assicurazione';
}
if (request()->query('status') === 'all') {
@ -255,6 +257,16 @@ public function apriScheda(int $ticketId): void
$this->syncSelectedTicketState();
}
public function apriAssicurazioneTicket(?int $ticketId = null): void
{
if (($ticketId ?? 0) > 0) {
$this->selectedTicketId = (int) $ticketId;
}
$this->activeTab = 'assicurazione';
$this->syncSelectedTicketState();
}
public function apriElenco(): void
{
$this->activeTab = 'elenco';
@ -275,6 +287,17 @@ public function getFornitoreAnagraficaUrl(int $fornitoreId): string
return \App\Filament\Pages\Gescon\FornitoreScheda::getUrl(['record' => $fornitoreId], panel: 'admin-filament');
}
public function getInsuranceClaimsUrl(): string
{
$parameters = [];
if ((int) ($this->selectedTicketId ?? 0) > 0) {
$parameters['ticket'] = (int) $this->selectedTicketId;
}
return GestioneAssicurazioni::getUrl($parameters, panel: 'admin-filament');
}
public function getAttachmentPublicUrl(TicketAttachment $attachment): string
{
return route('filament.tickets.attachments.view', ['attachment' => (int) $attachment->id]);

View File

@ -624,6 +624,7 @@ public function creaTicketRapido(): void
$this->newTicketTipoIntervento = 'altro';
$this->newTicketFotoNote = null;
$this->resetDraftUploadState();
$this->dispatch('ticket-mobile-draft-reset');
$this->refreshData();
}
@ -849,6 +850,8 @@ private function appendPendingUploads(string $source): void
if (count($accepted) > 0) {
$queueTotal = count($this->newTicketCameraShots) + count($this->newTicketAttachments);
$this->dispatch('ticket-mobile-local-previews-clear', source: $source);
Notification::make()
->title('File accodati alla bozza ticket')
->body(count($accepted) . ' file aggiunti. Totale attuale in coda: ' . $queueTotal . '.')

View File

@ -1,6 +1,6 @@
# NetGescon — TODO Copilot Tracker
Data aggiornamento: 15 dicembre 2025
Data aggiornamento: 6 aprile 2026
Questo file tiene traccia di:
- cosa è stato fatto (implementato e/o corretto)
@ -68,6 +68,14 @@ ### A) Riallineare le “Gestioni” per la contabilità
## 3) Da fare (funzionalità richieste, in ordine consigliato)
### 3.0 Area fornitore/manutentore 2026
- Unificare la rubrica operativa usando la stessa maschera principale condivisa tra amministratore, fornitore e moduli collegati; differenziare accesso e persistenza per ruolo, non l impianto della view.
- Aggiungere una pagina ad hoc per i ticket interni del fornitore con elenco unico di lavorazioni: ticket passati dall amministratore + lavorazioni interne + storico operativo.
- Estendere `fornitore/collaboratori` con gestione orari di lavoro, disponibilita e futura assegnazione lavori ricorrenti.
- Introdurre il dominio lavori ricorrenti/programmati distinto dal ticket occasionale, con contratti durevoli, clienti serviti, frequenze e calendario interventi.
- Separare nella scheda fornitore i blocchi TecnoRepair / seriali e catalogo operativo interno in pagine/tab dedicate.
- Portare avanti catalogo prodotti unificato con vari fornitori, listini acquisto/vendita multipli, seriali, varianti modello/colore, foto, allegati e riferimenti esterni tipo ICECAT.
### 3.1 Contabilità — fatture fornitori “alla Gescon”
- Rendere la maschera fattura fornitore più fedele allo screenshot (nomi/ordine tab + colonna azioni).
- Aggancio/creazione fornitore (contabilità + anagrafica unica/rubrica) direttamente dalla maschera.

View File

@ -89,6 +89,7 @@ ## 🧭 Documentazione & Indici
- `support/ARCHITETTURA-STORAGE-TENANCY.md` decisione architetturale su isolamento file per amministratore/stabile e valutazione pragmatica del modello Docker per stabile.
- `support/BUG-RISOLTI-E-VALIDAZIONI.md` registro sintetico bug corretti, fix applicati e verifiche ancora da completare.
- `support/CONTINUITA-SVILUPPO-AGENT.md` procedura minima per mantenere allineati manuale, note commit e sviluppo con agent.
- `support/FORNITORE-OPERATIVITA-ROADMAP-2026-04.md` roadmap operativa fornitore/manutentore: rubrica unica, ticket interni, lavori ricorrenti, TecnoRepair, catalogo prodotti e seriali.
- `docs/automation/` commit log mensile, summary giornalieri e protocollo handoff automatici.
- `07-IMPORT-UNITA-IMMOBILIARI.md` blueprint operativo per importare unità e collegare proprietari/inquilini.
- `08-ANAGRAFICA-UNICA-UI.md` specifica UX della scheda anagrafica unica e relativi widget condominiali.

View File

@ -116,3 +116,33 @@ ## Regola finale
- punto di esecuzione assistita: UI `Supporto > Modifiche`
- comando tecnico sottostante: `netgescon:git-sync`
- documentazione di cio che si sta distribuendo: file Markdown versionati nel repository
## Rilascio operativo 2026-04-06
Questo e il blocco che puo essere aggiornato adesso con la procedura Git-first senza correzioni manuali extra su staging.
Incluso nel rilascio:
- Ticket Mobile: anteprima immediata locale di foto e allegati prima dell invio definitivo.
- Ticket Gestione: nuova `Tab 4 - Assicurazioni` con nota rapida nello scheda ticket e pagina elenco pratiche dedicata.
- Area fornitore: correzione del 403 spurio sulla scheda ticket/intervento quando il link usa l id ticket invece dell id intervento.
- Area fornitore: apertura rapida in modal dalla lista ticket, allegati con miniature e preview in modal.
- Collaboratori fornitore: ricerca subfornitore con lo stesso pattern interattivo usato nella ricerca fornitore dei ticket.
Procedura consigliata per questo rilascio:
1. verificare che il commit sia presente su `origin/main`
2. aprire `Supporto > Modifiche`
3. usare `Aggiorna staging da Gitea`
4. aspettare la conclusione del sync Git e della QA sicura inclusa
5. validare subito i punti visibili:
- Ticket Mobile mostra l anteprima prima di inviare
- Ticket Gestione mostra `Tab 4 - Assicurazioni`
- `/admin-filament/fornitore/tickets/{id}` non produce 403 per i casi operativi normali
- la lista ticket fornitore apre anche la modal rapida
Regola per evitare nuovi problemi:
- non correggere staging a mano se il problema e gia nel repository
- prima si corregge Day0, poi commit, poi push, poi update UI da Gitea
- se un fix urgente viene fatto su staging per emergenza, va riportato subito nel repository prima del prossimo aggiornamento

View File

@ -99,3 +99,42 @@ ## Decisione corrente
- Si: NetGescon puo leggere l'MDB subito.
- Si: possiamo usare l'MDB come sorgente legacy di partenza.
- No: non conviene usare la scrittura diretta verso MDB come modello operativo principale.
## Scrittura DB TecnoRepair: risposta pragmatica
La risposta dipende dal tipo reale di archivio che vogliamo aggiornare.
### Caso A: file Access `.mdb`
- Lettura: si, gia fattibile e stabile con `mdb-tools`.
- Scrittura operativa diretta: no, non e il canale consigliato.
- Motivo: la scrittura su MDB da Linux resta troppo fragile per diventare il backoffice quotidiano di produzione.
### Caso B: database SQL o staging NetGescon derivato da TecnoRepair
- Si, questo e il modello consigliato.
- NetGescon diventa il sistema di lavoro.
- TecnoRepair legacy resta sorgente iniziale o archivio da sincronizzare con connettori dedicati.
### Modello consigliato per rubrica e sincronizzazione cellulare
Per ottenere gestione coerente tra NetGescon, telefono e Google Contacts:
1. rubrica unica NetGescon come sorgente operativa principale
2. sincronizzazione verso Google Contacts per il mobile
3. import/sync da TecnoRepair verso NetGescon sui contatti legacy
4. eventuale export di ritorno verso sistemi esterni solo tramite bridge controllato, non con scrittura diretta nel file MDB
Questo approccio permette:
- una sola maschera rubrica condivisa per tutti i ruoli
- aggiornamenti coerenti anche da cellulare
- audit e storicizzazione in NetGescon
- minore rischio di corruzione archivio legacy
## Decisione operativa aggiornata
- rubrica condivisa: si
- sincronizzazione con Google Contacts: si, come canale mobile consigliato
- scrittura diretta nel file `TecnoRepairDB.mdb`: no, da evitare come processo ordinario
- scrittura su tabelle NetGescon/staging SQL con bridge di sincronizzazione: si, e il percorso corretto

View File

@ -0,0 +1,144 @@
# Roadmap operativa fornitore/manutentore
Data: 6 aprile 2026
## 1. Obiettivo
Rendere l area fornitore realmente usabile come backoffice operativo quotidiano, senza creare viste isolate solo per un caso specifico.
Direzione confermata:
- UI e Blade riusabili tra ruoli diversi
- differenziazione principale a livello di accesso dati e permessi
- stessa esperienza utente su ticket, rubrica, allegati e schede prodotto
## 2. Stato gia rilasciato
- correzione accesso scheda ticket/intervento fornitore
- modal rapida dalla lista ticket fornitore
- miniature allegati ticket e preview modal lato fornitore
- anteprima immediata file in scheda intervento fornitore
- ricerca subfornitore/collaboratore con pattern coerente ai ticket
## 3. Rubrica unica condivisa
Decisione:
- usare sempre la stessa maschera rubrica come base comune
- i ruoli diversi devono vedere workflow coerenti
- le differenze vanno applicate nel layer DB, permessi, collegamenti e campi secondari
Impatto:
- un fix UI fatto una volta resta valido per tutti
- minore divergenza tra amministratore, fornitore e moduli tecnici
- base migliore per sincronizzazione mobile e Google Contacts
## 4. TecnoRepair e sorgente dati
Decisione tecnica:
- leggere il legacy MDB in modo sicuro
- non usare il file Access come database operativo da scrivere direttamente
- usare NetGescon come base dati di lavoro e storico operativo
- prevedere bridge di sincronizzazione verso/da sistemi esterni solo in modo controllato
Percorso consigliato:
1. import legacy TecnoRepair in staging SQL NetGescon
2. matching verso rubrica, fornitori, prodotti, seriali e schede assistenza
3. eventuale sync con Google Contacts per la parte rubrica mobile
4. eventuali export/sync di ritorno solo su connettori dedicati
## 5. Ticket interni e lavorazioni unificate
Da realizzare:
- pagina dedicata con elenco unico lavorazioni fornitore
- viste aggregate per:
- ticket assegnati dall amministratore
- ticket interni aperti dal fornitore
- lavorazioni concluse / archivio
- lavorazioni in attesa / da pianificare
Regola:
- il ticket e il contenitore del lavoro occasionale/non preventivato
- le lavorazioni interne devono convivere nello stesso pannello operativo, senza obbligare a due strumenti separati per il manutentore
## 6. Lavori ricorrenti e contratti
Dominio da introdurre separatamente dal ticket:
- contratti di servizio continuativi
- clienti serviti
- sedi/impianti/contesti serviti
- frequenza interventi
- calendario pianificato
- assegnazione operatori
- consuntivi e scostamenti
Esempi target:
- pulizie
- portierato
- disinfestazione
- manutenzione tecnica periodica
- servizi continuativi con SLA o visite programmate
## 7. Collaboratori e turni
Estensione richiesta sulla pagina collaboratori:
- riuso del componente di ricerca stile ticket
- memorizzazione come collaboratore/dipendente o subfornitore
- futura gestione orari di lavoro e disponibilita
- base per distribuire lavori ricorrenti e assegnazioni automatiche/manuali
## 8. Catalogo operativo interno
Modello richiesto:
- prodotto unico interno
- piu fornitori per lo stesso prodotto
- listini acquisto multipli comparabili
- listini vendita differenziabili per cliente o profilo cliente
- seriali singoli o multipli
- assemblati con seriali figli
- varianti modello/colore
- foto, allegati e link esterni tipo ICECAT
Decisione chiave:
- il codice pubblicato/operativo deve essere quello interno NetGescon
- i riferimenti esterni restano alias o sorgenti di import, non chiavi principali dell archivio
## 9. Scheda fornitore per pagine separate
Separazione richiesta per la scheda fornitore:
1. dati essenziali in scheda principale
2. pagina/tab `TecnoRepair / seriali`
3. pagina/tab `Catalogo operativo interno`
Contenuto previsto in `TecnoRepair / seriali`:
- import MDB collegato al fornitore
- seriali assistenza agganciati automaticamente
- storico schede legacy
Contenuto previsto in `Catalogo operativo interno`:
- anagrafica prodotti/servizi del fornitore
- listini acquisto/vendita
- varianti e seriali
- allegati e immagini
- riferimenti esterni interni al gestionale
## 10. Prossimi slice consigliati
1. pagina ticket interni/lavorazioni unificate del fornitore
2. estensione collaboratori con disponibilita/orari
3. separazione scheda fornitore in tab/pagine dedicate
4. staging SQL TecnoRepair per rubrica, schede e seriali
5. catalogo prodotti unificato con vendor multipli e serializzazione

View File

@ -49,15 +49,33 @@
<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>
<div class="mt-2 text-xs text-gray-500">Ricerca per tag, telefono, nome, ragione sociale o email. Tocca il risultato per collegarlo come subfornitore.</div>
@if($fornitoreEsternoId)
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-800">
Fornitore selezionato: <span class="font-semibold">{{ $fornitoreSearch }}</span>
<button type="button" wire:click="resetFornitoreEsternoSelection" class="ml-2 inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-emerald-700 ring-1 ring-inset ring-emerald-300 hover:bg-emerald-100">Cambia</button>
</div>
@endif
@if(count($fornitoreMatches) > 0)
<div class="space-y-2">
@foreach($fornitoreMatches as $option)
<button type="button" wire:click="selezionaFornitoreEsterno({{ (int) $option['id'] }})" class="w-full rounded-lg border p-3 text-left {{ (int) $fornitoreEsternoId === (int) $option['id'] ? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-200' : 'bg-white hover:bg-gray-50' }}">
<div class="text-sm font-medium">{{ $option['label'] }}</div>
@if($option['meta'] !== '')
<div class="mt-1 text-xs text-gray-600">{{ $option['meta'] }}</div>
@endif
@if($option['note'] !== '')
<div class="mt-1 text-xs text-gray-500">{{ $option['note'] }}</div>
@endif
</button>
@endforeach
</select>
</label>
</div>
@elseif(filled($fornitoreSearch) && ! $fornitoreEsternoId)
<div class="text-xs text-gray-500">Nessun fornitore trovato con questa ricerca.</div>
@endif
<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>

View File

@ -1,7 +1,17 @@
<x-filament-panels::page>
@php($ticket = $this->intervento->ticket)
<div class="space-y-4">
<div class="space-y-4"
x-data="{
localFotoPreviews: [],
localDocumentoPreviews: [],
revoke(items) { (items || []).forEach(item => { if (item && item.url) { URL.revokeObjectURL(item.url); } }); },
mapFiles(fileList) { return Array.from(fileList || []).map(file => ({ name: file.name, size: file.size || 0, isImage: (file.type || '').startsWith('image/'), url: (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null })); },
setPreviews(event, type) { const key = type === 'foto' ? 'localFotoPreviews' : 'localDocumentoPreviews'; this.revoke(this[key]); this[key] = this.mapFiles(event.target.files); },
clearPreviews(type = null) { if (type === null || type === 'foto') { this.revoke(this.localFotoPreviews); this.localFotoPreviews = []; } if (type === null || type === 'documenti') { this.revoke(this.localDocumentoPreviews); this.localDocumentoPreviews = []; } },
formatSize(bytes) { if (!bytes) return '0 KB'; const units = ['B', 'KB', 'MB', 'GB']; let value = bytes; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex++; } return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; }
}"
x-on:fornitore-local-previews-clear.window="clearPreviews()">
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
@ -134,14 +144,59 @@
<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" />
<input type="file" wire:model="fotoLavoro" multiple accept="image/*" x-on:change="setPreviews($event, 'foto')" 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" />
<input type="file" wire:model="documentiLavoro" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,image/*" x-on:change="setPreviews($event, 'documenti')" class="w-full rounded-lg border-gray-300 text-sm" />
</label>
</div>
<div class="mt-4 rounded-xl border border-dashed border-sky-200 bg-sky-50/60 p-4" x-show="localFotoPreviews.length || localDocumentoPreviews.length" x-cloak>
<div class="text-sm font-semibold text-sky-900">Anteprima immediata allegati fornitore</div>
<div class="mt-1 text-xs text-sky-700">Le immagini e i documenti selezionati sono visibili subito prima del salvataggio del rapportino.</div>
<div class="mt-4" x-show="localFotoPreviews.length" x-cloak>
<div class="text-xs font-semibold uppercase tracking-wide text-sky-800">Foto lavoro</div>
<div class="mt-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<template x-for="item in localFotoPreviews" :key="item.name + item.size">
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="aspect-[4/3] overflow-hidden rounded-lg border bg-slate-50">
<template x-if="item.url">
<img :src="item.url" :alt="item.name" class="h-full w-full object-cover" />
</template>
</div>
<div class="mt-2 truncate font-medium" x-text="item.name"></div>
<div class="text-gray-500" x-text="formatSize(item.size)"></div>
</div>
</template>
</div>
</div>
<div class="mt-4" x-show="localDocumentoPreviews.length" x-cloak>
<div class="text-xs font-semibold uppercase tracking-wide text-sky-800">Documenti lavoro</div>
<div class="mt-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<template x-for="item in localDocumentoPreviews" :key="item.name + item.size">
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="flex aspect-[4/3] items-center justify-center rounded-lg border bg-slate-50">
<template x-if="item.url">
<img :src="item.url" :alt="item.name" class="h-full w-full rounded object-cover" />
</template>
<template x-if="!item.url">
<div class="text-center text-gray-500">
<div class="text-sm font-semibold">FILE</div>
<div class="mt-1 text-[11px]">Anteprima pronta</div>
</div>
</template>
</div>
<div class="mt-2 truncate font-medium" x-text="item.name"></div>
<div class="text-gray-500" x-text="formatSize(item.size)"></div>
</div>
</template>
</div>
</div>
</div>
<div class="mt-4 grid gap-2 md:grid-cols-2">
@foreach($descrizioneFile as $index => $item)
<label class="block text-sm">
@ -215,13 +270,26 @@
<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">
<div class="mt-3 grid gap-3 sm:grid-cols-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>
<div class="mt-1 text-xs text-gray-500">Caricato da {{ $attachment->user->name ?? 'Sistema' }} il {{ optional($attachment->created_at)->format('d/m/Y H:i') ?: '-' }}</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 class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
@if($attachment->isImage())
<img src="{{ $this->getAttachmentPublicUrl($attachment) }}" alt="{{ $attachment->original_file_name }}" class="h-full w-full rounded object-cover" />
@else
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>
<div class="text-sm font-semibold">{{ strtoupper(pathinfo((string) $attachment->original_file_name, PATHINFO_EXTENSION) ?: ($attachment->isPdf() ? 'PDF' : 'FILE')) }}</div>
<div class="mt-1 text-[11px]">Anteprima disponibile in modal</div>
</div>
</div>
@endif
</div>
<div class="mt-2 font-medium">{{ $attachment->original_file_name }}</div>
<div class="mt-1 text-gray-600">{{ $attachment->description ?: 'Nessuna descrizione' }}</div>
<div class="mt-2 text-[11px] text-gray-500">Caricato da {{ $attachment->user->name ?? 'Sistema' }} il {{ optional($attachment->created_at)->format('d/m/Y H:i') ?: '-' }}</div>
<button type="button" wire:click="openAttachmentPreview({{ (int) $attachment->id }})" class="mt-2 inline-flex items-center rounded-md bg-indigo-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-indigo-500">Visualizza in modal</button>
</div>
@empty
<div class="text-sm text-gray-500">Nessun allegato disponibile. Se il ticket e stato creato senza file persistiti, qui non verra mostrato nulla finche non vengono caricati allegati reali.</div>
@ -248,4 +316,50 @@
</div>
</div>
</div>
@if($attachmentPreview)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" x-data="{ zoom: 1, pdfPage: 1, iframeKey: 0, refreshPdf() { this.iframeKey++; }, zoomIn() { this.zoom = Math.min(this.zoom + 0.2, 4); }, zoomOut() { this.zoom = Math.max(this.zoom - 0.2, 0.5); }, resetZoom() { this.zoom = 1; }, nextPage() { this.pdfPage = this.pdfPage + 1; this.refreshPdf(); }, prevPage() { this.pdfPage = Math.max(this.pdfPage - 1, 1); this.refreshPdf(); }, printPdf() { const frame = this.$refs.pdfFrame; if (frame?.contentWindow) { frame.contentWindow.focus(); frame.contentWindow.print(); } else { window.open(@js($attachmentPreview['url']), '_blank'); } } }">
<div class="h-[92vh] w-full max-w-[96vw] rounded-xl bg-white shadow-2xl">
<div class="flex items-center justify-between border-b px-4 py-3">
<div>
<div class="text-sm font-semibold">{{ $attachmentPreview['name'] ?? 'Allegato' }}</div>
@if(!empty($attachmentPreview['description']))
<div class="text-xs text-gray-500">{{ $attachmentPreview['description'] }}</div>
@endif
</div>
<div class="flex items-center gap-2">
@if($attachmentPreview['is_image'] ?? false)
<button type="button" x-on:click="zoomOut()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">-</button>
<button type="button" x-on:click="resetZoom()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">100%</button>
<button type="button" x-on:click="zoomIn()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">+</button>
@elseif($attachmentPreview['is_pdf'] ?? false)
<button type="button" x-on:click="prevPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina precedente</button>
<span class="text-xs text-slate-500">Pagina <span x-text="pdfPage"></span></span>
<button type="button" x-on:click="nextPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina successiva</button>
<button type="button" x-on:click="printPdf()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Stampa PDF</button>
@endif
<a href="{{ $attachmentPreview['url'] }}" target="_blank" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Apri in nuova scheda</a>
<button type="button" wire:click="closeAttachmentPreview" class="rounded-md bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">Chiudi</button>
</div>
</div>
<div class="h-[calc(92vh-64px)] overflow-auto p-4">
@if($attachmentPreview['is_image'] ?? false)
<div class="h-full overflow-auto rounded-lg bg-slate-50 p-4">
<div class="flex min-h-full min-w-full items-start justify-center">
<img src="{{ $attachmentPreview['url'] }}" alt="Anteprima allegato" class="max-w-none rounded border bg-white shadow" x-bind:style="`width: ${zoom * 100}%; height: auto;`" />
</div>
</div>
@elseif($attachmentPreview['is_pdf'] ?? false)
<iframe x-ref="pdfFrame" x-bind:key="iframeKey" x-bind:src="@js($attachmentPreview['url']) + '#toolbar=1&navpanes=0&scrollbar=1&view=FitH&zoom=page-width&page=' + pdfPage" class="h-[84vh] w-full rounded border"></iframe>
@else
<div class="rounded-md border bg-gray-50 p-4 text-sm text-gray-700">
Anteprima non disponibile per questo formato. Scarica o apri il file:
<a href="{{ $attachmentPreview['url'] }}" class="ml-1 text-primary-600 hover:underline">Apri file</a>
</div>
@endif
</div>
</div>
</div>
@endif
</x-filament-panels::page>

View File

@ -77,7 +77,10 @@
</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>
<div class="flex justify-end gap-2">
<button type="button" wire:click="openInterventoModal({{ (int) $row['id'] }})" class="inline-flex items-center rounded-md bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200">Apri modal</button>
<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>
</div>
</td>
</tr>
@empty
@ -91,4 +94,89 @@
</div>
@endif
</div>
@if($detailModal)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div class="max-h-[92vh] w-full max-w-5xl overflow-hidden rounded-xl bg-white shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b px-4 py-3">
<div>
<div class="text-sm font-semibold">Ticket #{{ $detailModal['ticket_id'] }} · {{ $detailModal['titolo'] }}</div>
<div class="text-xs text-gray-500">{{ $detailModal['stabile'] }} · stato {{ $detailModal['stato'] }}</div>
</div>
<div class="flex items-center gap-2">
<a href="{{ $detailModal['url'] }}" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Apri scheda completa</a>
<button type="button" wire:click="closeInterventoModal" class="rounded-md bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">Chiudi</button>
</div>
</div>
<div class="max-h-[calc(92vh-64px)] overflow-auto p-4">
<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="grid gap-3 text-sm md:grid-cols-2">
<div>
<div class="text-xs uppercase tracking-wide text-gray-500">Contatto</div>
<div class="mt-1">{{ $detailModal['contatto'] !== '' ? $detailModal['contatto'] : '-' }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-gray-500">Telefono</div>
<div class="mt-1">{{ $detailModal['telefono'] !== '' ? $detailModal['telefono'] : '-' }}</div>
</div>
<div class="md:col-span-2">
<div class="text-xs uppercase tracking-wide text-gray-500">Problema</div>
<div class="mt-1">{{ $detailModal['problema'] }}</div>
</div>
</div>
<div class="mt-4 whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-sm text-gray-700">{{ $detailModal['descrizione'] !== '' ? $detailModal['descrizione'] : 'Nessuna descrizione disponibile.' }}</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Ultime comunicazioni</div>
<div class="mt-3 space-y-2">
@forelse($detailModal['messages'] as $message)
<div class="rounded-lg border p-3 text-sm">
<div class="font-medium">{{ $message['author'] }}</div>
<div class="mt-1 whitespace-pre-wrap text-gray-700">{{ $message['body'] }}</div>
<div class="mt-1 text-xs text-gray-500">{{ $message['created_at'] }}</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">Allegati ticket</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
@forelse($detailModal['attachments'] as $attachment)
<a href="{{ $attachment['url'] }}" target="_blank" class="rounded-xl border bg-white p-3 text-xs shadow-sm hover:border-primary-300 hover:bg-primary-50/30">
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
@if($attachment['is_image'])
<img src="{{ $attachment['url'] }}" alt="{{ $attachment['name'] }}" class="h-full w-full rounded object-cover" />
@else
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>
<div class="text-sm font-semibold">{{ $attachment['is_pdf'] ? 'PDF' : 'FILE' }}</div>
<div class="mt-1 text-[11px]">Apri allegato</div>
</div>
</div>
@endif
</div>
<div class="mt-2 font-medium">{{ $attachment['name'] }}</div>
<div class="mt-1 text-gray-600">{{ $attachment['description'] !== '' ? $attachment['description'] : 'Nessuna descrizione' }}</div>
<div class="mt-2 text-[11px] text-gray-500">{{ $attachment['created_at'] }}</div>
</a>
@empty
<div class="text-sm text-gray-500">Nessun allegato disponibile.</div>
@endforelse
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@endif
</x-filament-panels::page>

View File

@ -0,0 +1,89 @@
<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">Pratiche assicurative</div>
<div class="text-sm text-gray-600">Elenco centralizzato dei sinistri aperti o chiusi, separato dalla lavorazione generale dei ticket.</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ \App\Filament\Pages\Supporto\TicketGestione::getUrl(panel: 'admin-filament') }}" class="inline-flex items-center rounded-md bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700">Torna a Gestione Ticket</a>
</div>
</div>
</div>
<div class="grid gap-4 md:grid-cols-3">
<button type="button" wire:click="setStatus('aperte')" class="rounded-xl border p-4 text-left {{ $status === 'aperte' ? 'border-amber-400 bg-amber-50' : 'bg-white' }}">
<div class="text-xs uppercase tracking-wide text-gray-500">Aperte</div>
<div class="mt-1 text-2xl font-semibold">{{ $totals['aperte'] }}</div>
<div class="mt-2 text-sm text-gray-600">Pratiche in corso o da completare.</div>
</button>
<button type="button" wire:click="setStatus('chiuse')" class="rounded-xl border p-4 text-left {{ $status === 'chiuse' ? 'border-emerald-400 bg-emerald-50' : 'bg-white' }}">
<div class="text-xs uppercase tracking-wide text-gray-500">Chiuse</div>
<div class="mt-1 text-2xl font-semibold">{{ $totals['chiuse'] }}</div>
<div class="mt-2 text-sm text-gray-600">Storico pratiche definite o archiviate.</div>
</button>
<button type="button" wire:click="setStatus('tutte')" class="rounded-xl border p-4 text-left {{ $status === 'tutte' ? 'border-slate-400 bg-slate-50' : 'bg-white' }}">
<div class="text-xs uppercase tracking-wide text-gray-500">Tutte</div>
<div class="mt-1 text-2xl font-semibold">{{ $totals['tutte'] }}</div>
<div class="mt-2 text-sm text-gray-600">Vista completa di tutte le pratiche.</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">Stabile</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Sinistro</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Polizza</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">Prossima azione</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Aperta il</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['ticket_title'] }}</div>
@if($focusTicketId && (int) $focusTicketId === (int) $row['ticket_id'])
<div class="mt-1 text-[11px] font-semibold text-amber-700">Pratica aperta dal ticket corrente</div>
@endif
</td>
<td class="px-4 py-3 align-top">{{ $row['stabile'] }}</td>
<td class="px-4 py-3 align-top">
<div>{{ $row['claim_number'] !== '' ? $row['claim_number'] : 'Da definire' }}</div>
@if($row['policy_reference'] !== '')
<div class="text-xs text-gray-500">Rif. {{ $row['policy_reference'] }}</div>
@endif
</td>
<td class="px-4 py-3 align-top">{{ $row['policy_label'] !== '' ? $row['policy_label'] : '-' }}</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['status'] }}</span>
</td>
<td class="px-4 py-3 align-top">
{{ $row['next_action'] !== '' ? $row['next_action'] : '-' }}
@if($row['documents_sent_at'] !== '')
<div class="mt-1 text-xs text-gray-500">Documenti inviati: {{ \Carbon\Carbon::parse($row['documents_sent_at'])->format('d/m/Y H:i') }}</div>
@endif
</td>
<td class="px-4 py-3 align-top text-gray-600">{{ $row['opened_at'] }}</td>
<td class="px-4 py-3 text-right align-top">
<a href="{{ $row['ticket_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 Tab 4</a>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-4 py-10 text-center text-sm text-gray-500">Nessuna pratica assicurativa trovata per il filtro selezionato.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -24,6 +24,9 @@
<button type="button" wire:click="$set('activeTab', 'fornitori')" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'fornitori' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}">
Tab 3 - Fornitori attivi
</button>
<button type="button" wire:click="apriAssicurazioneTicket" class="inline-flex items-center rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'assicurazione' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}">
Tab 4 - Assicurazioni
</button>
</div>
@if($activeTab === 'elenco')
<select wire:model.live="status" class="rounded-lg border-gray-300 text-sm">
@ -198,7 +201,9 @@
</div>
</div>
@elseif($activeTab === 'scheda')
@php($ticket = $this->selectedTicket)
@php
$ticket = $this->selectedTicket;
@endphp
@if(! $ticket)
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
Nessun ticket selezionato. Vai nella TAB Elenco e premi "Scheda" sulla riga del ticket.
@ -210,10 +215,31 @@
<div class="text-sm font-semibold">#{{ $ticket->id }} - {{ $ticket->titolo }}</div>
<div class="text-xs text-gray-600">Stato: {{ $ticket->stato }} · Priorita: {{ $ticket->priorita }} · Categoria: {{ optional($ticket->categoriaTicket)->nome ?: '-' }}</div>
</div>
<button type="button" wire:click="apriElenco" class="inline-flex items-center rounded-md bg-gray-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600">Torna all'elenco</button>
<div class="flex flex-wrap gap-2">
<button type="button" wire:click="apriAssicurazioneTicket" class="inline-flex items-center rounded-md bg-amber-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-600">Apri procedura assicurativa</button>
<a href="{{ $this->getInsuranceClaimsUrl() }}" class="inline-flex items-center rounded-md bg-slate-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-600">Elenco pratiche assicurative</a>
<button type="button" wire:click="apriElenco" class="inline-flex items-center rounded-md bg-gray-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600">Torna all'elenco</button>
</div>
</div>
<div class="mt-3 whitespace-pre-wrap text-sm text-gray-700">{{ $ticket->descrizione }}</div>
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="font-semibold uppercase tracking-wide">Stato pratica assicurativa</div>
@if($ticket->insuranceClaim)
<div class="mt-1">Pratica aperta · Stato {{ $ticket->insuranceClaim->status ?: 'aperta' }} @if($ticket->insuranceClaim->claim_number) · Sinistro {{ $ticket->insuranceClaim->claim_number }} @endif</div>
@if($ticket->insuranceClaim->policy_reference)
<div class="mt-1">Riferimento polizza: {{ $ticket->insuranceClaim->policy_reference }}</div>
@endif
@else
<div class="mt-1">Nessuna pratica assicurativa ancora aperta per questo ticket.</div>
@endif
</div>
<button type="button" wire:click="apriAssicurazioneTicket" class="inline-flex items-center rounded-md bg-amber-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-600">Gestisci in Tab 4</button>
</div>
</div>
</div>
<div class="mt-3 grid gap-4 lg:grid-cols-2">
@ -353,11 +379,48 @@
</div>
</div>
<div class="mt-4 rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Storico note/messaggi</div>
<div class="mt-3 space-y-2">
@forelse($ticket->messages as $msg)
<div class="rounded-md border p-2 text-xs">
<div class="font-medium">{{ optional($msg->user)->name ?: 'Sistema' }}</div>
<div class="mt-1 whitespace-pre-wrap text-gray-700">{{ $msg->messaggio }}</div>
<div class="mt-1 text-gray-500">{{ optional($msg->created_at)->format('d/m/Y H:i') }}</div>
</div>
@empty
<div class="text-xs text-gray-500">Nessuna nota registrata.</div>
@endforelse
</div>
</div>
@endif
@elseif($activeTab === 'assicurazione')
@php
$ticket = $this->selectedTicket;
@endphp
@if(! $ticket)
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
Nessun ticket selezionato. Apri un ticket e poi entra nella Tab 4 assicurazione.
</div>
@else
<div class="mt-3 rounded-lg border p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-sm font-semibold">Tab 4 - Procedura assicurativa per ticket #{{ $ticket->id }}</div>
<div class="text-xs text-gray-600">{{ $ticket->titolo }} · {{ optional($ticket->stabile)->denominazione ?: '-' }}</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $this->getInsuranceClaimsUrl() }}" class="inline-flex items-center rounded-md bg-slate-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-600">Elenco pratiche</a>
<button type="button" wire:click="$set('activeTab', 'scheda')" class="inline-flex items-center rounded-md bg-gray-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600">Torna alla scheda ticket</button>
</div>
</div>
</div>
<div class="mt-4 rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-sm font-semibold">Assicurazione e sinistro</div>
<div class="mt-1 text-xs text-gray-500">Gestione apertura o aggiornamento del sinistro senza uscire da Filament.</div>
<div class="mt-1 text-xs text-gray-500">Apertura e aggiornamento pratica in area separata dal resto della lavorazione ticket.</div>
</div>
@if($ticket->insuranceClaim)
<span class="inline-flex rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">
@ -466,23 +529,9 @@
</div>
@endif
<div class="mt-3">
<div class="mt-3 flex flex-wrap gap-2">
<x-filament::button color="warning" wire:click="salvaSinistroAssicurativo">Salva sinistro</x-filament::button>
</div>
</div>
<div class="mt-4 rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Storico note/messaggi</div>
<div class="mt-3 space-y-2">
@forelse($ticket->messages as $msg)
<div class="rounded-md border p-2 text-xs">
<div class="font-medium">{{ optional($msg->user)->name ?: 'Sistema' }}</div>
<div class="mt-1 whitespace-pre-wrap text-gray-700">{{ $msg->messaggio }}</div>
<div class="mt-1 text-gray-500">{{ optional($msg->created_at)->format('d/m/Y H:i') }}</div>
</div>
@empty
<div class="text-xs text-gray-500">Nessuna nota registrata.</div>
@endforelse
<a href="{{ $this->getInsuranceClaimsUrl() }}" class="inline-flex items-center rounded-md bg-slate-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-600">Apri elenco pratiche</a>
</div>
</div>
@endif

View File

@ -1,8 +1,10 @@
<x-filament-panels::page wire:poll.8s="refreshLiveCallBanner">
<div class="space-y-4">
@if($liveIncomingCall)
@php($rubricaUrl = $this->getLiveRubricaUrl())
@php($estrattoUrl = $this->getLiveEstrattoContoUrl())
@php
$rubricaUrl = $this->getLiveRubricaUrl();
$estrattoUrl = $this->getLiveEstrattoContoUrl();
@endphp
<div class="rounded-xl border border-emerald-300 bg-emerald-50 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
@ -82,7 +84,9 @@
@if($callerMatches->count() > 0)
<div class="mt-3 space-y-2">
@foreach($callerMatches as $match)
@php($stabileLabel = $this->getCallerStabileLabel((int) $match->id))
@php
$stabileLabel = $this->getCallerStabileLabel((int) $match->id);
@endphp
<button type="button" wire:click="selezionaCaller({{ (int) $match->id }})" class="w-full rounded-lg border p-3 text-left {{ (int) $selectedCallerId === (int) $match->id ? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-200' : 'bg-white hover:bg-gray-50' }}">
<div class="text-sm font-medium">{{ $match->nome_completo ?: ($match->ragione_sociale ?: 'Contatto') }}</div>
<div class="text-xs text-gray-600">{{ $match->categoria ?: 'altro' }} @if($match->email) · {{ $match->email }} @endif</div>
@ -132,7 +136,9 @@
</ul>
</div>
@endif
@php($ticketCallContext = $this->ticketCallContext)
@php
$ticketCallContext = $this->ticketCallContext;
@endphp
@if(!empty($ticketCallContext['phone']) || !empty($ticketCallContext['caller_name']) || !empty($ticketCallContext['target_extension']))
<div class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-900">
<div class="font-semibold uppercase tracking-wide">Contesto chiamata</div>
@ -244,7 +250,58 @@
<textarea rows="2" wire:model.defer="newTicketFotoNote" class="w-full rounded-lg border-gray-300" placeholder="Es. perdita visibile sotto il lavello, foto allegata" ></textarea>
</label>
<label class="block text-sm md:col-span-2">
<label class="block text-sm md:col-span-2"
x-data="{
localCameraPreviews: [],
localAttachmentPreviews: [],
revoke(items) {
items.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
});
},
mapFiles(fileList, kind) {
return Array.from(fileList || []).map((file, index) => ({
key: `${kind}-${index}-${file.name}-${file.size}`,
name: file.name,
size: file.size || 0,
isImage: (file.type || '').startsWith('image/'),
url: (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null,
}));
},
setPreviews(event, kind) {
const target = kind === 'camera' ? 'localCameraPreviews' : 'localAttachmentPreviews';
this.revoke(this[target]);
this[target] = this.mapFiles(event?.target?.files || [], kind);
},
clearPreviews(kind = null) {
if (kind === null || kind === 'camera') {
this.revoke(this.localCameraPreviews);
this.localCameraPreviews = [];
}
if (kind === null || kind === 'attachment') {
this.revoke(this.localAttachmentPreviews);
this.localAttachmentPreviews = [];
}
},
formatSize(bytes) {
if (!bytes) {
return '0 KB';
}
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1048576) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / 1048576).toFixed(1)} MB`;
},
}"
x-on:ticket-mobile-local-previews-clear.window="clearPreviews($event.detail?.source || null)"
x-on:ticket-mobile-draft-reset.window="clearPreviews()"
>
<span class="mb-2 block font-medium">Acquisizione foto e allegati</span>
@if(count($this->selectedUploads) > 0)
<div class="mb-2 inline-flex items-center rounded-full bg-emerald-100 px-3 py-1 text-[11px] font-semibold text-emerald-800">
@ -254,18 +311,69 @@
<div class="flex flex-wrap gap-2">
<label class="inline-flex cursor-pointer items-center rounded-md bg-emerald-600 px-3 py-2 text-xs font-medium text-white hover:bg-emerald-500">
Scatta foto
<input type="file" wire:model="pendingTicketCameraShots" multiple accept="image/*" capture="environment" class="hidden" />
<input type="file" wire:model="pendingTicketCameraShots" multiple accept="image/*" capture="environment" class="hidden" x-on:change="setPreviews($event, 'camera')" />
</label>
<label class="inline-flex cursor-pointer items-center rounded-md bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-500">
Aggiungi allegati
<input type="file" wire:model="pendingTicketAttachments" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,image/*" class="hidden" />
<input type="file" wire:model="pendingTicketAttachments" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,image/*" class="hidden" x-on:change="setPreviews($event, 'attachment')" />
</label>
</div>
<div class="mt-1 text-xs text-gray-500">Puoi caricare fino a 10 file totali. Ogni nuovo scatto viene accodato senza sostituire i precedenti.</div>
<div class="mt-1 text-xs text-gray-500">Bozza protocollo corrente: <span class="font-semibold">{{ $newTicketDraftProtocol }}</span></div>
<div class="mt-1 text-xs text-gray-500">iPhone: su "Aggiungi allegati" scegli "Sfoglia" per aprire l'app File (iCloud Drive/On My iPhone).</div>
<div wire:loading wire:target="pendingTicketCameraShots,pendingTicketAttachments" class="mt-2 text-xs font-medium text-amber-700">Caricamento in corso: sto accodando foto e allegati alla bozza ticket.</div>
<div x-show="localCameraPreviews.length || localAttachmentPreviews.length" x-cloak class="mt-3 rounded-xl border border-sky-200 bg-sky-50 p-3">
<div class="text-xs font-semibold text-sky-900">Anteprima immediata sul telefono</div>
<div class="mt-1 text-[11px] text-sky-800">Le foto e gli allegati selezionati si vedono subito qui, prima dell'accodamento definitivo nella bozza ticket.</div>
<template x-if="localCameraPreviews.length">
<div class="mt-3">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-sky-900">Foto appena scattate</div>
<div class="grid gap-3 sm:grid-cols-2">
<template x-for="item in localCameraPreviews" :key="item.key">
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
<img x-show="item.url" :src="item.url" :alt="item.name" class="h-full w-full rounded object-cover" />
</div>
<div class="mt-2 font-medium" x-text="item.name"></div>
<div class="mt-1 text-[11px] text-sky-700">In attesa di accodamento</div>
<div class="mt-1 text-[11px] text-gray-500" x-text="formatSize(item.size)"></div>
</div>
</template>
</div>
</div>
</template>
<template x-if="localAttachmentPreviews.length">
<div class="mt-3">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-sky-900">Allegati appena selezionati</div>
<div class="grid gap-3 sm:grid-cols-2">
<template x-for="item in localAttachmentPreviews" :key="item.key">
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
<template x-if="item.isImage && item.url">
<img :src="item.url" :alt="item.name" class="h-full w-full rounded object-cover" />
</template>
<template x-if="!item.isImage">
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>
<div class="text-sm font-semibold" x-text="(item.name.split('.').pop() || 'FILE').toUpperCase()"></div>
<div class="mt-1 text-[11px]">Pronto per l'invio</div>
</div>
</div>
</template>
</div>
<div class="mt-2 font-medium" x-text="item.name"></div>
<div class="mt-1 text-[11px] text-sky-700">In attesa di accodamento</div>
<div class="mt-1 text-[11px] text-gray-500" x-text="formatSize(item.size)"></div>
</div>
</template>
</div>
</div>
</template>
</div>
</label>
@if(count($this->selectedUploads) > 0)