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.
- 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.
- 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

View File

@ -290,6 +290,15 @@ public function handle(): int
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->callSilently('optimize:clear');
@ -312,6 +321,7 @@ public function handle(): int
'repo_path' => $repoPath,
'deploy_path' => $deployPath,
'mode' => $deploySync ? 'external-repo-rsync' : 'in-place-git',
'rubrica_legami_qa' => $rubricaQaExit === 0 ? 'completed' : 'warning',
]);
$this->info('Sync Git completata con successo.');

View File

@ -64,6 +64,21 @@ class SchedaAmministratore extends Page implements HasForms
/** @var array<int,string> */
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 static function canAccess(): bool
@ -1284,6 +1299,8 @@ public function getAccessoAmministratoreRowsProperty(): array
*/
public function getCollaboratoriCentralinoRowsProperty(): array
{
$mappings = (array) Arr::get($this->amministratore->impostazioni ?? [], 'pbx.user_mappings', []);
$query = User::query()
->with('roles')
->whereHas('roles', function ($q): void {
@ -1298,7 +1315,21 @@ public function getCollaboratoriCentralinoRowsProperty(): array
return $query
->get()
->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->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 [
'user_id' => (int) $user->id,
@ -1306,6 +1337,9 @@ public function getCollaboratoriCentralinoRowsProperty(): array
'email' => (string) $user->email,
'ruoli' => $user->roles->pluck('name')->values()->all(),
'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_click_to_call_enabled' => (bool) ($user->pbx_click_to_call_enabled ?? false),
];
@ -1313,6 +1347,56 @@ public function getCollaboratoriCentralinoRowsProperty(): array
->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}
*/
@ -1577,16 +1661,78 @@ public function salvaInternoCollaboratore(int $userId): void
return;
}
$extension = trim((string) ($this->collaboratorePbxExtension[$userId] ?? ''));
$user->pbx_extension = $extension !== '' ? $extension : null;
$extensions = $this->normalizePbxMappingInput((string) ($this->collaboratorePbxExtensions[$userId] ?? $this->collaboratorePbxExtension[$userId] ?? ''));
$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();
$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()
->title('Interno collaboratore aggiornato')
->title('Mappatura PBX collaboratore aggiornata')
->success()
->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
{
$dipendente = FornitoreDipendente::query()

View File

@ -2,11 +2,13 @@
namespace App\Filament\Pages\Strumenti;
use App\Models\ChiamataPostIt;
use App\Models\Fornitore;
use App\Models\RubricaUniversale;
use App\Models\Stabile;
use App\Models\Ticket;
use App\Models\User;
use App\Support\PhoneNumber;
use App\Support\StabileContext;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@ -42,6 +44,11 @@ class PostIt extends Page
public string $priorita = 'Media';
public ?int $rubricaId = 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;
@ -53,6 +60,10 @@ public function mount(): void
$this->priorita = 'Media';
$this->direzione = 'in_arrivo';
$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
@ -65,11 +76,95 @@ public function getGestioneTicketUrl(): string
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
{
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
{
$query = trim((string) $value);
@ -247,6 +342,8 @@ public function convertiInTicket(int $postItId): void
$ticket = Ticket::query()->create([
'stabile_id' => $postIt->stabile_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')),
'descrizione' => trim(($postIt->nota ?? '') . "\n\nRiferimento chiamata: #{$postIt->id}"),
'data_apertura' => now(),
@ -292,16 +389,81 @@ public function getRecentiProperty()
}
try {
return ChiamataPostIt::query()
->with(['rubrica', 'stabile', 'ticket', 'creatoDa'])
$items = ChiamataPostIt::query()
->with(['rubrica', 'stabile', 'ticket', 'creatoDa', 'assegnatoAUser:id,name', 'assegnatoAFornitore:id,ragione_sociale,nome,cognome'])
->orderByDesc('chiamata_il')
->limit(25)
->get();
if (! $this->selectedPostItId && $items->isNotEmpty()) {
$this->selectedPostItId = (int) ($this->focusPostItId ?: $items->first()->id);
$this->loadSelectedPostItAssignment();
}
return $items;
} catch (QueryException) {
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
{
$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
{
$this->telefono = null;

View File

@ -60,6 +60,9 @@ class PostItGestione extends Page
/** @var array<string, array{id:int|null,name:string|null}> */
private array $pbxExtensionCache = [];
/** @var array<int, CommunicationMessage|null> */
private array $postItMessageCache = [];
public function mount(): void
{
$focus = (int) request()->query('focus_post_it', 0);
@ -75,6 +78,11 @@ public function getPostItInserimentoUrl(): string
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
{
if (! $this->isPostItTableReady()) {
@ -216,7 +224,7 @@ public function getRecentiProperty()
try {
$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('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()
{
if (! Schema::hasTable('communication_messages')) {
@ -518,6 +561,59 @@ private function buildTicketDescriptionFromPostIt(int $postItId, ChiamataPostIt
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
{
$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.',
'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) {

View File

@ -15,6 +15,9 @@ class ChiamataPostIt extends Model
'rubrica_id',
'ticket_id',
'creato_da_user_id',
'assegnato_a_user_id',
'assegnato_a_fornitore_id',
'assegnazione_tipo',
'telefono',
'nome_chiamante',
'direzione',
@ -57,4 +60,14 @@ public function creatoDa()
{
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
2. allinea il nodo al branch richiesto
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.
## 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
### 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
- 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
## 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
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:
```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
### 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
- 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
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
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($observed = $this->pbxObservedTokens)
<div class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-white p-3">
@ -94,6 +95,100 @@
</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="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>

View File

@ -33,6 +33,60 @@
<div class="rounded-xl border bg-white p-4">
<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">
@forelse($this->recenti as $riga)
<div @class([

View File

@ -108,5 +108,131 @@
<x-filament::button color="success" wire:click="salvaEConvertiInTicket">Salva e crea ticket</x-filament::button>
</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>
</x-filament-panels::page>