feat: consolida post-it pbx e continuita operativa
This commit is contained in:
parent
72661559df
commit
236aef53cd
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
145
docs/support/ARCHITETTURA-RUOLI-MODULI-COWORKING.md
Normal file
145
docs/support/ARCHITETTURA-RUOLI-MODULI-COWORKING.md
Normal 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
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
107
docs/support/DISTRIBUZIONE-SERVER-DEBUG.md
Normal file
107
docs/support/DISTRIBUZIONE-SERVER-DEBUG.md
Normal 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
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user