Add PBX topbar and mobile ticket UX

This commit is contained in:
michele 2026-03-30 17:57:22 +00:00
parent b0bdf1a020
commit 4ed0b392d1
15 changed files with 1164 additions and 126 deletions

View File

@ -2,6 +2,7 @@
namespace App\Filament\Pages\Impostazioni;
use App\Models\Amministratore;
use App\Models\CommunicationMessage;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\Stabile;
@ -718,6 +719,128 @@ public function form(Schema $schema): Schema
]),
]),
Tab::make('PBX / TAPI')
->schema([
Section::make('Profilo centralino e bridge')
->columns(3)
->schema([
Select::make('impostazioni.pbx.provider')
->label('Tipo centralino')
->options([
'panasonic_ns1000' => 'Panasonic NS1000',
'panasonic_generic' => 'Panasonic generico',
'wildix' => 'Wildix',
'3cx' => '3CX',
'custom' => 'Altro / custom',
])
->default('panasonic_ns1000'),
Select::make('impostazioni.pbx.channel')
->label('Canale CRM')
->options([
'panasonic_csta' => 'Panasonic CSTA / CTI',
'tapi' => 'Windows TAPI',
'hybrid' => 'Ibrido TAPI + CSTA',
])
->default('hybrid'),
Select::make('impostazioni.pbx.bridge_mode')
->label('Modalita bridge')
->options([
'group-first' => 'Group-first',
'extension-first' => 'Extension-first',
'none' => 'Solo monitoraggio',
])
->default('group-first'),
TextInput::make('impostazioni.pbx.tapi_host')
->label('Host Windows watcher')
->maxLength(255)
->placeholder('Es. ws-panasonic-01'),
TextInput::make('impostazioni.pbx.tapi_provider')
->label('Provider TAPI / TSP')
->maxLength(255)
->default('CTSP0000.tsp'),
TextInput::make('impostazioni.pbx.watch_extensions')
->label('Interni monitorati')
->maxLength(255)
->placeholder('201,205,206,601,603')
->helperText('Lista CSV degli interni/gruppi che il watcher Windows deve osservare.'),
Toggle::make('impostazioni.pbx.popup_enabled')
->label('Popup CRM attivi')
->default(true),
Toggle::make('impostazioni.pbx.click_to_call_enabled')
->label('Click-to-call attivo')
->default(true),
Textarea::make('impostazioni.pbx.routing_notes')
->label('Note routing PBX')
->rows(3)
->columnSpanFull(),
]),
Section::make('Gruppi di risposta')
->schema([
Repeater::make('impostazioni.pbx.response_groups')
->label('Gruppi di risposta Panasonic')
->defaultItems(0)
->schema([
TextInput::make('extension')
->label('Interno gruppo')
->maxLength(20)
->placeholder('601'),
TextInput::make('label')
->label('Descrizione')
->maxLength(120)
->placeholder('Gruppo giorno'),
TextInput::make('mode')
->label('Modalita')
->maxLength(60)
->placeholder('giorno / notte / backup'),
TextInput::make('target_extension')
->label('Interno di fallback')
->maxLength(20)
->placeholder('205'),
])
->columns(4)
->columnSpanFull(),
]),
Section::make('Linee entranti e DID')
->schema([
Repeater::make('impostazioni.pbx.incoming_lines')
->label('Linee in entrata')
->defaultItems(0)
->schema([
TextInput::make('number')
->label('Numero / DID')
->maxLength(40)
->placeholder('0811234567'),
TextInput::make('label')
->label('Descrizione linea')
->maxLength(120)
->placeholder('Linea assistenza'),
TextInput::make('route_group')
->label('Gruppo target')
->maxLength(20)
->placeholder('601'),
TextInput::make('target_extension')
->label('Interno target')
->maxLength(20)
->placeholder('205'),
Textarea::make('notes')
->label('Note')
->rows(2)
->columnSpanFull(),
])
->columns(4)
->columnSpanFull(),
]),
Section::make('Cruscotto chiamate Panasonic / TAPI')
->schema([
Placeholder::make('pbx_tapi_panel')
->hiddenLabel()
->content(fn() => view('filament.pages.impostazioni.partials.scheda-amministratore-pbx-tapi')),
]),
]),
Tab::make('Operativita / Accessi')
->schema([
Section::make('Azioni operative e gestione accessi')
@ -729,7 +852,7 @@ public function form(Schema $schema): Schema
]),
]),
]);
}
}
public function runProductionUpgrade(): void
{
@ -1189,6 +1312,219 @@ public function getCollaboratoriCentralinoRowsProperty(): array
->all();
}
/**
* @return array{status:string,label:string,provider:string,channel:string,bridge_mode:string,watched_extensions:array<int,string>,response_groups:array<int,array<string,mixed>>,incoming_lines:array<int,array<string,mixed>>,last_event_at:?string,last_event_type:?string,total_messages:int,last_24h:int,missed_calls:int,assessment:string}
*/
public function getPbxSummaryProperty(): array
{
$settings = (array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', []);
$provider = (string) ($settings['provider'] ?? 'panasonic_ns1000');
$channel = (string) ($settings['channel'] ?? 'hybrid');
$bridgeMode = (string) ($settings['bridge_mode'] ?? 'group-first');
$watchedExtensions = $this->resolveWatchedPbxExtensions($settings);
$responseGroups = array_values(array_filter((array) ($settings['response_groups'] ?? []), fn($row): bool => is_array($row)));
$incomingLines = array_values(array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row)));
if (! Schema::hasTable('communication_messages')) {
return [
'status' => ($watchedExtensions !== [] || $responseGroups !== [] || $incomingLines !== []) ? 'configured' : 'to-configure',
'label' => ($watchedExtensions !== [] || $responseGroups !== [] || $incomingLines !== []) ? 'Configurato, in attesa eventi' : 'Da configurare',
'provider' => $provider,
'channel' => $channel,
'bridge_mode' => $bridgeMode,
'watched_extensions' => $watchedExtensions,
'response_groups' => $responseGroups,
'incoming_lines' => $incomingLines,
'last_event_at' => null,
'last_event_type' => null,
'total_messages' => 0,
'last_24h' => 0,
'missed_calls' => 0,
'assessment' => 'La tabella communication_messages non e disponibile in questo ambiente, quindi il cruscotto mostra solo la configurazione PBX.',
];
}
$query = $this->pbxMessagesQuery();
$latest = (clone $query)->orderByDesc('received_at')->orderByDesc('id')->first();
$total = (clone $query)->count();
$last24h = (clone $query)->where('received_at', '>=', now()->subDay())->count();
$missedCalls = (clone $query)->where(function ($q): void {
$q->where('message_text', 'like', '%persa%')
->orWhere('metadata->outcome', 'like', '%miss%')
->orWhere('metadata->outcome', 'like', '%no_answer%');
})->count();
$status = 'to-configure';
$label = 'Da configurare';
if ($total > 0) {
$status = 'active';
$label = 'CRM collegato';
} elseif ($watchedExtensions !== [] || $responseGroups !== [] || $incomingLines !== []) {
$status = 'configured';
$label = 'Configurato, in attesa eventi';
}
$assessment = $bridgeMode === 'group-first'
? 'Con modalita group-first il watcher Windows puo vedere eventi sugli interni singoli ma il bridge usa come sorgente autorevole i gruppi di risposta. Questo e coerente con i log Panasonic gia acquisiti.'
: 'La configurazione e predisposta per usare gli eventi PBX direttamente nel CRM. Verifica i log del watcher Windows per confermare il flusso sul canale selezionato.';
return [
'status' => $status,
'label' => $label,
'provider' => $provider,
'channel' => $channel,
'bridge_mode' => $bridgeMode,
'watched_extensions'=> $watchedExtensions,
'response_groups' => $responseGroups,
'incoming_lines' => $incomingLines,
'last_event_at' => $latest?->received_at?->format('d/m/Y H:i:s'),
'last_event_type' => $latest ? (string) data_get($latest->metadata, 'event_type', $latest->status) : null,
'total_messages' => $total,
'last_24h' => $last24h,
'missed_calls' => $missedCalls,
'assessment' => $assessment,
];
}
/**
* @return array<int,array<string,mixed>>
*/
public function getPbxRecentMessagesProperty(): array
{
if (! Schema::hasTable('communication_messages')) {
return [];
}
return $this->pbxMessagesQuery()
->with('assignedUser:id,name')
->orderByDesc('received_at')
->orderByDesc('id')
->limit(12)
->get(['id', 'phone_number', 'target_extension', 'assigned_user_id', 'direction', 'status', 'received_at', 'metadata'])
->map(fn(CommunicationMessage $message): array => [
'id' => (int) $message->id,
'received_at' => $message->received_at?->format('d/m/Y H:i:s') ?? '-',
'phone_number' => (string) ($message->phone_number ?? '-'),
'target_extension' => (string) ($message->target_extension ?? data_get($message->metadata, 'called_extension', '-')),
'direction' => (string) ($message->direction ?? '-'),
'status' => (string) ($message->status ?? '-'),
'event_type' => (string) data_get($message->metadata, 'event_type', '-'),
'provider' => (string) data_get($message->metadata, 'provider', $message->channel),
'user' => (string) ($message->assignedUser?->name ?? '-'),
'studio_role' => (string) data_get($message->metadata, 'studio_role', '-'),
'studio_mode' => (string) data_get($message->metadata, 'studio_mode', '-'),
])
->all();
}
/**
* @return array<int,array<string,string>>
*/
public function getPbxRoutingRowsProperty(): array
{
$settings = (array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', []);
$centralino = (array) Arr::get($this->amministratore->impostazioni ?? [], 'centralino', []);
$groupRows = array_values(array_filter((array) ($settings['response_groups'] ?? []), fn($row): bool => is_array($row)));
$incoming = array_values(array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row)));
$routingRows = [
[
'type' => 'Studio',
'label' => 'Interno centralino',
'extension' => (string) Arr::get($centralino, 'interno_centralino', '-'),
'target' => 'Smistamento studio',
'note' => (string) Arr::get($centralino, 'numero_principale', '-'),
],
[
'type' => 'Studio',
'label' => 'Operatore',
'extension' => (string) Arr::get($centralino, 'interno_operatore', '-'),
'target' => 'Utente operatore',
'note' => 'Instradamento diretto',
],
[
'type' => 'Studio',
'label' => 'Amministratore',
'extension' => (string) Arr::get($centralino, 'interno_amministratore', '-'),
'target' => 'Utente amministratore',
'note' => 'Instradamento diretto',
],
[
'type' => 'Gruppo',
'label' => 'Gruppo giorno',
'extension' => (string) Arr::get($centralino, 'interno_gruppo_giorno', '-'),
'target' => 'Modalita giorno',
'note' => 'Routing shared',
],
[
'type' => 'Gruppo',
'label' => 'Gruppo notte',
'extension' => (string) Arr::get($centralino, 'interno_gruppo_notte', '-'),
'target' => 'Modalita notte',
'note' => 'Routing shared',
],
];
foreach ($groupRows as $row) {
$routingRows[] = [
'type' => 'Gruppo',
'label' => (string) ($row['label'] ?? 'Gruppo risposta'),
'extension' => (string) ($row['extension'] ?? '-'),
'target' => (string) ($row['target_extension'] ?? '-'),
'note' => (string) ($row['mode'] ?? '-'),
];
}
foreach ($incoming as $row) {
$routingRows[] = [
'type' => 'Linea',
'label' => (string) ($row['label'] ?? 'Linea entrante'),
'extension' => (string) ($row['number'] ?? '-'),
'target' => trim((string) (($row['route_group'] ?? '') !== '' ? $row['route_group'] : ($row['target_extension'] ?? '-'))),
'note' => (string) ($row['notes'] ?? '-'),
];
}
return $routingRows;
}
private function pbxMessagesQuery()
{
$extensions = $this->resolveWatchedPbxExtensions((array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx', []));
return CommunicationMessage::query()
->where('channel', 'panasonic_csta')
->where(function ($q) use ($extensions): void {
$q->where('metadata->amministratore_id', (int) $this->amministratore->id);
if ($extensions !== [] && Schema::hasColumn('communication_messages', 'target_extension')) {
$q->orWhereIn('target_extension', $extensions);
}
});
}
/**
* @return array<int,string>
*/
private function resolveWatchedPbxExtensions(array $settings): array
{
$centralinoExtensions = [
(string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_centralino', ''),
(string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_operatore', ''),
(string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_amministratore', ''),
(string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_gruppo_giorno', ''),
(string) Arr::get($this->amministratore->impostazioni ?? [], 'centralino.interno_gruppo_notte', ''),
];
$csvExtensions = preg_split('/\s*,\s*/', (string) ($settings['watch_extensions'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$groupExtensions = array_map(fn($row): string => (string) ($row['extension'] ?? ''), array_filter((array) ($settings['response_groups'] ?? []), fn($row): bool => is_array($row)));
$lineTargets = array_map(fn($row): string => (string) (($row['target_extension'] ?? '') !== '' ? $row['target_extension'] : ($row['route_group'] ?? '')), array_filter((array) ($settings['incoming_lines'] ?? []), fn($row): bool => is_array($row)));
return array_values(array_unique(array_filter(array_map(
fn($value): string => preg_replace('/\D+/', '', (string) $value) ?: '',
array_merge($centralinoExtensions, $csvExtensions, $groupExtensions, $lineTargets)
))));
}
public function salvaInternoCollaboratore(int $userId): void
{
$user = User::query()->find($userId);

View File

@ -4,6 +4,7 @@
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
use App\Models\ChiamataPostIt;
use App\Models\CommunicationMessage;
use App\Models\PbxClickToCallRequest;
use App\Models\RubricaUniversale;
use App\Models\Ticket;
use App\Models\User;
@ -214,9 +215,16 @@ public function getChiamateTecnicheProperty()
try {
$query = CommunicationMessage::query()
->with(['assignedUser:id,name', 'stabile:id,denominazione'])
->whereIn('channel', ['smdr', 'panasonic_csta'])
->latest('id');
if ($this->activeTab === 'smdr') {
$query->where('channel', 'smdr');
} elseif ($this->activeTab === 'csta') {
$query->where('channel', 'panasonic_csta');
} else {
$query->whereIn('channel', ['smdr', 'panasonic_csta']);
}
if ($this->tecnicoCallView === 'interne') {
$query->where('direction', 'internal');
} elseif ($this->tecnicoCallView === 'esterne') {
@ -251,6 +259,32 @@ public function getChiamateTecnicheProperty()
}
}
public function getRecentClickToCallRequestsProperty()
{
if (! Schema::hasTable('pbx_click_to_call_requests')) {
return collect();
}
$user = Auth::user();
if (! $user instanceof User) {
return collect();
}
$extension = $this->normalizeExtension((string) ($user->pbx_extension ?? ''));
return PbxClickToCallRequest::query()
->when($extension !== '', function ($query) use ($extension, $user): void {
$query->where(function ($inner) use ($extension, $user): void {
$inner->where('source_extension', $extension)
->orWhere('requested_by_user_id', (int) $user->id);
});
}, fn($query) => $query->where('requested_by_user_id', (int) $user->id))
->orderByDesc('requested_at')
->orderByDesc('id')
->limit(20)
->get();
}
public function creaPostItDaMessaggio(int $messageId): void
{
if (! $this->isPostItTableReady()) {
@ -268,10 +302,10 @@ public function creaPostItDaMessaggio(int $messageId): void
return;
}
$sourceKey = (string) ($message->channel ?: 'cti');
$sourceKey = (string) ($message->channel ?: 'cti');
$sourceLabel = $sourceKey === 'panasonic_csta' ? 'Panasonic CTI' : 'SMDR';
$smdr = (array) data_get($message->metadata, 'smdr', []);
$line = trim((string) ($message->message_text ?? ''));
$smdr = (array) data_get($message->metadata, 'smdr', []);
$line = trim((string) ($message->message_text ?? ''));
$postIt = ChiamataPostIt::query()->create([
'stabile_id' => $message->stabile_id,
@ -355,6 +389,86 @@ public function getTecnicoNominativo(CommunicationMessage $message): ?string
return $this->getRubricaNomeByPhone($phone);
}
public function getTecnicoProviderLabel(CommunicationMessage $message): string
{
$provider = strtolower(trim((string) data_get($message->metadata, 'provider', '')));
if ($provider === '') {
$provider = (string) $message->channel;
}
return match ($provider) {
'smdr' => 'SMDR',
'panasonic_csta' => 'CSTA',
'panasonic_ns1000' => 'Panasonic NS1000',
'freepbx' => 'FreePBX',
default => strtoupper(str_replace('_', ' ', $provider)),
};
}
public function getTecnicoLineaLabel(CommunicationMessage $message): string
{
if ((string) $message->channel === 'smdr') {
$line = trim((string) data_get($message->metadata, 'smdr.co', ''));
return $line !== '' ? $line : '-';
}
$eventType = trim((string) data_get($message->metadata, 'event_type', ''));
$providerCallId = trim((string) data_get($message->metadata, 'provider_call_id', ''));
$parts = array_values(array_filter([$eventType, $providerCallId], fn(string $value): bool => $value !== ''));
return $parts !== [] ? implode(' / ', $parts) : '-';
}
public function richiediClickToCallDaMessaggio(int $messageId): void
{
$user = Auth::user();
if (! $user instanceof User) {
return;
}
$extension = $this->normalizeExtension((string) ($user->pbx_extension ?? ''));
if (! (bool) ($user->pbx_click_to_call_enabled ?? false) || $extension === '') {
Notification::make()->title('Click-to-call non abilitato per il tuo utente')->warning()->send();
return;
}
$message = CommunicationMessage::query()->find($messageId);
if (! $message) {
Notification::make()->title('Messaggio tecnico non trovato')->warning()->send();
return;
}
$phone = preg_replace('/\D+/', '', (string) ($message->phone_number ?: data_get($message->metadata, 'smdr.dial_number', '')));
if (! is_string($phone) || $phone === '') {
Notification::make()->title('Numero non valido')->warning()->send();
return;
}
PbxClickToCallRequest::query()->create([
'requested_by_user_id' => (int) $user->id,
'assigned_user_id' => (int) $user->id,
'stabile_id' => $message->stabile_id,
'communication_message_id' => (int) $message->id,
'source_extension' => $extension,
'target_number' => $phone,
'status' => 'pending',
'note' => 'Richiesta da Post-it Gestione [' . $this->getTecnicoProviderLabel($message) . ']',
'requested_at' => now(),
'metadata' => [
'requested_from' => 'post_it_gestione',
'source_channel' => (string) $message->channel,
'provider' => (string) data_get($message->metadata, 'provider', $message->channel),
'target_extension' => (string) ($message->target_extension ?? ''),
],
]);
Notification::make()
->title('Richiesta click-to-call registrata')
->body('Interno ' . $extension . ' -> ' . $phone)
->success()
->send();
}
public function getCollaboratoreNomeByExtension(?string $extension): ?string
{
$normalized = $this->normalizeExtension($extension);

View File

@ -13,6 +13,7 @@
use App\Models\User;
use App\Services\Documenti\ImageTextExtractionService;
use App\Services\Documenti\PdfTextExtractionService;
use App\Services\Support\TicketAttachmentUploadService;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
@ -540,21 +541,16 @@ public function caricaAllegati(): void
continue;
}
$path = $file->store('ticket-gestione/' . $ticket->id, 'public');
$mime = TicketAttachment::normalizeMimeType(
method_exists($file, 'getClientMimeType') ? (string) $file->getClientMimeType() : '',
method_exists($file, 'getClientOriginalName') ? (string) $file->getClientOriginalName() : '',
$path
);
$stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-gestione/' . $ticket->id);
$attachment = TicketAttachment::query()->create([
'ticket_id' => $ticket->id,
'ticket_update_id' => null,
'user_id' => Auth::id(),
'file_path' => $path,
'original_file_name' => (string) $file->getClientOriginalName(),
'mime_type' => $mime,
'size' => (int) $file->getSize(),
'file_path' => $stored['path'],
'original_file_name' => $stored['original_name'],
'mime_type' => $stored['mime'],
'size' => $stored['size'],
'description' => $this->descrizioneAllegati ?: 'Allegato da gestione ticket',
]);

View File

@ -5,15 +5,14 @@
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
use App\Models\CategoriaTicket;
use App\Models\ChiamataPostIt;
use App\Models\CommunicationMessage;
use App\Models\PbxClickToCallRequest;
use App\Models\RubricaUniversale;
use App\Models\Soggetto;
use App\Models\Stabile;
use App\Models\Ticket;
use App\Models\TicketAttachment;
use App\Models\User;
use App\Services\Cti\PbxRoutingService;
use App\Services\Cti\LiveIncomingCallService;
use App\Services\Support\TicketAttachmentUploadService;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
@ -130,6 +129,7 @@ public function mount(): void
}
$this->refreshLiveCallBanner();
$this->applyLiveCallQueryPrefill();
$this->refreshData();
}
@ -160,54 +160,8 @@ public function refreshLiveCallBanner(): void
return;
}
$userExtension = trim((string) ($authUser->pbx_extension ?? ''));
$watchedExtensions = app(PbxRoutingService::class)->getWatchedExtensionsForUser($authUser);
$popupRestricted = (bool) ($authUser->pbx_popup_enabled ?? false) && count($watchedExtensions) > 0;
$latest = CommunicationMessage::query()
->whereIn('channel', ['smdr', 'panasonic_csta'])
->where('direction', 'inbound')
->whereNotNull('received_at')
->where('received_at', '>=', now()->subMinutes(8))
->when($popupRestricted, function ($q) use ($watchedExtensions): void {
$q->whereIn('target_extension', $watchedExtensions);
})
->latest('id')
->first(['id', 'phone_number', 'target_extension', 'received_at', 'message_text', 'metadata']);
if (! $latest) {
$this->liveIncomingCall = null;
$this->liveCallCanClickToCall = false;
return;
}
$phone = $this->normalizePhoneDigits((string) ($latest->phone_number ?? ''));
if ($phone === '') {
$phone = $this->normalizePhoneDigits((string) data_get($latest->metadata, 'smdr.dial_number', ''));
}
if ($phone === '') {
$this->liveIncomingCall = null;
$this->liveCallCanClickToCall = false;
return;
}
$rubrica = $this->matchRubricaByPhone($phone);
$soggetto = $this->matchSoggetto($rubrica, $phone);
$this->liveIncomingCall = [
'message_id' => (int) $latest->id,
'phone' => $phone,
'received_at' => optional($latest->received_at)->format('d/m/Y H:i:s'),
'line' => (string) ($latest->message_text ?? ''),
'target_extension' => (string) ($latest->target_extension ?? ''),
'rubrica_id' => (int) ($rubrica?->id ?? 0),
'rubrica_nome' => (string) ($rubrica?->nome_completo ?: $rubrica?->ragione_sociale ?: ''),
'soggetto_id' => (int) ($soggetto?->id ?? 0),
];
$this->liveCallCanClickToCall = (bool) ($authUser->pbx_click_to_call_enabled ?? false)
&& $userExtension !== '';
$this->liveIncomingCall = app(LiveIncomingCallService::class)->getForUser($authUser);
$this->liveCallCanClickToCall = (bool) ($this->liveIncomingCall['can_click_to_call'] ?? false);
}
public function creaPostItDaChiamataInIngresso(): void
@ -322,6 +276,10 @@ public function richiediClickToCallDaInterno(): void
public function getLiveRubricaUrl(): ?string
{
if (! empty($this->liveIncomingCall['rubrica_url'])) {
return (string) $this->liveIncomingCall['rubrica_url'];
}
$rubricaId = (int) ($this->liveIncomingCall['rubrica_id'] ?? 0);
if ($rubricaId <= 0) {
return null;
@ -332,6 +290,10 @@ public function getLiveRubricaUrl(): ?string
public function getLiveEstrattoContoUrl(): ?string
{
if (! empty($this->liveIncomingCall['estratto_conto_url'])) {
return (string) $this->liveIncomingCall['estratto_conto_url'];
}
$soggettoId = (int) ($this->liveIncomingCall['soggetto_id'] ?? 0);
if ($soggettoId <= 0) {
return null;
@ -345,6 +307,16 @@ public function getRubricaFilamentUrl(): string
return route('filament.admin-filament.pages.gescon.anagrafica.rubrica-universale');
}
public function getAdminMobileHubUrl(): string
{
return route('admin.mobile');
}
public function getCondominoMobileTicketUrl(): string
{
return route('condomino.tickets.mobile');
}
public function getDashboardFilamentUrl(): string
{
return route('filament.admin-filament.pages.dashboard');
@ -701,16 +673,16 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int
}
try {
$path = $file->store('ticket-mobile/' . $ticket->id, 'public');
$stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-mobile/' . $ticket->id);
TicketAttachment::query()->create([
'ticket_id' => (int) $ticket->id,
'ticket_update_id' => null,
'user_id' => $userId,
'file_path' => $path,
'original_file_name' => (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : basename($path)),
'mime_type' => (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'),
'size' => (int) (method_exists($file, 'getSize') ? $file->getSize() : 0),
'file_path' => $stored['path'],
'original_file_name' => $stored['original_name'],
'mime_type' => $stored['mime'],
'size' => $stored['size'],
'description' => $this->resolveAttachmentDescription((int) $index),
]);
@ -781,55 +753,22 @@ private function normalizePhoneDigits(string $value): string
return preg_replace('/\D+/', '', $value) ?: '';
}
private function matchRubricaByPhone(string $digits): ?RubricaUniversale
private function applyLiveCallQueryPrefill(): void
{
if ($digits === '') {
return null;
$phone = $this->normalizePhoneDigits((string) request()->query('live_phone', ''));
if ($phone === '') {
return;
}
$needle = '%' . $digits . '%';
$tail = strlen($digits) >= 7 ? substr($digits, -7) : $digits;
$tailNeedle = '%' . $tail . '%';
return RubricaUniversale::query()
->where(function ($q) use ($needle, $tailNeedle): void {
$q->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle]);
})
->orderByDesc('updated_at')
->first();
}
private function matchSoggetto(?RubricaUniversale $rubrica, string $phone): ?Soggetto
{
if ($rubrica) {
$cf = trim((string) ($rubrica->codice_fiscale ?? ''));
if ($cf !== '') {
$found = Soggetto::query()->where('codice_fiscale', $cf)->first();
if ($found) {
return $found;
}
}
$piva = trim((string) ($rubrica->partita_iva ?? ''));
if ($piva !== '') {
$found = Soggetto::query()->where('partita_iva', $piva)->first();
if ($found) {
return $found;
}
}
if (! $this->liveIncomingCall) {
$this->liveIncomingCall = [
'message_id' => (int) request()->query('live_message_id', 0),
'phone' => $phone,
'line' => (string) request()->query('live_note', ''),
];
}
$tail = strlen($phone) >= 7 ? substr($phone, -7) : $phone;
if ($tail === '') {
return null;
}
return Soggetto::query()->where('telefono', 'like', '%' . $tail . '%')->first();
$this->prefillTicketFromLiveCall($phone, (string) request()->query('live_note', ''));
}
public function excerpt(?string $text, int $limit = 160): string

View File

@ -6,6 +6,7 @@
use App\Models\TicketAttachment;
use App\Models\TicketIntervento;
use App\Models\User;
use App\Services\Support\TicketAttachmentUploadService;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
@ -218,16 +219,16 @@ public function caricaAllegati(): void
continue;
}
$path = $file->store('ticket-scheda/' . $ticket->id, 'public');
$stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-scheda/' . $ticket->id);
TicketAttachment::query()->create([
'ticket_id' => $ticket->id,
'ticket_update_id' => null,
'user_id' => Auth::id(),
'file_path' => $path,
'original_file_name' => (string) $file->getClientOriginalName(),
'mime_type' => (string) $file->getClientMimeType(),
'size' => (int) $file->getSize(),
'file_path' => $stored['path'],
'original_file_name' => $stored['original_name'],
'mime_type' => $stored['mime'],
'size' => $stored['size'],
'description' => $this->descrizioneAllegati ?: 'Allegato da scheda ticket',
]);

View File

@ -0,0 +1,79 @@
<?php
namespace App\Livewire\Filament;
use App\Models\ChiamataPostIt;
use App\Models\User;
use App\Services\Cti\LiveIncomingCallService;
use App\Support\StabileContext;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class TopbarLiveCall extends Component
{
/** @var array<string,mixed>|null */
public ?array $liveIncomingCall = null;
public function mount(): void
{
$this->refreshLiveIncomingCall();
}
public function refreshLiveIncomingCall(): void
{
$user = Auth::user();
if (! $user instanceof User) {
$this->liveIncomingCall = null;
return;
}
$this->liveIncomingCall = app(LiveIncomingCallService::class)->getForUser($user);
}
public function createPostIt(): void
{
$user = Auth::user();
if (! $user instanceof User || ! $this->liveIncomingCall) {
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
Notification::make()->title('Seleziona prima uno stabile attivo')->warning()->send();
return;
}
ChiamataPostIt::query()->create([
'stabile_id' => (int) $stabileId,
'creato_da_user_id' => (int) $user->id,
'rubrica_id' => (int) ($this->liveIncomingCall['rubrica_id'] ?? 0) ?: null,
'telefono' => (string) ($this->liveIncomingCall['phone'] ?? ''),
'nome_chiamante' => (string) (($this->liveIncomingCall['rubrica_nome'] ?? '') ?: ('Chiamata ' . ($this->liveIncomingCall['phone'] ?? ''))),
'direzione' => 'in_arrivo',
'origine' => 'topbar_live_call',
'origine_id' => (string) ($this->liveIncomingCall['message_id'] ?? ''),
'oggetto' => 'Chiamata in arrivo da valutare',
'nota' => (string) ($this->liveIncomingCall['line'] ?? ('Chiamata in ingresso da ' . ($this->liveIncomingCall['phone'] ?? ''))),
'priorita' => 'Media',
'stato' => 'post_it',
'chiamata_il' => now(),
]);
Notification::make()->title('Post-it creato dalla testata')->success()->send();
}
public function openTicketMobile()
{
if (! $this->liveIncomingCall || empty($this->liveIncomingCall['ticket_mobile_url'])) {
return null;
}
return redirect()->to((string) $this->liveIncomingCall['ticket_mobile_url']);
}
public function render()
{
return view('livewire.filament.topbar-live-call');
}
}

View File

@ -55,6 +55,16 @@ public function panel(Panel $panel): Panel
->visible(function () {
$user = Auth::user();
return $user instanceof \App\Models\User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}),
NavigationItem::make('Cellulare / WebApp')
->group('NetGescon')
->icon('heroicon-o-phone')
->url(fn() => route('admin.mobile'))
->visible(function () {
$user = Auth::user();
return $user instanceof \App\Models\User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'collaboratore']);
}),

View File

@ -0,0 +1,134 @@
<?php
namespace App\Services\Cti;
use App\Filament\Pages\Supporto\TicketMobile;
use App\Models\CommunicationMessage;
use App\Models\RubricaUniversale;
use App\Models\Soggetto;
use App\Models\User;
class LiveIncomingCallService
{
/**
* @return array<string,mixed>|null
*/
public function getForUser(User $user): ?array
{
$userExtension = trim((string) ($user->pbx_extension ?? ''));
$watchedExtensions = app(PbxRoutingService::class)->getWatchedExtensionsForUser($user);
$popupRestricted = (bool) ($user->pbx_popup_enabled ?? false) && count($watchedExtensions) > 0;
$latest = CommunicationMessage::query()
->whereIn('channel', ['smdr', 'panasonic_csta'])
->where('direction', 'inbound')
->whereNotNull('received_at')
->where('received_at', '>=', now()->subMinutes(8))
->when($popupRestricted, function ($query) use ($watchedExtensions): void {
$query->whereIn('target_extension', $watchedExtensions);
})
->latest('id')
->first(['id', 'phone_number', 'target_extension', 'received_at', 'message_text', 'metadata']);
if (! $latest) {
return null;
}
$phone = $this->normalizePhoneDigits((string) ($latest->phone_number ?? ''));
if ($phone === '') {
$phone = $this->normalizePhoneDigits((string) data_get($latest->metadata, 'smdr.dial_number', ''));
}
if ($phone === '') {
return null;
}
$rubrica = $this->matchRubricaByPhone($phone);
$soggetto = $this->matchSoggetto($rubrica, $phone);
return [
'message_id' => (int) $latest->id,
'phone' => $phone,
'received_at' => optional($latest->received_at)->format('d/m/Y H:i:s'),
'line' => (string) ($latest->message_text ?? ''),
'target_extension' => (string) ($latest->target_extension ?? ''),
'rubrica_id' => (int) ($rubrica?->id ?? 0),
'rubrica_nome' => (string) ($rubrica?->nome_completo ?: $rubrica?->ragione_sociale ?: ''),
'soggetto_id' => (int) ($soggetto?->id ?? 0),
'can_click_to_call' => (bool) ($user->pbx_click_to_call_enabled ?? false) && $userExtension !== '',
'ticket_mobile_url' => TicketMobile::getUrl([
'live_phone' => $phone,
'live_message_id' => (int) $latest->id,
'live_note' => (string) ($latest->message_text ?? ''),
], panel: 'admin-filament'),
'rubrica_url' => (int) ($rubrica?->id ?? 0) > 0
? \App\Filament\Pages\Gescon\RubricaUniversaleScheda::getUrl(['record' => (int) $rubrica->id], panel: 'admin-filament')
: null,
'estratto_conto_url' => (int) ($soggetto?->id ?? 0) > 0
? \App\Filament\Pages\Contabilita\EstrattoContoSoggetto::getUrl(['record' => (int) $soggetto->id], panel: 'admin-filament')
: null,
'post_it_redirect_url' => TicketMobile::getUrl([
'live_phone' => $phone,
'live_message_id' => (int) $latest->id,
'live_note' => (string) ($latest->message_text ?? ''),
'live_action' => 'post-it',
], panel: 'admin-filament'),
];
}
private function normalizePhoneDigits(string $value): string
{
return preg_replace('/\D+/', '', $value) ?: '';
}
private function matchRubricaByPhone(string $digits): ?RubricaUniversale
{
if ($digits === '') {
return null;
}
$needle = '%' . $digits . '%';
$tail = strlen($digits) >= 7 ? substr($digits, -7) : $digits;
$tailNeedle = '%' . $tail . '%';
return RubricaUniversale::query()
->where(function ($query) use ($needle, $tailNeedle): void {
$query->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$needle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_cellulare, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_ufficio, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle])
->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(telefono_casa, ''), ' ', ''), '+', ''), '-', ''), '(', ''), ')', '') LIKE ?", [$tailNeedle]);
})
->orderByDesc('updated_at')
->first();
}
private function matchSoggetto(?RubricaUniversale $rubrica, string $phone): ?Soggetto
{
if ($rubrica) {
$cf = trim((string) ($rubrica->codice_fiscale ?? ''));
if ($cf !== '') {
$found = Soggetto::query()->where('codice_fiscale', $cf)->first();
if ($found) {
return $found;
}
}
$piva = trim((string) ($rubrica->partita_iva ?? ''));
if ($piva !== '') {
$found = Soggetto::query()->where('partita_iva', $piva)->first();
if ($found) {
return $found;
}
}
}
$tail = strlen($phone) >= 7 ? substr($phone, -7) : $phone;
if ($tail === '') {
return null;
}
return Soggetto::query()->where('telefono', 'like', '%' . $tail . '%')->first();
}
}

View File

@ -90,6 +90,7 @@ public function getWatchedExtensionsForUser(User $user): array
*/
public function getStudioExtensionsForAmministratore(Amministratore $amministratore): array
{
$pbx = (array) (($amministratore->impostazioni ?? [])['pbx'] ?? []);
$centralino = (array) (($amministratore->impostazioni ?? [])['centralino'] ?? []);
$extensions = [
@ -100,6 +101,29 @@ public function getStudioExtensionsForAmministratore(Amministratore $amministrat
$this->normalizeExtension((string) Arr::get($centralino, 'interno_gruppo_notte', '')),
];
$watchExtensions = preg_split('/\s*,\s*/', (string) ($pbx['watch_extensions'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
foreach ($watchExtensions as $extension) {
$extensions[] = $this->normalizeExtension((string) $extension);
}
foreach ((array) ($pbx['response_groups'] ?? []) as $group) {
if (! is_array($group)) {
continue;
}
$extensions[] = $this->normalizeExtension((string) ($group['extension'] ?? ''));
$extensions[] = $this->normalizeExtension((string) ($group['target_extension'] ?? ''));
}
foreach ((array) ($pbx['incoming_lines'] ?? []) as $line) {
if (! is_array($line)) {
continue;
}
$extensions[] = $this->normalizeExtension((string) ($line['route_group'] ?? ''));
$extensions[] = $this->normalizeExtension((string) ($line['target_extension'] ?? ''));
}
return array_values(array_unique(array_filter($extensions, fn($extension): bool => $extension !== '')));
}
@ -118,6 +142,7 @@ public function findStudioContextByExtension(?string $extension): array
}
foreach (Amministratore::query()->get(['id', 'impostazioni']) as $amministratore) {
$pbx = (array) (($amministratore->impostazioni ?? [])['pbx'] ?? []);
$centralino = (array) (($amministratore->impostazioni ?? [])['centralino'] ?? []);
$map = [
'interno_centralino' => ['studio_role' => 'centralino', 'studio_mode' => null],
@ -138,6 +163,39 @@ public function findStudioContextByExtension(?string $extension): array
'studio_mode' => $context['studio_mode'],
];
}
foreach ((array) ($pbx['response_groups'] ?? []) as $group) {
if (! is_array($group)) {
continue;
}
if ($this->normalizeExtension((string) ($group['extension'] ?? '')) !== $normalized) {
continue;
}
return [
'amministratore_id' => (int) $amministratore->id,
'studio_role' => 'gruppo',
'studio_mode' => filled($group['mode'] ?? null) ? (string) $group['mode'] : null,
];
}
foreach ((array) ($pbx['incoming_lines'] ?? []) as $line) {
if (! is_array($line)) {
continue;
}
$routeExtension = $this->normalizeExtension((string) (($line['route_group'] ?? '') !== '' ? $line['route_group'] : ($line['target_extension'] ?? '')));
if ($routeExtension !== $normalized) {
continue;
}
return [
'amministratore_id' => (int) $amministratore->id,
'studio_role' => 'linea',
'studio_mode' => filled($line['label'] ?? null) ? (string) $line['label'] : null,
];
}
}
return [

View File

@ -0,0 +1,119 @@
<?php
namespace App\Services\Support;
use App\Models\TicketAttachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class TicketAttachmentUploadService
{
/**
* @param UploadedFile|TemporaryUploadedFile $file
* @return array{path:string,mime:string,size:int,original_name:string}
*/
public function store(object $file, string $directory): array
{
$path = $file->store($directory, 'public');
$originalName = method_exists($file, 'getClientOriginalName')
? (string) $file->getClientOriginalName()
: basename($path);
$mime = TicketAttachment::normalizeMimeType(
method_exists($file, 'getClientMimeType') ? (string) $file->getClientMimeType() : '',
$originalName,
$path,
);
if (str_starts_with($mime, 'image/')) {
$this->optimizeImage($path, $mime);
$mime = TicketAttachment::normalizeMimeType($mime, $originalName, $path);
}
$size = 0;
try {
$size = (int) Storage::disk('public')->size($path);
} catch (\Throwable) {
$size = (int) (method_exists($file, 'getSize') ? $file->getSize() : 0);
}
return [
'path' => $path,
'mime' => $mime,
'size' => $size,
'original_name' => $originalName,
];
}
private function optimizeImage(string $path, string $mime): void
{
$absolutePath = Storage::disk('public')->path($path);
if (! is_file($absolutePath) || ! function_exists('getimagesize')) {
return;
}
$size = @getimagesize($absolutePath);
if (! is_array($size)) {
return;
}
[$width, $height] = $size;
if ($width <= 0 || $height <= 0) {
return;
}
$maxWidth = 1600;
$maxHeight = 1600;
$ratio = min($maxWidth / $width, $maxHeight / $height, 1);
$targetW = (int) max(1, round($width * $ratio));
$targetH = (int) max(1, round($height * $ratio));
$source = $this->createImageResource($absolutePath, $mime);
if (! $source) {
return;
}
$target = imagecreatetruecolor($targetW, $targetH);
if ($target === false) {
imagedestroy($source);
return;
}
if (in_array($mime, ['image/png', 'image/webp'], true)) {
imagealphablending($target, false);
imagesavealpha($target, true);
$transparent = imagecolorallocatealpha($target, 255, 255, 255, 127);
imagefilledrectangle($target, 0, 0, $targetW, $targetH, $transparent);
}
imagecopyresampled($target, $source, 0, 0, 0, 0, $targetW, $targetH, $width, $height);
switch ($mime) {
case 'image/png':
imagepng($target, $absolutePath, 6);
break;
case 'image/webp':
if (function_exists('imagewebp')) {
imagewebp($target, $absolutePath, 82);
}
break;
default:
imagejpeg($target, $absolutePath, 82);
break;
}
imagedestroy($target);
imagedestroy($source);
}
private function createImageResource(string $absolutePath, string $mime)
{
return match ($mime) {
'image/png' => function_exists('imagecreatefrompng') ? @imagecreatefrompng($absolutePath) : false,
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($absolutePath) : false,
default => function_exists('imagecreatefromjpeg') ? @imagecreatefromjpeg($absolutePath) : false,
};
}
}

View File

@ -35,7 +35,7 @@
$haRiscaldamento = (bool) ($stabileAttivo?->riscaldamento_centralizzato ?? false);
@endphp
<div class="flex items-center gap-2 ps-3">
<div class="flex min-w-0 items-center gap-2 ps-3">
<x-filament::dropdown placement="bottom-start">
<x-slot name="trigger">
<button type="button" class="flex items-center gap-2">
@ -92,6 +92,8 @@
</div>
</x-filament::dropdown>
<livewire:filament.topbar-live-call />
<button
type="button"
class="fi-icon-btn fi-color-gray"

View File

@ -7,6 +7,21 @@
]"
/>
<div class="rounded-2xl border border-slate-200 bg-gradient-to-r from-slate-900 via-slate-800 to-emerald-800 p-4 text-white shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<div class="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-200">Cellulare e WebApp</div>
<div class="mt-1 text-lg font-semibold">Accessi rapidi per chiamate, ticket e consultazione mobile</div>
<div class="mt-1 text-sm text-slate-200">Metto in evidenza i collegamenti operativi che servono quando stai lavorando dal telefono o stai gestendo una chiamata in ingresso.</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ route('admin.mobile') }}" class="inline-flex items-center rounded-md bg-emerald-500 px-3 py-2 text-sm font-medium text-slate-950 hover:bg-emerald-400">Hub Cellulare</a>
<a href="{{ \App\Filament\Pages\Supporto\TicketMobile::getUrl(panel: 'admin-filament') }}" class="inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-medium text-slate-900 hover:bg-slate-100">Ticket Mobile</a>
<a href="{{ route('condomino.tickets.mobile') }}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center rounded-md border border-white/30 bg-white/10 px-3 py-2 text-sm font-medium text-white hover:bg-white/20">WebApp Condomino</a>
</div>
</div>
</div>
<livewire:gescon.rubrica-universale-table />
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,136 @@
@php($summary = $this->pbxSummary)
<div class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Stato integrazione Panasonic / TAPI</div>
<div class="mb-3 text-xs text-gray-500">Questa sezione tiene insieme configurazione PBX, gruppi di risposta, linee entranti e ultimi eventi CRM letti dal canale Panasonic.</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-4">
<div class="rounded-md border border-slate-200 bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Stato</div>
<div class="mt-1 text-sm font-semibold text-slate-800">{{ $summary['label'] }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $summary['assessment'] }}</div>
</div>
<div class="rounded-md border border-slate-200 bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Provider</div>
<div class="mt-1 text-sm font-semibold text-slate-800">{{ $summary['provider'] }}</div>
<div class="mt-1 text-xs text-slate-500">Canale: {{ $summary['channel'] }} · Bridge: {{ $summary['bridge_mode'] }}</div>
</div>
<div class="rounded-md border border-slate-200 bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Eventi CRM</div>
<div class="mt-1 text-sm font-semibold text-slate-800">{{ $summary['total_messages'] }}</div>
<div class="mt-1 text-xs text-slate-500">Ultime 24h: {{ $summary['last_24h'] }} · Perse: {{ $summary['missed_calls'] }}</div>
</div>
<div class="rounded-md border border-slate-200 bg-slate-50 p-3">
<div class="text-[11px] uppercase tracking-wide text-slate-500">Ultimo evento</div>
<div class="mt-1 text-sm font-semibold text-slate-800">{{ $summary['last_event_type'] ?: 'Nessun evento' }}</div>
<div class="mt-1 text-xs text-slate-500">{{ $summary['last_event_at'] ?: 'Nessun timestamp disponibile' }}</div>
</div>
</div>
<div class="mt-3 text-xs text-gray-600">
<span class="font-semibold">Interni osservati:</span>
{{ count($summary['watched_extensions']) > 0 ? implode(', ', $summary['watched_extensions']) : 'nessuno configurato' }}
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Matrice di instradamento</div>
<div class="overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-1.5 text-left">Tipo</th>
<th class="border px-2 py-1.5 text-left">Voce</th>
<th class="border px-2 py-1.5 text-left">Interno / numero</th>
<th class="border px-2 py-1.5 text-left">Target</th>
<th class="border px-2 py-1.5 text-left">Note</th>
</tr>
</thead>
<tbody>
@foreach($this->pbxRoutingRows as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5">{{ $row['type'] }}</td>
<td class="border px-2 py-1.5">{{ $row['label'] }}</td>
<td class="border px-2 py-1.5">{{ $row['extension'] }}</td>
<td class="border px-2 py-1.5">{{ $row['target'] }}</td>
<td class="border px-2 py-1.5">{{ $row['note'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Gruppi e linee configurate</div>
<div class="mb-3">
<div class="mb-1 text-xs font-semibold text-gray-700">Gruppi di risposta</div>
<div class="text-xs text-gray-600">
@if(count($summary['response_groups']) > 0)
@foreach($summary['response_groups'] as $row)
<div>{{ $row['extension'] ?? '-' }} · {{ $row['label'] ?? 'Gruppo' }} · fallback {{ $row['target_extension'] ?? '-' }} · modalita {{ $row['mode'] ?? '-' }}</div>
@endforeach
@else
<div>Nessun gruppo aggiuntivo configurato. Restano validi i campi studio 601 e 603.</div>
@endif
</div>
</div>
<div>
<div class="mb-1 text-xs font-semibold text-gray-700">Linee entranti</div>
<div class="text-xs text-gray-600">
@if(count($summary['incoming_lines']) > 0)
@foreach($summary['incoming_lines'] as $row)
<div>{{ $row['number'] ?? '-' }} · {{ $row['label'] ?? 'Linea' }} · gruppo {{ $row['route_group'] ?? '-' }} · interno {{ $row['target_extension'] ?? '-' }}</div>
@endforeach
@else
<div>Nessuna linea entrante mappata. Usa questa sezione per separare flussi clienti, fornitori e reperibilita.</div>
@endif
</div>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-3">
<div class="mb-2 text-sm font-semibold text-gray-700">Ultimi eventi letti dal CRM</div>
<div class="mb-3 text-xs text-gray-500">Se qui compaiono record, il bridge Panasonic sta gia portando nel CRM almeno una parte del traffico telefonico.</div>
<div class="overflow-x-auto">
<table class="min-w-full border-collapse border text-xs">
<thead>
<tr class="bg-slate-100 text-slate-700">
<th class="border px-2 py-1.5 text-left">Quando</th>
<th class="border px-2 py-1.5 text-left">Evento</th>
<th class="border px-2 py-1.5 text-left">Direzione</th>
<th class="border px-2 py-1.5 text-left">Telefono</th>
<th class="border px-2 py-1.5 text-left">Interno</th>
<th class="border px-2 py-1.5 text-left">Utente</th>
<th class="border px-2 py-1.5 text-left">Ruolo</th>
<th class="border px-2 py-1.5 text-left">Stato</th>
</tr>
</thead>
<tbody>
@forelse($this->pbxRecentMessages as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5">{{ $row['received_at'] }}</td>
<td class="border px-2 py-1.5">{{ $row['event_type'] }}</td>
<td class="border px-2 py-1.5">{{ $row['direction'] }}</td>
<td class="border px-2 py-1.5">{{ $row['phone_number'] }}</td>
<td class="border px-2 py-1.5">{{ $row['target_extension'] }}</td>
<td class="border px-2 py-1.5">{{ $row['user'] }}</td>
<td class="border px-2 py-1.5">{{ $row['studio_role'] }}{{ $row['studio_mode'] !== '-' ? ' / ' . $row['studio_mode'] : '' }}</td>
<td class="border px-2 py-1.5">{{ $row['status'] }}</td>
</tr>
@empty
<tr>
<td colspan="8" class="border px-2 py-3 text-center text-gray-500">Nessun evento Panasonic presente nel CRM per questo amministratore.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>

View File

@ -58,6 +58,8 @@
<div class="flex flex-wrap items-center gap-2">
<a href="{{ $this->getDashboardFilamentUrl() }}" class="inline-flex items-center rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Dashboard Filament</a>
<a href="{{ $this->getRubricaFilamentUrl() }}" 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">Rubrica Unica Filament</a>
<a href="{{ $this->getAdminMobileHubUrl() }}" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">Hub Cellulare / WebApp</a>
<a href="{{ $this->getCondominoMobileTicketUrl() }}" class="inline-flex items-center rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-500" target="_blank" rel="noopener noreferrer">WebApp Ticket Condomino</a>
<a href="{{ $this->getTicketArchivioUrl() }}" class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500">Archivio Ticket Completo</a>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
@ -109,9 +111,19 @@
<div class="mt-3 text-xs text-gray-500">Nessun contatto trovato con questa ricerca.</div>
@endif
<div class="mt-4 rounded-lg border border-dashed p-3">
<div class="mt-4 rounded-lg border border-dashed p-3" x-data="ticketMobilePreview()">
<div class="text-sm font-semibold">Nuovo ticket rapido</div>
<div class="mt-1 text-xs text-gray-500">Dopo aver selezionato il chiamante, compila e crea il ticket.</div>
@if($errors->any())
<div class="mt-3 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-800">
<div class="font-semibold">Il ticket non e stato inviato.</div>
<ul class="mt-1 list-disc ps-4">
@foreach($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="mt-3 grid gap-3 md:grid-cols-2">
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Titolo</span>
@ -167,21 +179,46 @@
<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="newTicketCameraShots" multiple accept="image/*" capture="environment" class="hidden" />
<input type="file" wire:model="newTicketCameraShots" x-on:change="ingest($event, 'camera')" multiple accept="image/*" capture="environment" class="hidden" />
</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="newTicketAttachments" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,image/*" class="hidden" />
<input type="file" wire:model="newTicketAttachments" x-on:change="ingest($event, 'attachment')" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,image/*" class="hidden" />
</label>
</div>
<div class="mt-1 text-xs text-gray-500">Puoi caricare fino a 10 file totali (foto + documenti). Ogni file ha descrizione separata.</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="newTicketCameraShots,newTicketAttachments" class="mt-2 text-xs font-medium text-amber-700">Caricamento in corso: l'anteprima locale e gia disponibile, sto sincronizzando i file al server.</div>
</label>
<div class="md:col-span-2" x-show="previews.length > 0">
<div class="mb-2 text-xs font-semibold text-emerald-700">Anteprima immediata sul telefono</div>
<div class="grid gap-3 sm:grid-cols-2">
<template x-for="item in previews" :key="item.key">
<div class="rounded-xl border border-emerald-100 bg-emerald-50 p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-white p-2">
<template x-if="item.isImage && item.previewUrl">
<img :src="item.previewUrl" :alt="item.name" class="h-full w-full rounded object-cover" />
</template>
<template x-if="!item.isImage || !item.previewUrl">
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>
<div class="text-sm font-semibold" x-text="item.ext"></div>
<div class="mt-1 text-[11px]" x-text="item.name"></div>
</div>
</div>
</template>
</div>
<div class="mt-2 font-medium" x-text="item.name"></div>
</div>
</template>
</div>
</div>
@if(count($this->selectedUploads) > 0)
<div class="md:col-span-2">
<div class="mb-2 text-xs font-semibold text-gray-700">Anteprima allegati selezionati (stile catalogo)</div>
<div class="mb-2 text-xs font-semibold text-gray-700">Anteprima allegati sincronizzati</div>
<div class="grid gap-3 sm:grid-cols-2">
@foreach($this->selectedUploads as $upload)
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
@ -212,7 +249,10 @@
@endif
</div>
<div class="mt-3">
<x-filament::button wire:click="creaTicketRapido">Crea ticket</x-filament::button>
<x-filament::button wire:click="creaTicketRapido" wire:loading.attr="disabled" wire:target="creaTicketRapido,newTicketCameraShots,newTicketAttachments">
<span wire:loading.remove wire:target="creaTicketRapido">Crea ticket</span>
<span wire:loading wire:target="creaTicketRapido">Invio ticket...</span>
</x-filament::button>
</div>
</div>
</div>
@ -230,4 +270,28 @@
Suggerimento: aggiungi questa pagina ai preferiti del browser sul telefono per usarla come webapp rapida.
</div>
</div>
<script>
function ticketMobilePreview() {
return {
previews: [],
ingest(event, source) {
const files = Array.from(event.target.files || []);
files.forEach((file, index) => {
const isImage = (file.type || '').startsWith('image/');
const previewUrl = isImage ? URL.createObjectURL(file) : null;
const extension = (file.name.split('.').pop() || 'FILE').toUpperCase();
this.previews.unshift({
key: `${source}-${file.name}-${file.size}-${index}`,
name: file.name,
isImage,
previewUrl,
ext: extension,
});
});
this.previews = this.previews.slice(0, 10);
},
};
}
</script>
</x-filament-panels::page>

View File

@ -0,0 +1,35 @@
<div wire:poll.8s="refreshLiveIncomingCall" class="hidden xl:flex xl:min-w-[28rem] xl:flex-1 xl:justify-center">
@if($liveIncomingCall)
<div class="mx-3 flex w-full max-w-2xl items-center justify-between gap-3 rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 shadow-sm">
<div class="min-w-0">
<div class="text-[11px] font-semibold uppercase tracking-wide text-emerald-800">Chiamata in arrivo</div>
<div class="truncate text-sm font-semibold text-emerald-950">
{{ $liveIncomingCall['rubrica_nome'] ?: 'Numero non riconosciuto' }}
</div>
<div class="truncate text-xs text-emerald-800">
{{ $liveIncomingCall['phone'] ?? '-' }}
@if(!empty($liveIncomingCall['target_extension']))
· int. {{ $liveIncomingCall['target_extension'] }}
@endif
@if(!empty($liveIncomingCall['received_at']))
· {{ $liveIncomingCall['received_at'] }}
@endif
</div>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<button type="button" wire:click="createPostIt" class="inline-flex items-center rounded-md bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600">
Post-it
</button>
<button type="button" wire:click="openTicketMobile" class="inline-flex items-center rounded-md bg-slate-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-800">
Prendi nota
</button>
@if(!empty($liveIncomingCall['rubrica_url']))
<a href="{{ $liveIncomingCall['rubrica_url'] }}" class="inline-flex items-center rounded-md border border-emerald-300 bg-white px-3 py-1.5 text-xs font-medium text-emerald-800 hover:bg-emerald-100">
Rubrica
</a>
@endif
</div>
</div>
@endif
</div>