feat: consolida post-it pbx e continuita operativa

This commit is contained in:
michele 2026-04-04 13:13:05 +00:00
parent 72661559df
commit 236aef53cd
17 changed files with 1139 additions and 7 deletions

View File

@ -11,6 +11,10 @@ ## [Unreleased]
- Added live-call CRM improvements in Ticket Mobile and Post-it Gestione: cleaner call context, operator note before conversion, clearer SMDR/CSTA labels, and line/group filtering. - Added live-call CRM improvements in Ticket Mobile and Post-it Gestione: cleaner call context, operator note before conversion, clearer SMDR/CSTA labels, and line/group filtering.
- Extended PBX day0 preset coverage to include main/admin lines `0001` and `0003`, with aligned studio watchlist for broader staging monitoring. - Extended PBX day0 preset coverage to include main/admin lines `0001` and `0003`, with aligned studio watchlist for broader staging monitoring.
- Documented current CTI staging status and Windows restart procedure so the deployment trail remains in-repo even if the active chat is interrupted. - Documented current CTI staging status and Windows restart procedure so the deployment trail remains in-repo even if the active chat is interrupted.
- Added grouped Post-it call boxes, direct Post-it opening from grouped history, and explicit Post-it assignment to operatore, fornitore, or collaboratore.
- Extended Scheda Amministratore PBX/TAPI with observed tokens, multi-line collaborator mapping, and inline PBX flag editing.
- Included safe rubrica-link QA repair in the Git-first staging sync flow from Supporto > Modifiche.
- Documented the modular coworking direction of NetGescon and the first server-distribution path for isolated debug nodes.
## [0.1.0] - 2026-01-17 ## [0.1.0] - 2026-01-17

View File

@ -290,6 +290,15 @@ public function handle(): int
return self::FAILURE; return self::FAILURE;
} }
$this->writeProgress(80, 'Ricucitura legami rubrica CRM', 'running');
$rubricaQaExit = 0;
try {
$rubricaQaExit = Artisan::call('gescon:qa-rubrica-legami', ['--apply-safe' => true]);
} catch (\Throwable $e) {
$rubricaQaExit = 1;
$this->warn('Ricucitura legami rubrica non completata: ' . $e->getMessage());
}
$this->writeProgress(82, 'Pulizia cache applicativa', 'running'); $this->writeProgress(82, 'Pulizia cache applicativa', 'running');
$this->callSilently('optimize:clear'); $this->callSilently('optimize:clear');
@ -312,6 +321,7 @@ public function handle(): int
'repo_path' => $repoPath, 'repo_path' => $repoPath,
'deploy_path' => $deployPath, 'deploy_path' => $deployPath,
'mode' => $deploySync ? 'external-repo-rsync' : 'in-place-git', 'mode' => $deploySync ? 'external-repo-rsync' : 'in-place-git',
'rubrica_legami_qa' => $rubricaQaExit === 0 ? 'completed' : 'warning',
]); ]);
$this->info('Sync Git completata con successo.'); $this->info('Sync Git completata con successo.');

View File

@ -64,6 +64,21 @@ class SchedaAmministratore extends Page implements HasForms
/** @var array<int,string> */ /** @var array<int,string> */
public array $collaboratorePbxExtension = []; public array $collaboratorePbxExtension = [];
/** @var array<int,string> */
public array $collaboratorePbxExtensions = [];
/** @var array<int,string> */
public array $collaboratorePbxGroups = [];
/** @var array<int,string> */
public array $collaboratorePbxLines = [];
/** @var array<int,bool> */
public array $collaboratorePbxPopupEnabled = [];
/** @var array<int,bool> */
public array $collaboratorePbxClickToCallEnabled = [];
public Amministratore $amministratore; public Amministratore $amministratore;
public static function canAccess(): bool public static function canAccess(): bool
@ -1284,6 +1299,8 @@ public function getAccessoAmministratoreRowsProperty(): array
*/ */
public function getCollaboratoriCentralinoRowsProperty(): array public function getCollaboratoriCentralinoRowsProperty(): array
{ {
$mappings = (array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx.user_mappings', []);
$query = User::query() $query = User::query()
->with('roles') ->with('roles')
->whereHas('roles', function ($q): void { ->whereHas('roles', function ($q): void {
@ -1298,7 +1315,21 @@ public function getCollaboratoriCentralinoRowsProperty(): array
return $query return $query
->get() ->get()
->map(function (User $user): array { ->map(function (User $user): array {
$mapping = (array) ($mappings[(string) $user->id] ?? []);
$extensions = array_values(array_filter((array) ($mapping['extensions'] ?? []), fn($value): bool => is_string($value) && trim($value) !== ''));
$groups = array_values(array_filter((array) ($mapping['groups'] ?? []), fn($value): bool => is_string($value) && trim($value) !== ''));
$lines = array_values(array_filter((array) ($mapping['lines'] ?? []), fn($value): bool => is_string($value) && trim($value) !== ''));
if ($extensions === [] && filled($user->pbx_extension)) {
$extensions = [(string) $user->pbx_extension];
}
$this->collaboratorePbxExtension[(int) $user->id] = (string) ($user->pbx_extension ?? ''); $this->collaboratorePbxExtension[(int) $user->id] = (string) ($user->pbx_extension ?? '');
$this->collaboratorePbxExtensions[(int) $user->id] = implode(', ', $extensions);
$this->collaboratorePbxGroups[(int) $user->id] = implode(', ', $groups);
$this->collaboratorePbxLines[(int) $user->id] = implode(', ', $lines);
$this->collaboratorePbxPopupEnabled[(int) $user->id] = (bool) ($user->pbx_popup_enabled ?? false);
$this->collaboratorePbxClickToCallEnabled[(int) $user->id] = (bool) ($user->pbx_click_to_call_enabled ?? false);
return [ return [
'user_id' => (int) $user->id, 'user_id' => (int) $user->id,
@ -1306,6 +1337,9 @@ public function getCollaboratoriCentralinoRowsProperty(): array
'email' => (string) $user->email, 'email' => (string) $user->email,
'ruoli' => $user->roles->pluck('name')->values()->all(), 'ruoli' => $user->roles->pluck('name')->values()->all(),
'pbx_extension' => (string) ($user->pbx_extension ?? ''), 'pbx_extension' => (string) ($user->pbx_extension ?? ''),
'pbx_extensions' => $this->collaboratorePbxExtensions[(int) $user->id],
'pbx_groups' => $this->collaboratorePbxGroups[(int) $user->id],
'pbx_lines' => $this->collaboratorePbxLines[(int) $user->id],
'pbx_popup_enabled' => (bool) ($user->pbx_popup_enabled ?? false), 'pbx_popup_enabled' => (bool) ($user->pbx_popup_enabled ?? false),
'pbx_click_to_call_enabled' => (bool) ($user->pbx_click_to_call_enabled ?? false), 'pbx_click_to_call_enabled' => (bool) ($user->pbx_click_to_call_enabled ?? false),
]; ];
@ -1313,6 +1347,56 @@ public function getCollaboratoriCentralinoRowsProperty(): array
->all(); ->all();
} }
/**
* @return array{extensions:array<int,string>,groups:array<int,string>,lines:array<int,string>}
*/
public function getPbxObservedTokensProperty(): array
{
if (! Schema::hasTable('communication_messages')) {
return [
'extensions' => [],
'groups' => [],
'lines' => [],
];
}
$rows = $this->pbxRawMessagesQuery()
->latest('id')
->limit(300)
->get(['target_extension', 'message_text', 'metadata']);
$extensions = [];
$groups = [];
$lines = [];
foreach ($rows as $message) {
$metadata = (array) ($message->metadata ?? []);
$this->collectPbxTokens($extensions, [
(string) ($message->target_extension ?? ''),
(string) data_get($metadata, 'called_extension', ''),
(string) data_get($metadata, 'source_extension', ''),
], '/\b(?:EXT\d+|\d{2,5})\b/i');
$this->collectPbxTokens($groups, [
(string) data_get($metadata, 'message_text', ''),
(string) ($message->message_text ?? ''),
], '/\bGRP\d+\b/i');
$this->collectPbxTokens($lines, [
(string) data_get($metadata, 'smdr.co', ''),
(string) data_get($metadata, 'line', ''),
(string) data_get($metadata, 'did', ''),
], '/\b(?:\d{3,6}|EXT\d+|GRP\d+)\b/i');
}
return [
'extensions' => array_slice(array_values(array_unique($extensions)), 0, 24),
'groups' => array_slice(array_values(array_unique($groups)), 0, 24),
'lines' => array_slice(array_values(array_unique($lines)), 0, 24),
];
}
/** /**
* @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} * @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}
*/ */
@ -1577,16 +1661,78 @@ public function salvaInternoCollaboratore(int $userId): void
return; return;
} }
$extension = trim((string) ($this->collaboratorePbxExtension[$userId] ?? '')); $extensions = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxExtensions[$userId] ?? $this->collaboratorePbxExtension[$userId] ?? ''));
$user->pbx_extension = $extension !== '' ? $extension : null; $groups = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxGroups[$userId] ?? ''));
$lines = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxLines[$userId] ?? ''));
$user->pbx_extension = $extensions[0] ?? null;
$user->pbx_popup_enabled = (bool) ($this->collaboratorePbxPopupEnabled[$userId] ?? false);
$user->pbx_click_to_call_enabled = (bool) ($this->collaboratorePbxClickToCallEnabled[$userId] ?? false);
$user->save(); $user->save();
$impostazioni = $this->amministratore->impostazioni ?? [];
$pbx = (array) Arr::get($impostazioni, 'pbx', []);
$mappings = (array) ($pbx['user_mappings'] ?? []);
$mappings[(string) $userId] = [
'extensions' => $extensions,
'groups' => $groups,
'lines' => $lines,
];
$pbx['user_mappings'] = $mappings;
$impostazioni['pbx'] = $pbx;
$this->amministratore->impostazioni = $impostazioni;
$this->amministratore->save();
Notification::make() Notification::make()
->title('Interno collaboratore aggiornato') ->title('Mappatura PBX collaboratore aggiornata')
->success() ->success()
->send(); ->send();
} }
/**
* @param array<int,string> $target
* @param array<int,string> $values
*/
private function collectPbxTokens(array &$target, array $values, string $pattern): void
{
foreach ($values as $value) {
if ($value === '') {
continue;
}
if (preg_match_all($pattern, strtoupper($value), $matches) > 0) {
foreach ((array) ($matches[0] ?? []) as $match) {
$token = trim((string) $match);
if ($token !== '') {
$target[] = $token;
}
}
continue;
}
$token = trim(strtoupper($value));
if ($token !== '') {
$target[] = $token;
}
}
}
/**
* @return array<int,string>
*/
private function normalizePbxMappingInput(string $value): array
{
$tokens = preg_split('/[\s,;]+/', strtoupper(trim($value)), -1, PREG_SPLIT_NO_EMPTY) ?: [];
return array_values(array_unique(array_filter(array_map(function ($token): string {
$clean = preg_replace('/[^A-Z0-9_-]/', '', (string) $token) ?? '';
return trim($clean);
}, $tokens), fn(string $token): bool => $token !== '')));
}
public function abilitaAccessoFornitore(int $dipendenteId): void public function abilitaAccessoFornitore(int $dipendenteId): void
{ {
$dipendente = FornitoreDipendente::query() $dipendente = FornitoreDipendente::query()

View File

@ -2,11 +2,13 @@
namespace App\Filament\Pages\Strumenti; namespace App\Filament\Pages\Strumenti;
use App\Models\ChiamataPostIt; use App\Models\ChiamataPostIt;
use App\Models\Fornitore;
use App\Models\RubricaUniversale; use App\Models\RubricaUniversale;
use App\Models\Stabile; use App\Models\Stabile;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Support\PhoneNumber; use App\Support\PhoneNumber;
use App\Support\StabileContext;
use BackedEnum; use BackedEnum;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
@ -42,6 +44,11 @@ class PostIt extends Page
public string $priorita = 'Media'; public string $priorita = 'Media';
public ?int $rubricaId = null; public ?int $rubricaId = null;
public ?int $stabileId = null; public ?int $stabileId = null;
public ?int $focusPostItId = null;
public ?int $selectedPostItId = null;
public string $assegnazioneTipo = 'operatore';
public ?int $assegnazioneUserId = null;
public ?int $assegnazioneFornitoreId = null;
public ?int $selectedSuggerimentoId = null; public ?int $selectedSuggerimentoId = null;
@ -53,6 +60,10 @@ public function mount(): void
$this->priorita = 'Media'; $this->priorita = 'Media';
$this->direzione = 'in_arrivo'; $this->direzione = 'in_arrivo';
$this->suggerimentiRubrica = collect(); $this->suggerimentiRubrica = collect();
$focus = (int) request()->query('focus_post_it', 0);
$this->focusPostItId = $focus > 0 ? $focus : null;
$this->selectedPostItId = $this->focusPostItId;
} }
public function getGestionePostItUrl(): string public function getGestionePostItUrl(): string
@ -65,11 +76,95 @@ public function getGestioneTicketUrl(): string
return \App\Filament\Pages\Supporto\TicketGestione::getUrl(panel: 'admin-filament'); return \App\Filament\Pages\Supporto\TicketGestione::getUrl(panel: 'admin-filament');
} }
public function selectPostIt(int $postItId): void
{
$this->selectedPostItId = $postItId;
$this->loadSelectedPostItAssignment();
}
public function assegnaPostIt(): void
{
if (! $this->isPostItTableReady()) {
return;
}
$postIt = $this->selectedPostIt;
if (! $postIt) {
Notification::make()->title('Seleziona prima un Post-it')->warning()->send();
return;
}
$this->validate([
'assegnazioneTipo' => ['required', 'in:operatore,collaboratore,fornitore'],
'assegnazioneUserId' => ['nullable', 'integer', 'exists:users,id'],
'assegnazioneFornitoreId' => ['nullable', 'integer', 'exists:fornitori,id'],
]);
$payload = [
'assegnazione_tipo' => $this->assegnazioneTipo,
'assegnato_a_user_id' => null,
'assegnato_a_fornitore_id' => null,
];
if (in_array($this->assegnazioneTipo, ['operatore', 'collaboratore'], true)) {
if (! $this->assegnazioneUserId) {
Notification::make()->title('Seleziona un utente')->warning()->send();
return;
}
$payload['assegnato_a_user_id'] = $this->assegnazioneUserId;
}
if ($this->assegnazioneTipo === 'fornitore') {
if (! $this->assegnazioneFornitoreId) {
Notification::make()->title('Seleziona un fornitore')->warning()->send();
return;
}
$payload['assegnato_a_fornitore_id'] = $this->assegnazioneFornitoreId;
}
$postIt->update($payload);
if ($postIt->ticket) {
$ticketPayload = [];
if ($this->assegnazioneTipo === 'fornitore') {
$ticketPayload['assegnato_a_fornitore_id'] = $this->assegnazioneFornitoreId;
$ticketPayload['assegnato_a_user_id'] = null;
} else {
$ticketPayload['assegnato_a_user_id'] = $this->assegnazioneUserId;
}
if ($ticketPayload !== []) {
$postIt->ticket->update($ticketPayload);
}
if (method_exists($postIt->ticket, 'messages') && Schema::hasTable('ticket_messages')) {
$postIt->ticket->messages()->create([
'user_id' => Auth::id(),
'messaggio' => 'Assegnazione da Post-it: ' . $this->getSelectedAssignmentLabel(),
]);
}
}
Notification::make()->title('Assegnazione Post-it aggiornata')->success()->send();
$this->selectedPostItId = (int) $postIt->id;
}
public function isPostItTableReady(): bool public function isPostItTableReady(): bool
{ {
return Schema::hasTable('chiamate_post_it'); return Schema::hasTable('chiamate_post_it');
} }
public function isPostItAssignmentReady(): bool
{
return $this->isPostItTableReady()
&& Schema::hasColumn('chiamate_post_it', 'assegnazione_tipo')
&& Schema::hasColumn('chiamate_post_it', 'assegnato_a_user_id')
&& Schema::hasColumn('chiamate_post_it', 'assegnato_a_fornitore_id');
}
public function updatedRicercaChiamante(?string $value): void public function updatedRicercaChiamante(?string $value): void
{ {
$query = trim((string) $value); $query = trim((string) $value);
@ -247,6 +342,8 @@ public function convertiInTicket(int $postItId): void
$ticket = Ticket::query()->create([ $ticket = Ticket::query()->create([
'stabile_id' => $postIt->stabile_id, 'stabile_id' => $postIt->stabile_id,
'aperto_da_user_id' => Auth::id(), 'aperto_da_user_id' => Auth::id(),
'assegnato_a_user_id' => $postIt->assegnazione_tipo !== 'fornitore' ? $postIt->assegnato_a_user_id : null,
'assegnato_a_fornitore_id' => $postIt->assegnazione_tipo === 'fornitore' ? $postIt->assegnato_a_fornitore_id : null,
'titolo' => $postIt->oggetto ?: ('Chiamata da ' . ($postIt->nome_chiamante ?: 'Contatto sconosciuto')), 'titolo' => $postIt->oggetto ?: ('Chiamata da ' . ($postIt->nome_chiamante ?: 'Contatto sconosciuto')),
'descrizione' => trim(($postIt->nota ?? '') . "\n\nRiferimento chiamata: #{$postIt->id}"), 'descrizione' => trim(($postIt->nota ?? '') . "\n\nRiferimento chiamata: #{$postIt->id}"),
'data_apertura' => now(), 'data_apertura' => now(),
@ -292,16 +389,81 @@ public function getRecentiProperty()
} }
try { try {
return ChiamataPostIt::query() $items = ChiamataPostIt::query()
->with(['rubrica', 'stabile', 'ticket', 'creatoDa']) ->with(['rubrica', 'stabile', 'ticket', 'creatoDa', 'assegnatoAUser:id,name', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome'])
->orderByDesc('chiamata_il') ->orderByDesc('chiamata_il')
->limit(25) ->limit(25)
->get(); ->get();
if (! $this->selectedPostItId && $items->isNotEmpty()) {
$this->selectedPostItId = (int) ($this->focusPostItId ?: $items->first()->id);
$this->loadSelectedPostItAssignment();
}
return $items;
} catch (QueryException) { } catch (QueryException) {
return collect(); return collect();
} }
} }
public function getSelectedPostItProperty(): ?ChiamataPostIt
{
if (! $this->isPostItTableReady() || ! $this->selectedPostItId) {
return null;
}
return ChiamataPostIt::query()
->with(['stabile', 'ticket', 'assegnatoAUser:id,name', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome'])
->find($this->selectedPostItId);
}
public function getOperatoriOptionsProperty(): array
{
return User::query()
->role(['super-admin', 'admin', 'amministratore'])
->orderBy('name')
->limit(100)
->pluck('name', 'id')
->map(fn($name): string => (string) $name)
->all();
}
public function getCollaboratoriOptionsProperty(): array
{
$adminId = $this->resolveCurrentAmministratoreId();
return User::query()
->whereHas('roles', function ($q): void {
$q->where('name', 'collaboratore');
})
->when($adminId > 0, function ($query) use ($adminId): void {
$query->whereHas('stabiliAssegnati', function ($inner) use ($adminId): void {
$inner->where('amministratore_id', $adminId);
});
})
->orderBy('name')
->limit(150)
->pluck('name', 'id')
->map(fn($name): string => (string) $name)
->all();
}
public function getFornitoriOptionsProperty(): array
{
$adminId = $this->resolveCurrentAmministratoreId();
return Fornitore::query()
->when($adminId > 0, fn($query) => $query->where('amministratore_id', $adminId))
->orderByRaw("COALESCE(ragione_sociale, CONCAT(COALESCE(nome, ''), ' ', COALESCE(cognome, '')))")
->limit(150)
->get(['id', 'ragione_sociale', 'nome', 'cognome'])
->mapWithKeys(function (Fornitore $fornitore): array {
$label = trim((string) ($fornitore->ragione_sociale ?: (($fornitore->nome ?? '') . ' ' . ($fornitore->cognome ?? ''))));
return [(int) $fornitore->id => ($label !== '' ? $label : ('Fornitore #' . $fornitore->id))];
})
->all();
}
private function creaTicketDirettoDaForm(): void private function creaTicketDirettoDaForm(): void
{ {
$this->validate([ $this->validate([
@ -369,6 +531,43 @@ private function createPostItRecord(): ChiamataPostIt
]); ]);
} }
private function loadSelectedPostItAssignment(): void
{
$postIt = $this->selectedPostIt;
if (! $postIt) {
return;
}
$this->assegnazioneTipo = (string) ($postIt->assegnazione_tipo ?: 'operatore');
$this->assegnazioneUserId = (int) ($postIt->assegnato_a_user_id ?? 0) ?: null;
$this->assegnazioneFornitoreId = (int) ($postIt->assegnato_a_fornitore_id ?? 0) ?: null;
}
private function getSelectedAssignmentLabel(): string
{
return match ($this->assegnazioneTipo) {
'fornitore' => 'fornitore #' . (int) ($this->assegnazioneFornitoreId ?? 0),
'collaboratore' => 'collaboratore #' . (int) ($this->assegnazioneUserId ?? 0),
default => 'operatore #' . (int) ($this->assegnazioneUserId ?? 0),
};
}
private function resolveCurrentAmministratoreId(): int
{
$user = Auth::user();
if (! $user instanceof User) {
return 0;
}
if ($user->amministratore) {
return (int) $user->amministratore->id;
}
$stabile = StabileContext::getActiveStabile($user);
return (int) ($stabile?->amministratore_id ?? 0);
}
private function resetFormState(): void private function resetFormState(): void
{ {
$this->telefono = null; $this->telefono = null;

View File

@ -60,6 +60,9 @@ class PostItGestione extends Page
/** @var array<string, array{id:int|null,name:string|null}> */ /** @var array<string, array{id:int|null,name:string|null}> */
private array $pbxExtensionCache = []; private array $pbxExtensionCache = [];
/** @var array<int, CommunicationMessage|null> */
private array $postItMessageCache = [];
public function mount(): void public function mount(): void
{ {
$focus = (int) request()->query('focus_post_it', 0); $focus = (int) request()->query('focus_post_it', 0);
@ -75,6 +78,11 @@ public function getPostItInserimentoUrl(): string
return PostIt::getUrl(panel: 'admin-filament'); return PostIt::getUrl(panel: 'admin-filament');
} }
public function getPostItPageUrl(int $postItId): string
{
return PostIt::getUrl(panel: 'admin-filament') . '?focus_post_it=' . $postItId;
}
public function convertiInTicket(int $postItId): void public function convertiInTicket(int $postItId): void
{ {
if (! $this->isPostItTableReady()) { if (! $this->isPostItTableReady()) {
@ -216,7 +224,7 @@ public function getRecentiProperty()
try { try {
$query = ChiamataPostIt::query() $query = ChiamataPostIt::query()
->with(['rubrica', 'stabile', 'ticket', 'creatoDa']) ->with(['rubrica', 'stabile', 'ticket', 'creatoDa', 'assegnatoAUser:id,name', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome'])
->orderByDesc('chiamata_il') ->orderByDesc('chiamata_il')
->orderByDesc('id'); ->orderByDesc('id');
@ -230,6 +238,41 @@ public function getRecentiProperty()
} }
} }
public function getRecentiRaggruppatiProperty()
{
$groups = [];
foreach ($this->recenti as $postIt) {
if ($this->isInternalPostIt($postIt)) {
continue;
}
$groupKey = $this->buildGroupedPostItKey($postIt);
$lastIndex = count($groups) - 1;
if ($lastIndex >= 0 && $groups[$lastIndex]['key'] === $groupKey) {
$groups[$lastIndex]['items'][] = $postIt;
$groups[$lastIndex]['count']++;
$groups[$lastIndex]['last_at'] = optional($postIt->chiamata_il)->format('d/m/Y H:i') ?: '-';
continue;
}
$groups[] = [
'key' => $groupKey,
'items' => [$postIt],
'count' => 1,
'latest' => $postIt,
'caller_label' => $postIt->nome_chiamante ?: 'Chiamante non identificato',
'phone' => (string) ($postIt->telefono ?? ''),
'route_label' => $this->resolvePostItRouteLabel($postIt),
'first_at' => optional($postIt->chiamata_il)->format('d/m/Y H:i') ?: '-',
'last_at' => optional($postIt->chiamata_il)->format('d/m/Y H:i') ?: '-',
];
}
return collect($groups);
}
public function getChiamateTecnicheProperty() public function getChiamateTecnicheProperty()
{ {
if (! Schema::hasTable('communication_messages')) { if (! Schema::hasTable('communication_messages')) {
@ -518,6 +561,59 @@ private function buildTicketDescriptionFromPostIt(int $postItId, ChiamataPostIt
return implode("\n\n", $parts); return implode("\n\n", $parts);
} }
private function buildGroupedPostItKey(ChiamataPostIt $postIt): string
{
$phone = preg_replace('/\D+/', '', (string) ($postIt->telefono ?? '')) ?: '';
$caller = mb_strtolower(trim((string) ($postIt->nome_chiamante ?? '')));
$route = mb_strtolower($this->resolvePostItRouteLabel($postIt));
return implode('|', [$phone, $caller, $route, (string) $postIt->direzione]);
}
private function resolvePostItRouteLabel(ChiamataPostIt $postIt): string
{
$message = $this->resolveMessageFromPostIt($postIt);
if ($message instanceof CommunicationMessage) {
$line = trim((string) ($message->target_extension ?: data_get($message->metadata, 'smdr.co', '')));
if ($line !== '') {
return $line;
}
}
if (preg_match('/linea\s+([^\n]+)/i', (string) ($postIt->oggetto ?? ''), $matches) === 1) {
return trim((string) $matches[1]);
}
return '-';
}
private function isInternalPostIt(ChiamataPostIt $postIt): bool
{
$message = $this->resolveMessageFromPostIt($postIt);
if ($message instanceof CommunicationMessage) {
return $this->isInternalSmdrMessage($message);
}
return false;
}
private function resolveMessageFromPostIt(ChiamataPostIt $postIt): ?CommunicationMessage
{
$originId = (int) ($postIt->origine_id ?? 0);
if ($originId <= 0) {
return null;
}
if (array_key_exists($originId, $this->postItMessageCache)) {
return $this->postItMessageCache[$originId];
}
$message = CommunicationMessage::query()->find($originId);
$this->postItMessageCache[$originId] = $message;
return $message;
}
public function richiediClickToCallDaMessaggio(int $messageId): void public function richiediClickToCallDaMessaggio(int $messageId): void
{ {
$user = Auth::user(); $user = Auth::user();

View File

@ -1096,6 +1096,18 @@ private function loadManualSections(): void
'description' => 'Regole pratiche per aggiornare documentazione, note commit e materiali utili al riaggancio delle prossime sessioni.', 'description' => 'Regole pratiche per aggiornare documentazione, note commit e materiali utili al riaggancio delle prossime sessioni.',
'path' => 'docs/support/CONTINUITA-SVILUPPO-AGENT.md', 'path' => 'docs/support/CONTINUITA-SVILUPPO-AGENT.md',
], ],
[
'key' => 'coworking-moduli',
'title' => 'Architettura ruoli, moduli e coworking',
'description' => 'Direzione architetturale di NetGescon come spazio unico abilitato per ruoli multipli e moduli attivabili sulla stessa base dominio.',
'path' => 'docs/support/ARCHITETTURA-RUOLI-MODULI-COWORKING.md',
],
[
'key' => 'server-debug',
'title' => 'Distribuzione server e debug separato',
'description' => 'Primo piano pratico per portare NetGescon su nodi separati dalla rete locale e stabilizzare deploy, collaudo e diagnostica.',
'path' => 'docs/support/DISTRIBUZIONE-SERVER-DEBUG.md',
],
]; ];
foreach ($documents as $document) { foreach ($documents as $document) {

View File

@ -15,6 +15,9 @@ class ChiamataPostIt extends Model
'rubrica_id', 'rubrica_id',
'ticket_id', 'ticket_id',
'creato_da_user_id', 'creato_da_user_id',
'assegnato_a_user_id',
'assegnato_a_fornitore_id',
'assegnazione_tipo',
'telefono', 'telefono',
'nome_chiamante', 'nome_chiamante',
'direzione', 'direzione',
@ -57,4 +60,14 @@ public function creatoDa()
{ {
return $this->belongsTo(User::class, 'creato_da_user_id'); return $this->belongsTo(User::class, 'creato_da_user_id');
} }
public function assegnatoAUser()
{
return $this->belongsTo(User::class, 'assegnato_a_user_id');
}
public function assegnatoAFornitore()
{
return $this->belongsTo(Fornitore::class, 'assegnato_a_fornitore_id');
}
} }

View File

@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('chiamate_post_it', function (Blueprint $table): void {
if (! Schema::hasColumn('chiamate_post_it', 'assegnato_a_user_id')) {
$table->foreignId('assegnato_a_user_id')->nullable()->after('creato_da_user_id')->constrained('users')->nullOnDelete();
}
if (! Schema::hasColumn('chiamate_post_it', 'assegnato_a_fornitore_id')) {
$table->foreignId('assegnato_a_fornitore_id')->nullable()->after('assegnato_a_user_id')->constrained('fornitori')->nullOnDelete();
}
if (! Schema::hasColumn('chiamate_post_it', 'assegnazione_tipo')) {
$table->string('assegnazione_tipo', 32)->nullable()->after('assegnato_a_fornitore_id');
$table->index(['assegnazione_tipo', 'assegnato_a_user_id'], 'chiamate_post_it_assign_user_idx');
$table->index(['assegnazione_tipo', 'assegnato_a_fornitore_id'], 'chiamate_post_it_assign_supplier_idx');
}
});
}
public function down(): void
{
Schema::table('chiamate_post_it', function (Blueprint $table): void {
if (Schema::hasColumn('chiamate_post_it', 'assegnazione_tipo')) {
try {
$table->dropIndex('chiamate_post_it_assign_user_idx');
} catch (Throwable) {
}
try {
$table->dropIndex('chiamate_post_it_assign_supplier_idx');
} catch (Throwable) {
}
$table->dropColumn('assegnazione_tipo');
}
if (Schema::hasColumn('chiamate_post_it', 'assegnato_a_fornitore_id')) {
try {
$table->dropConstrainedForeignId('assegnato_a_fornitore_id');
} catch (Throwable) {
$table->dropColumn('assegnato_a_fornitore_id');
}
}
if (Schema::hasColumn('chiamate_post_it', 'assegnato_a_user_id')) {
try {
$table->dropConstrainedForeignId('assegnato_a_user_id');
} catch (Throwable) {
$table->dropColumn('assegnato_a_user_id');
}
}
});
}
};

View File

@ -31,10 +31,23 @@ ## UI e comando predisposto
1. fa fetch dal repository Gitea 1. fa fetch dal repository Gitea
2. allinea il nodo al branch richiesto 2. allinea il nodo al branch richiesto
3. rigenera il changelog Git automatico per la pagina supporto 3. rigenera il changelog Git automatico per la pagina supporto
4. salva esito e diagnostica in `storage/app/support/generated/git-sync-last.json` 4. lancia anche la ricucitura sicura dei legami CRM rubrica se il comando QA e disponibile
5. salva esito e diagnostica in `storage/app/support/generated/git-sync-last.json`
Quindi la UI non e un percorso separato: e il front-end controllato del flusso Git ufficiale. Quindi la UI non e un percorso separato: e il front-end controllato del flusso Git ufficiale.
## Ricucitura CRM inclusa nella UI
La procedura `Supporto > Modifiche` non richiede piu che l operatore esegua a mano il comando QA rubrica da terminale.
Durante il sync Git, il backend prova anche a eseguire:
```bash
php artisan gescon:qa-rubrica-legami --apply-safe
```
In questo modo la riallocazione dei legami sicuri tra rubrica, stabile e unita rientra nel normale aggiornamento UI di staging.
## Cosa stiamo aggiornando in questo momento ## Cosa stiamo aggiornando in questo momento
### 1. Supporto > Modifiche come pannello operativo reale ### 1. Supporto > Modifiche come pannello operativo reale

View File

@ -0,0 +1,145 @@
# Architettura ruoli, moduli e coworking
## Obiettivo
NetGescon non va piu letto come un applicativo monoruolo dedicato solo all amministrazione condominiale.
La direzione corretta e questa:
1. una base dominio unica e coerente
2. moduli attivabili sopra la stessa base dati
3. ruoli e abilitazioni che decidono cosa una persona puo fare
4. stessa persona abilitabile su piu funzioni operative
In pratica: uno spazio unico di coworking operativo, non una somma di mini-app scollegate.
## Base dominio da tenere stabile
La base comune resta:
- anagrafica unica dei nominativi
- stabili, unita immobiliari, relazioni e ruoli
- movimenti, banca, ripartizione spese, piano dei conti
- contabilita in partita doppia
- ticket, allegati, documenti e storico operativo
Questa base non va duplicata per mestiere o per modulo.
## Cosa cambia davvero tra un uso e l altro
Quello che cambia non e il dato di partenza, ma:
- il ruolo
- la procedura disponibile
- il modulo abilitato
- il livello di permesso sul tenant, sullo stabile o sul progetto
Esempi reali della stessa persona:
- supporto informatico
- sviluppo software
- revisione contabile
- collaboratore dell amministratore
- gestione PBX / CTI
- supervisione di ticket e fornitori
## Regola architetturale
NetGescon deve diventare:
- domain-first
- permission-first
- module-first
Non deve diventare:
- menu-first
- pagina-first
- ruolo singolo hard-coded
## Moduli principali gia visibili o in formazione
- Condominio e anagrafica
- Contabilita e banca
- Ripartizioni e tabelle millesimali
- Ticket e Post-it operativi
- CTI / PBX / click-to-call
- Import legacy Gescon
- Revisione contabile
- Storage tenant-aware e archivi
- Distribuzione server e manutenzione nodo
## Effetto pratico sulla UI
La UI deve progressivamente convergere verso:
1. stessa base record
2. azioni visibili solo se abilitate
3. moduli attivabili senza biforcare il prodotto
4. procedure specialistiche innestate sul contesto corretto
Esempio:
- una chiamata entra nel CRM
- produce un Post-it
- il Post-it puo essere assegnato a operatore, collaboratore o fornitore
- se serve diventa ticket
- il ticket impatta documenti, fornitore, stabile, contabilita o revisione
Sempre sullo stesso grafo dati.
## Modello di abilitazione consigliato
Tre livelli distinti:
### 1. Ruolo base
Definisce il perimetro generale:
- amministratore
- collaboratore
- fornitore
- revisore
- tecnico
- sviluppatore / ops
### 2. Capacita operative
Definiscono le azioni concrete:
- vedere contabilità
- modificare ripartizioni
- usare CTI
- lanciare sync staging
- aprire ticket
- assegnare Post-it
- gestire fornitori
### 3. Ambito
Definisce dove valgono:
- tenant / amministratore
- stabile
- progetto
- modulo
## Conseguenza tecnica
Ogni nuova funzione dovrebbe chiedersi prima:
1. su quale base dominio si appoggia
2. quale modulo la ospita
3. quale permission la abilita
4. su quale ambito agisce
Se una funzione non risponde a queste quattro domande, rischia di diventare una pagina isolata e fragile.
## Decisione attuale
La direzione da seguire nelle prossime sessioni e:
1. consolidare i moduli gia emersi invece di moltiplicare pagine parallele
2. portare la gestione operativa sempre piu vicino alle procedure abilitate
3. usare la pagina `Supporto > Modifiche` come ponte fra sviluppo, documentazione e deploy
4. preparare la distribuzione di NetGescon su nodi separati per ambienti di debug e collaudo

View File

@ -65,3 +65,19 @@ ## Direzione architetturale attuale
- staging deve potersi riallineare da interfaccia web senza richiedere terminale all operatore - staging deve potersi riallineare da interfaccia web senza richiedere terminale all operatore
- CTI/PBX va tenuto aperto a provider diversi, senza chiudersi su Panasonic - CTI/PBX va tenuto aperto a provider diversi, senza chiudersi su Panasonic
- le pagine gestionali devono seguire il contesto condiviso topbar per stabile e anno - le pagine gestionali devono seguire il contesto condiviso topbar per stabile e anno
## Stato utile da ricordare dopo questo slice
- `Supporto > Modifiche` e il punto da cui l operatore deve poter riallineare staging e far partire anche la ricucitura rubrica sicura.
- `Strumenti > Post-it Gestione` mostra ora un livello operativo raggruppato per richiami consecutivi, non solo righe singole.
- `Strumenti > Post-it` sta diventando il punto unico dove inserire, prendere in carico e indirizzare il Post-it verso operatore, collaboratore o fornitore.
- `Impostazioni > Scheda Amministratore > PBX / TAPI` e gia il posto giusto per accumulare segnali PBX osservati e mapparli alle persone reali dello studio.
- la direzione architetturale e `coworking unico + moduli abilitabili + ruoli multipli sulla stessa base dominio`
- il prossimo fronte infrastrutturale non e una rivoluzione immediata a microservizi, ma la distribuzione controllata su nodi separati per debug e collaudo fuori rete locale
## File nuovi o da leggere per ripartire
- `docs/support/ARCHITETTURA-RUOLI-MODULI-COWORKING.md`
- `docs/support/DISTRIBUZIONE-SERVER-DEBUG.md`
- `docs/support/CRM-ANAGRAFICA-LEGAMI.md`
- `docs/support/AGGIORNAMENTI-STAGING-GIT.md`

View File

@ -21,6 +21,10 @@ ## Ordine corretto della pipeline
## Nuovo comando QA mirato ## Nuovo comando QA mirato
Per l operatore la via ordinaria non deve essere il terminale: il flusso corretto passa dalla UI `Supporto > Modifiche`, che durante il sync Git prova anche la ricucitura sicura dei legami rubrica.
Il comando resta comunque disponibile per diagnosi tecniche o ambienti di sviluppo.
Per analizzare casi singoli senza modificare nulla: Per analizzare casi singoli senza modificare nulla:
```bash ```bash

View File

@ -0,0 +1,107 @@
# Distribuzione server e debug separato
## Obiettivo
Portare NetGescon fuori dalla sola rete locale e iniziare a lavorare con nodi separati per:
- deploy piu stabili
- collaudo fuori macchina sviluppo
- debug su ambienti isolati
- verifica reale di update, restore e sincronizzazioni
## Punto di partenza
Oggi il flusso piu stabile e:
1. sviluppo nel workspace Day0
2. commit e push su Gitea
3. sync UI da `Supporto > Modifiche`
4. verifica su staging
Questo flusso va mantenuto anche quando iniziamo a distribuire NetGescon su altri server.
## Strategia consigliata
### Fase 1. Nodo staging separato e disciplinato
Obiettivo:
- continuare a usare staging come nodo intermedio obbligatorio
- verificare che update, migrazioni e ricuciture dati passino sempre dalla procedura Git-first
Vincoli:
- niente modifiche manuali non tracciate sul nodo
- niente deploy paralleli fuori procedura
### Fase 2. Nodo debug esterno alla rete locale
Obiettivo:
- avere una macchina separata dalla rete di sviluppo
- usare dati di test controllati
- validare comportamento applicativo, login, PBX, allegati, storage, update
Uso consigliato:
- test deployment
- test restore
- test debug multi-macchina
- verifiche su watcher o bridge che non devono vivere solo sul PC principale
### Fase 3. Pacchetto installabile ripetibile
Obiettivo:
- trasformare NetGescon in un installabile coerente
- poter allestire un nuovo nodo senza ricostruire a mano script, env e procedure
Minimo indispensabile:
- checklist server
- script bootstrap
- procedura aggiornamento
- procedura backup e restore
- note di rete e storage
## Architettura iniziale consigliata
Per il primo ciclo non serve un parco complesso di macchine.
Bastano tre ruoli chiari:
1. workspace autorevole Day0
2. staging operativo aggiornato da Gitea
3. debug node separato per prove e collaudi fuori rete locale
## Cosa validare sul debug node
- installazione applicativa pulita
- migrazioni e bootstrap
- login e permessi ruoli
- documentazione in `Supporto > Modifiche`
- procedura Git sync e pacchetto aggiornamento
- Post-it, ticket e CRM telefonico
- storage tenant-aware e documenti sensibili
- eventuali watcher PBX o bridge esterni
## Regola pratica per il deploy
Ogni nodo deve poter essere ricostruito da materiale nel repository.
Quindi il repo deve contenere almeno:
- manuale operativo
- flusso update staging
- architettura ruoli/moduli
- note continuita agent
- script di update e di sync gia usati dalla UI
## Decisione attuale
Per il prossimo passo concreto:
1. completare il commit del slice corrente
2. aggiornare staging via Git-first
3. preparare una checklist tecnica per il primo nodo debug esterno
4. poi estrarre un bootstrap server riusabile, senza dipendere dalla macchina locale

View File

@ -35,6 +35,34 @@ ## Effetto pratico
## Implementazioni recenti da tenere come riferimento ## Implementazioni recenti da tenere come riferimento
### 0. Direzione di prodotto: NetGescon come spazio unico a ruoli e moduli
- La direzione attuale non e piu quella di un gestionale chiuso solo per l amministratore condominiale.
- NetGescon va trattato come uno spazio operativo unico dove la differenza non la fa la macchina o il menu fisso, ma l abilitazione concessa a ruolo, persona o tenant.
- Lo stesso utente puo lavorare su piu assi: assistenza informatica, sviluppo software, revisione contabile, collaboratore amministrativo, gestione PBX, supporto fornitori.
- La base dominio resta stabile: unita immobiliari, anagrafica unica, spese, banca, piano dei conti, ripartizioni, partita doppia.
- Sopra questa base si attivano moduli o funzioni: CTI/PBX, ticketing, revisione, import legacy, storage tenant-aware, distribuzione server, fornitori, allegati, analytics.
Effetto pratico:
- [P] l evoluzione corretta e modulo-first e permission-first, non pagina-first
- [P] lo stesso record dominio deve poter essere usato in procedure diverse senza duplicare dati o UI separate per mestiere
- [U] una persona puo lavorare nello stesso ambiente con ruoli multipli senza cambiare prodotto
### 0-bis. Slice appena chiuso su CRM telefonico e instradamento umano
- In `strumenti/post-it-gestione` le chiamate recenti hanno ora anche box raggruppati per chiamante/numero/linea consecutivi.
- Le chiamate interne sono escluse dal raggruppamento operativo, cosi la vista serve davvero per il CRM e non per il rumore tecnico.
- Ogni box puo aprire direttamente il Post-it corrispondente.
- In `strumenti/post-it` il Post-it puo essere assegnato esplicitamente a operatore, collaboratore o fornitore.
- In `impostazioni/scheda-amministratore` il tab PBX/TAPI accumula i token osservati e permette di associare piu linee, gruppi ed extension allo stesso collaboratore.
Effetto pratico:
- [U] meno duplicazione nei richiami ripetuti
- [U] piu facile decidere chi prende in carico una chiamata o un ticket derivato
- [P] il PBX viene trattato come sorgente di segnali osservati, non come struttura rigida hard-coded sul fornitore attuale
### 1. CTI / CSTA / click-to-call piu aperti lato PBX ### 1. CTI / CSTA / click-to-call piu aperti lato PBX
- Separato il tab tecnico SMDR dal tab `CTI / CSTA e Click-to-Call` in `strumenti/post-it-gestione`. - Separato il tab tecnico SMDR dal tab `CTI / CSTA e Click-to-Call` in `strumenti/post-it-gestione`.
@ -96,3 +124,5 @@ ## Prossimi passi consigliati
1. usare questa sezione come fonte primaria per le prossime implementazioni CTI e contabilita 1. usare questa sezione come fonte primaria per le prossime implementazioni CTI e contabilita
2. automatizzare anche la scrittura di snapshot di sviluppo basati sui commit e sulle note operative 2. automatizzare anche la scrittura di snapshot di sviluppo basati sui commit e sulle note operative
3. aggiungere progressivamente how-to piu mirati, senza spargere file duplicati 3. aggiungere progressivamente how-to piu mirati, senza spargere file duplicati
4. spostare le prossime decisioni su ruoli/moduli nel documento architetturale dedicato `docs/support/ARCHITETTURA-RUOLI-MODULI-COWORKING.md`
5. preparare il primo nodo esterno di debug/deploy seguendo `docs/support/DISTRIBUZIONE-SERVER-DEBUG.md`

View File

@ -1,4 +1,5 @@
@php($summary = $this->pbxSummary) @php($summary = $this->pbxSummary)
@php($observed = $this->pbxObservedTokens)
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-white p-3"> <div class="rounded-lg border border-gray-200 bg-white p-3">
@ -94,6 +95,100 @@
</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">Token PBX osservati dal CRM</div>
<div class="mb-3 text-xs text-gray-500">Il CRM accumula automaticamente gruppi, interni e linee letti dagli eventi telefonici. Usa questi valori per mappare chi gestisce cosa, senza vincolarti a Panasonic.</div>
<div class="grid gap-4 md:grid-cols-3">
<div>
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-600">Interni / extension</div>
<div class="flex flex-wrap gap-2">
@forelse($observed['extensions'] as $token)
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-700">{{ $token }}</span>
@empty
<span class="text-xs text-slate-500">Nessun interno osservato.</span>
@endforelse
</div>
</div>
<div>
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-600">Gruppi</div>
<div class="flex flex-wrap gap-2">
@forelse($observed['groups'] as $token)
<span class="rounded-full bg-blue-100 px-2.5 py-1 text-xs text-blue-800">{{ $token }}</span>
@empty
<span class="text-xs text-slate-500">Nessun gruppo osservato.</span>
@endforelse
</div>
</div>
<div>
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-600">Linee / DID</div>
<div class="flex flex-wrap gap-2">
@forelse($observed['lines'] as $token)
<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs text-emerald-800">{{ $token }}</span>
@empty
<span class="text-xs text-slate-500">Nessuna linea osservata.</span>
@endforelse
</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">Chi gestisce interni, linee e gruppi</div>
<div class="mb-3 text-xs text-gray-500">Ogni nominativo puo gestire piu interni, piu linee e piu gruppi. I flag PBX restano modificabili inline con check rapidi.</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">Collaboratore</th>
<th class="border px-2 py-1.5 text-left">Extension gestite</th>
<th class="border px-2 py-1.5 text-left">Gruppi gestiti</th>
<th class="border px-2 py-1.5 text-left">Linee / DID</th>
<th class="border px-2 py-1.5 text-left">Flags</th>
<th class="border px-2 py-1.5 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($this->collaboratoriCentralinoRows as $row)
<tr class="hover:bg-slate-50">
<td class="border px-2 py-1.5">
<div class="font-medium">{{ $row['name'] }}</div>
<div class="text-[11px] text-slate-500">{{ $row['email'] }}</div>
</td>
<td class="border px-2 py-1.5">
<input type="text" wire:model.defer="collaboratorePbxExtensions.{{ (int) $row['user_id'] }}" class="w-40 rounded-md border-gray-300 text-xs" placeholder="EXT00208, 208" />
</td>
<td class="border px-2 py-1.5">
<input type="text" wire:model.defer="collaboratorePbxGroups.{{ (int) $row['user_id'] }}" class="w-36 rounded-md border-gray-300 text-xs" placeholder="GRP00601, GRP00603" />
</td>
<td class="border px-2 py-1.5">
<input type="text" wire:model.defer="collaboratorePbxLines.{{ (int) $row['user_id'] }}" class="w-36 rounded-md border-gray-300 text-xs" placeholder="0003, 0008" />
</td>
<td class="border px-2 py-1.5">
<label class="mr-3 inline-flex items-center gap-1 text-[11px]">
<input type="checkbox" wire:model.defer="collaboratorePbxPopupEnabled.{{ (int) $row['user_id'] }}" class="rounded border-gray-300" />
popup
</label>
<label class="inline-flex items-center gap-1 text-[11px]">
<input type="checkbox" wire:model.defer="collaboratorePbxClickToCallEnabled.{{ (int) $row['user_id'] }}" class="rounded border-gray-300" />
click2call
</label>
</td>
<td class="border px-2 py-1.5">
<x-filament::button size="xs" color="gray" type="button" wire:click="salvaInternoCollaboratore({{ (int) $row['user_id'] }})">Salva</x-filament::button>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="border px-2 py-3 text-center text-gray-500">Nessun collaboratore assegnato agli stabili di questo amministratore.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-3"> <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-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, almeno uno dei canali telefonici attivi sta gia portando traffico nel CRM. In modalita hybrid puoi vedere sia eventi Panasonic sia righe SMDR.</div> <div class="mb-3 text-xs text-gray-500">Se qui compaiono record, almeno uno dei canali telefonici attivi sta gia portando traffico nel CRM. In modalita hybrid puoi vedere sia eventi Panasonic sia righe SMDR.</div>

View File

@ -33,6 +33,60 @@
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4">
<h2 class="mb-3 text-base font-semibold">Storico chiamate recenti</h2> <h2 class="mb-3 text-base font-semibold">Storico chiamate recenti</h2>
<div class="mb-5">
<div class="mb-2 flex items-center justify-between gap-2">
<div>
<div class="text-sm font-semibold text-slate-800">Box chiamate raggruppate</div>
<div class="text-xs text-slate-500">Le comunicazioni interne non compaiono qui. Le chiamate consecutive dello stesso numero restano nello stesso box finche non interviene un altro chiamante.</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
@forelse($this->recentiRaggruppati as $gruppo)
@php($box = $gruppo['latest'])
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-semibold text-slate-900">{{ $gruppo['caller_label'] }}</div>
<div class="text-sm text-slate-600">
{{ $gruppo['phone'] !== '' ? $gruppo['phone'] : 'Numero non disponibile' }}
@if($gruppo['route_label'] !== '-')
· verso {{ $gruppo['route_label'] }}
@endif
</div>
</div>
<span class="rounded-full bg-indigo-100 px-2.5 py-1 text-xs font-semibold text-indigo-800">{{ $gruppo['count'] }} tentativi</span>
</div>
<div class="mt-3 text-xs text-slate-500">{{ $gruppo['last_at'] }}{{ $gruppo['count'] > 1 ? ' · gruppo consecutivo' : '' }}</div>
<div class="mt-2 text-sm text-slate-700">{{ $box->oggetto ?: 'Senza oggetto' }}</div>
<div class="mt-3 flex flex-wrap gap-2 text-[11px]">
<span class="rounded-md bg-white px-2 py-1 text-slate-700">{{ $box->stato }}</span>
@if($box->assegnazione_tipo)
<span class="rounded-md bg-amber-100 px-2 py-1 text-amber-800">{{ ucfirst(str_replace('_', ' ', (string) $box->assegnazione_tipo)) }}</span>
@endif
@if($box->assegnatoAUser)
<span class="rounded-md bg-emerald-100 px-2 py-1 text-emerald-800">{{ $box->assegnatoAUser->name }}</span>
@endif
@if($box->assegnatoAFornitore)
<span class="rounded-md bg-emerald-100 px-2 py-1 text-emerald-800">{{ $box->assegnatoAFornitore->ragione_sociale ?: trim(($box->assegnatoAFornitore->nome ?? '') . ' ' . ($box->assegnatoAFornitore->cognome ?? '')) }}</span>
@endif
</div>
<div class="mt-4 flex flex-wrap gap-2">
<a href="{{ $this->getPostItPageUrl((int) $box->id) }}" class="inline-flex items-center rounded-md bg-indigo-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-600">Apri Post-it</a>
@if($box->ticket_id)
<a href="{{ \App\Filament\Pages\Supporto\TicketGestione::getUrl(panel: 'admin-filament') }}#ticket-{{ (int) $box->ticket_id }}" class="inline-flex items-center rounded-md border px-3 py-1.5 text-xs">Apri ticket</a>
@endif
</div>
</div>
@empty
<div class="rounded-lg border border-dashed p-4 text-sm text-gray-500 md:col-span-2 xl:col-span-3">Nessuna chiamata esterna raggruppabile disponibile.</div>
@endforelse
</div>
</div>
<div class="space-y-3"> <div class="space-y-3">
@forelse($this->recenti as $riga) @forelse($this->recenti as $riga)
<div @class([ <div @class([

View File

@ -108,5 +108,131 @@
<x-filament::button color="success" wire:click="salvaEConvertiInTicket">Salva e crea ticket</x-filament::button> <x-filament::button color="success" wire:click="salvaEConvertiInTicket">Salva e crea ticket</x-filament::button>
</div> </div>
</div> </div>
@if($this->isPostItTableReady())
<div class="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(320px,1fr)]">
<div class="rounded-xl border bg-white p-4">
<div class="mb-3 flex items-center justify-between gap-2">
<div>
<h2 class="text-base font-semibold">Post-it recenti</h2>
<p class="text-sm text-gray-500">Seleziona un Post-it per gestirlo o assegnarlo direttamente.</p>
</div>
</div>
<div class="space-y-3">
@forelse($this->recenti as $riga)
<button type="button" wire:click="selectPostIt({{ (int) $riga->id }})" class="block w-full rounded-lg border p-3 text-left {{ (int) $selectedPostItId === (int) $riga->id ? 'border-emerald-400 bg-emerald-50 ring-1 ring-emerald-200' : 'hover:bg-gray-50' }}">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<div class="font-medium">{{ $riga->oggetto ?: 'Senza oggetto' }}</div>
<div class="text-sm text-gray-600">
{{ $riga->nome_chiamante ?: 'Chiamante non identificato' }}
@if($riga->telefono)
· {{ $riga->telefono }}
@endif
@if($riga->stabile)
· {{ $riga->stabile->denominazione ?: ('Stabile #' . $riga->stabile->id) }}
@endif
</div>
</div>
<div class="text-xs text-gray-500">{{ optional($riga->chiamata_il)->format('d/m/Y H:i') }}</div>
</div>
<div class="mt-2 flex flex-wrap gap-2 text-[11px]">
<span class="rounded-md bg-slate-100 px-2 py-1 text-slate-700">{{ $riga->stato }}</span>
<span class="rounded-md bg-blue-100 px-2 py-1 text-blue-800">{{ $riga->priorita }}</span>
@if($riga->assegnazione_tipo)
<span class="rounded-md bg-amber-100 px-2 py-1 text-amber-800">Assegnato a {{ $riga->assegnazione_tipo }}</span>
@endif
@if($riga->assegnatoAUser)
<span class="rounded-md bg-emerald-100 px-2 py-1 text-emerald-800">{{ $riga->assegnatoAUser->name }}</span>
@endif
@if($riga->assegnatoAFornitore)
<span class="rounded-md bg-emerald-100 px-2 py-1 text-emerald-800">{{ $riga->assegnatoAFornitore->ragione_sociale ?: trim(($riga->assegnatoAFornitore->nome ?? '') . ' ' . ($riga->assegnatoAFornitore->cognome ?? '')) }}</span>
@endif
@if($riga->ticket_id)
<span class="rounded-md bg-indigo-100 px-2 py-1 text-indigo-800">Ticket #{{ (int) $riga->ticket_id }}</span>
@endif
</div>
</button>
@empty
<div class="rounded-lg border border-dashed p-4 text-sm text-gray-500">Nessun Post-it recente.</div>
@endforelse
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<h2 class="text-base font-semibold">Assegnazione Post-it</h2>
@if(! $this->isPostItAssignmentReady())
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">Assegnazione disponibile dopo l'applicazione della nuova migrazione Post-it nel normale flusso di aggiornamento.</div>
@elseif($this->selectedPostIt)
<div class="mt-3 rounded-lg border bg-slate-50 p-3 text-sm text-slate-700">
<div class="font-medium">Post-it #{{ (int) $this->selectedPostIt->id }} · {{ $this->selectedPostIt->oggetto ?: 'Senza oggetto' }}</div>
<div class="mt-1 text-xs text-slate-500">
{{ $this->selectedPostIt->nome_chiamante ?: 'Chiamante non identificato' }}
@if($this->selectedPostIt->telefono)
· {{ $this->selectedPostIt->telefono }}
@endif
@if($this->selectedPostIt->ticket_id)
· Ticket #{{ (int) $this->selectedPostIt->ticket_id }}
@endif
</div>
</div>
<div class="mt-4 space-y-4">
<label class="block text-sm">
<span class="mb-1 block font-medium">Tipo assegnazione</span>
<select wire:model.live="assegnazioneTipo" class="w-full rounded-lg border-gray-300">
<option value="operatore">Operatore</option>
<option value="fornitore">Fornitore</option>
<option value="collaboratore">Collaboratore dello studio</option>
</select>
</label>
@if($assegnazioneTipo === 'operatore')
<label class="block text-sm">
<span class="mb-1 block font-medium">Operatore</span>
<select wire:model="assegnazioneUserId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona operatore</option>
@foreach($this->operatoriOptions as $id => $label)
<option value="{{ $id }}">{{ $label }}</option>
@endforeach
</select>
</label>
@elseif($assegnazioneTipo === 'collaboratore')
<label class="block text-sm">
<span class="mb-1 block font-medium">Collaboratore</span>
<select wire:model="assegnazioneUserId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona collaboratore</option>
@foreach($this->collaboratoriOptions as $id => $label)
<option value="{{ $id }}">{{ $label }}</option>
@endforeach
</select>
</label>
@else
<label class="block text-sm">
<span class="mb-1 block font-medium">Fornitore</span>
<select wire:model="assegnazioneFornitoreId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona fornitore</option>
@foreach($this->fornitoriOptions as $id => $label)
<option value="{{ $id }}">{{ $label }}</option>
@endforeach
</select>
</label>
@endif
<div class="flex flex-wrap gap-2">
<x-filament::button wire:click="assegnaPostIt">Salva assegnazione</x-filament::button>
@if($this->selectedPostIt->ticket_id)
<a href="{{ \App\Filament\Pages\Supporto\TicketGestione::getUrl(panel: 'admin-filament') }}#ticket-{{ (int) $this->selectedPostIt->ticket_id }}" class="inline-flex items-center rounded-md border px-3 py-2 text-sm">Apri ticket collegato</a>
@endif
</div>
</div>
@else
<div class="mt-3 rounded-lg border border-dashed p-4 text-sm text-gray-500">Seleziona un Post-it recente per assegnarlo.</div>
@endif
</div>
</div>
@endif
</div> </div>
</x-filament-panels::page> </x-filament-panels::page>