Fix post-it live flow and legacy imports

This commit is contained in:
michele 2026-03-31 11:55:43 +00:00
parent 8c10069a71
commit cd5d004561
7 changed files with 584 additions and 100 deletions

View File

@ -817,7 +817,7 @@ private function normalizePhone(?string $phone): ?string
* Recupera identità tenant da altre righe condomin della stessa unità, quando la riga corrente è incompleta.
* @return array{cf:?string,email:?string,telefono:?string,cellulare:?string}
*/
private function lookupTenantIdentityFromStaging(string $conn, string $codStabile, string $scala, string $interno, mixed $piano, mixed $tenantNameHint): array
private function lookupTenantIdentityFromStaging(string $conn, string $codStabile, ?string $scala, ?string $interno, mixed $piano, mixed $tenantNameHint): array
{
try {
if (! Schema::connection($conn)->hasTable('condomin')) {

View File

@ -14,6 +14,8 @@ class ImportLegacyFornitoriTagsCommand extends Command
protected $description = 'Importa e normalizza i tag fornitori dalla colonna legacy descrizione (gescon_import.fornitori).';
private bool $hasLegacyDescrizioneColumn = false;
public function handle(): int
{
if (! config('database.connections.gescon_import')) {
@ -22,30 +24,38 @@ public function handle(): int
}
$dryRun = (bool) $this->option('dry-run');
$this->hasLegacyDescrizioneColumn = Schema::hasColumn('fornitori', 'legacy_descrizione');
$filterIds = collect((array) $this->option('fornitore-id'))
->map(fn($v) => (int) $v)
->filter(fn(int $v): bool => $v > 0)
->values()
->all();
[$legacyTable, $descrColumn] = $this->resolveLegacySource();
if ($legacyTable === null || $descrColumn === null) {
$source = $this->resolveLegacySource();
if ($source === null) {
$this->warn('Sorgente legacy non trovata: manca tabella/colonna descrizione su gescon_import.');
return self::FAILURE;
}
$legacyRows = DB::connection('gescon_import')
->table($legacyTable)
->table($source['table'])
->select([
'id',
'cod_forn',
'ragione_sociale',
'partita_iva',
'codice_fiscale',
DB::raw($descrColumn . ' as descrizione_legacy'),
$this->selectColumnOrNull($source['id_column'] ?? null, 'id'),
$this->selectColumnOrNull($source['code_column'] ?? null, 'cod_forn'),
$this->selectCoalescedColumnOrNull($source['name_columns'] ?? [], 'ragione_sociale'),
$this->selectColumnOrNull($source['piva_column'] ?? null, 'partita_iva'),
$this->selectColumnOrNull($source['cf_column'] ?? null, 'codice_fiscale'),
$this->selectColumnOrNull($source['description_column'] ?? null, 'descrizione_legacy'),
$this->selectColumnOrNull($source['notes_column'] ?? null, 'note_legacy'),
])
->whereNotNull($descrColumn)
->where($descrColumn, '<>', '')
->where(function ($query) use ($source): void {
foreach (($source['text_columns'] ?? []) as $column) {
$query->orWhere(function ($inner) use ($column): void {
$inner->whereNotNull($column)
->where($column, '<>', '');
});
}
})
->get();
if ($legacyRows->isEmpty()) {
@ -67,7 +77,8 @@ public function handle(): int
continue;
}
$incoming = $this->extractTags((string) ($legacy->descrizione_legacy ?? ''));
$legacyDescrizione = $this->buildLegacyDescription($legacy);
$incoming = $this->extractTags($legacyDescrizione);
if (count($incoming) === 0) {
$skipped++;
continue;
@ -77,7 +88,6 @@ public function handle(): int
$merged = array_values(array_unique(array_merge($current, $incoming)));
sort($merged, SORT_NATURAL | SORT_FLAG_CASE);
$legacyDescrizione = trim((string) ($legacy->descrizione_legacy ?? ''));
$newTagsValue = implode(', ', $merged);
$changed = ((string) ($fornitore->tags ?? '')) !== $newTagsValue
|| ((string) ($fornitore->legacy_descrizione ?? '')) !== $legacyDescrizione;
@ -93,8 +103,10 @@ public function handle(): int
continue;
}
$fornitore->tags = $newTagsValue;
$fornitore->legacy_descrizione = $legacyDescrizione !== '' ? $legacyDescrizione : null;
$fornitore->tags = $newTagsValue;
if ($this->hasLegacyDescrizioneColumn) {
$fornitore->legacy_descrizione = $legacyDescrizione !== '' ? $legacyDescrizione : null;
}
$fornitore->save();
$updated++;
}
@ -105,24 +117,129 @@ public function handle(): int
return self::SUCCESS;
}
private function resolveLegacySource(): array
private function resolveLegacySource(): ?array
{
$tables = ['fornitori', 'fornitori_gescon'];
$columns = ['descrizione', 'descr', 'note'];
$sources = [
[
'table' => 'mdb_fornitori',
'id_column' => 'id_fornitore',
'code_column' => 'cod_forn',
'name_columns' => ['denominazione', 'ragione_sociale', 'cognome', 'nome'],
'piva_column' => 'p_iva',
'cf_column' => 'cod_fisc',
'description_column' => 'descrizione',
'notes_column' => 'note',
'text_columns' => ['descrizione', 'note'],
],
[
'table' => 'fornitori',
'id_column' => 'id',
'code_column' => 'cod_forn',
'name_columns' => ['ragione_sociale', 'cognome', 'nome'],
'piva_column' => 'partita_iva',
'cf_column' => 'codice_fiscale',
'description_column' => 'descrizione',
'notes_column' => 'note',
'text_columns' => ['descrizione', 'note'],
],
[
'table' => 'fornitori_gescon',
'id_column' => 'id',
'code_column' => 'cod_forn',
'name_columns' => ['ragione_sociale'],
'piva_column' => 'partita_iva',
'cf_column' => 'codice_fiscale',
'description_column' => 'descr',
'notes_column' => 'note',
'text_columns' => ['descr', 'note'],
],
];
foreach ($tables as $table) {
if (! Schema::connection('gescon_import')->hasTable($table)) {
foreach ($sources as $source) {
if (! Schema::connection('gescon_import')->hasTable($source['table'])) {
continue;
}
foreach ($columns as $column) {
if (Schema::connection('gescon_import')->hasColumn($table, $column)) {
return [$table, $column];
$hasColumn = fn(string $column): bool => Schema::connection('gescon_import')->hasColumn($source['table'], $column);
$source['id_column'] = isset($source['id_column']) && $hasColumn($source['id_column'])
? $source['id_column']
: null;
$source['code_column'] = isset($source['code_column']) && $hasColumn($source['code_column'])
? $source['code_column']
: null;
$source['piva_column'] = isset($source['piva_column']) && $hasColumn($source['piva_column'])
? $source['piva_column']
: null;
$source['cf_column'] = isset($source['cf_column']) && $hasColumn($source['cf_column'])
? $source['cf_column']
: null;
$source['name_columns'] = array_values(array_filter(
$source['name_columns'] ?? [],
fn(string $column): bool => $hasColumn($column)
));
$availableTextColumns = [];
foreach (($source['text_columns'] ?? []) as $column) {
if ($hasColumn($column)) {
$availableTextColumns[] = $column;
}
}
if ($availableTextColumns === []) {
continue;
}
$source['text_columns'] = $availableTextColumns;
$source['description_column'] = in_array((string) ($source['description_column'] ?? ''), $availableTextColumns, true)
? $source['description_column']
: null;
$source['notes_column'] = in_array((string) ($source['notes_column'] ?? ''), $availableTextColumns, true)
? $source['notes_column']
: null;
return $source;
}
return null;
}
private function selectColumnOrNull(?string $column, string $alias): \Illuminate\Database\Query\Expression
{
if ($column === null) {
return DB::raw('NULL as ' . $alias);
}
return DB::raw($column . ' as ' . $alias);
}
/**
* @param array<int, string> $columns
*/
private function selectCoalescedColumnOrNull(array $columns, string $alias): \Illuminate\Database\Query\Expression
{
$usable = array_values(array_filter($columns, fn(string $column): bool => $column !== ''));
if ($usable === []) {
return DB::raw('NULL as ' . $alias);
}
$segments = array_map(fn(string $column): string => 'NULLIF(TRIM(' . $column . '), \'\')', $usable);
return DB::raw('COALESCE(' . implode(', ', $segments) . ') as ' . $alias);
}
private function buildLegacyDescription(object $legacy): string
{
$parts = [];
foreach (['descrizione_legacy', 'note_legacy'] as $field) {
$value = trim((string) ($legacy->{$field} ?? ''));
if ($value !== '') {
$parts[] = $value;
}
}
return [null, null];
return implode(' | ', array_values(array_unique($parts)));
}
private function resolveLocalFornitore(object $legacy): ?Fornitore
@ -165,6 +282,15 @@ private function resolveLocalFornitore(object $legacy): ?Fornitore
return Fornitore::query()->where('ragione_sociale', $ragione)->first();
}
$normalizedRagione = $this->normalizeComparableString($ragione);
if ($normalizedRagione !== '') {
return Fornitore::query()
->get(['id', 'ragione_sociale'])
->first(function (Fornitore $fornitore) use ($normalizedRagione): bool {
return $this->normalizeComparableString((string) ($fornitore->ragione_sociale ?? '')) === $normalizedRagione;
});
}
return null;
}
@ -179,7 +305,7 @@ private function extractTags(string $input): array
}
$parts = preg_split('/[,;|\n\r\/]+/', $input) ?: [];
$tags = [];
$tags = $this->extractKeywordTags($input);
foreach ($parts as $part) {
$normalized = $this->canonicalizeTag($part);
@ -195,14 +321,93 @@ private function extractTags(string $input): array
return $tags;
}
/**
* @return array<int, string>
*/
private function extractKeywordTags(string $input): array
{
$haystack = mb_strtolower(trim($input));
if ($haystack === '') {
return [];
}
$keywords = [
'idr' => 'idraulico',
'termoidraul' => 'termoidraulico',
'elettric' => 'elettricista',
'energia elet' => 'energia elettrica',
'ascens' => 'ascensorista',
'puliz' => 'pulizie',
'giardin' => 'giardiniere',
'spurgh' => 'spurgo',
'edil' => 'edile',
'murator' => 'edile',
'fabbro' => 'fabbro',
'ferrament' => 'ferramenta',
'serrament' => 'serramenti',
'portier' => 'portierato',
'citofon' => 'citofonia',
'videosorv' => 'videosorveglianza',
'sicurezz' => 'sicurezza',
'antincend' => 'antincendio',
'estintor' => 'antincendio',
'calda' => 'caldaia',
'riscald' => 'riscaldamento',
'condizion' => 'condizionamento',
'gas' => 'gas',
'acqua' => 'acqua',
'fogna' => 'fognatura',
'antenna' => 'antenna',
'telecom' => 'telefonia',
'pec' => 'pec',
'aruba' => 'pec',
'posta' => 'postali',
'raccomand' => 'postali',
'spediz' => 'spedizioni',
'infest' => 'disinfestazione',
'amminist' => 'amministrazione',
'assicur' => 'assicurazione',
'avvocat' => 'legale',
'legale' => 'legale',
'commercial' => 'commercialista',
'geometr' => 'geometra',
'privacy' => 'privacy',
'timbri' => 'timbri',
'targhe' => 'targhe',
'paghe' => 'paghe contributi',
'contribut' => 'paghe contributi',
'utenz' => 'utenze',
];
$tags = [];
foreach ($keywords as $needle => $canonical) {
if (str_contains($haystack, $needle)) {
$tags[] = $canonical;
}
}
$tags = array_values(array_unique($tags));
sort($tags, SORT_NATURAL | SORT_FLAG_CASE);
return $tags;
}
private function canonicalizeTag(string $raw): ?string
{
$clean = trim(mb_strtolower($raw));
$clean = trim(mb_strtolower($raw), " \t\n\r\0\x0B-_,.;:");
$clean = preg_replace('/\s+/', ' ', $clean) ?? '';
if ($clean === '' || mb_strlen($clean) < 3) {
return null;
}
if (str_contains($clean, '@') || preg_match('/\d{2,}/', $clean)) {
return null;
}
if (preg_match('/\b(tel|telefono|fax|cell|cellulare|pec|email|codice cliente|parlare con|sig\.?|sig\.ra)\b/u', $clean)) {
return null;
}
$map = [
'idr' => 'idraulico',
'idraul' => 'idraulico',
@ -213,14 +418,31 @@ private function canonicalizeTag(string $raw): ?string
'giardin' => 'giardiniere',
'assicur' => 'assicurazione',
'manut' => 'manutenzione',
'murator' => 'edile',
'privacy' => 'privacy',
'ferrament'=> 'ferramenta',
];
foreach ($map as $needle => $canonical) {
if (str_starts_with($clean, $needle)) {
if (str_contains($clean, $needle)) {
return $canonical;
}
}
$lettersOnly = preg_replace('/[^[:alpha:] ]+/u', '', $clean) ?? '';
$wordCount = count(array_filter(explode(' ', $lettersOnly), fn(string $word): bool => $word !== ''));
if (mb_strlen(trim($lettersOnly)) < 3 || $wordCount > 3 || mb_strlen($clean) > 28) {
return null;
}
return $clean;
}
private function normalizeComparableString(string $value): string
{
$normalized = mb_strtolower(trim($value));
$normalized = preg_replace('/[^[:alnum:]]+/u', '', $normalized) ?? '';
return $normalized;
}
}

View File

@ -128,6 +128,7 @@ public function mount(int | string $record): void
}
$this->rubrica = RubricaUniversale::query()->findOrFail((int) $record);
$this->applyLegacyIdentityDefaults();
$activeStabile = StabileContext::getActiveStabile($user);
$adminId = (int) ($activeStabile?->amministratore_id ?: 0);
@ -301,6 +302,17 @@ public function mount(int | string $record): void
$this->hydrateStudioCollaboratoreWorkspace();
}
private function applyLegacyIdentityDefaults(): void
{
$legacyIdentity = $this->extractLegacyIdentityFromNote($this->rubrica->note ?? null);
if (! $this->rubrica->titolo_id && ! empty($legacyIdentity['titolo_id'])) {
$this->rubrica->titolo_id = (int) $legacyIdentity['titolo_id'];
$this->rubrica->saveQuietly();
$this->rubrica->refresh();
}
}
public function startInlineEdit(): void
{
$this->fillInlineForm();

View File

@ -34,6 +34,8 @@ class PostItGestione extends Page
protected string $view = 'filament.pages.strumenti.post-it-gestione';
public ?int $focusPostItId = null;
public string $activeTab = 'storico';
public string $tecnicoCallView = 'esterne';
@ -53,6 +55,16 @@ class PostItGestione extends Page
/** @var array<string, array{id:int|null,name:string|null}> */
private array $pbxExtensionCache = [];
public function mount(): void
{
$focus = (int) request()->query('focus_post_it', 0);
$this->focusPostItId = $focus > 0 ? $focus : null;
if (request()->query('tab') === 'storico') {
$this->activeTab = 'storico';
}
}
public function getPostItInserimentoUrl(): string
{
return PostIt::getUrl(panel: 'admin-filament');
@ -196,11 +208,16 @@ public function getRecentiProperty()
}
try {
return ChiamataPostIt::query()
$query = ChiamataPostIt::query()
->with(['rubrica', 'stabile', 'ticket', 'creatoDa'])
->orderByDesc('chiamata_il')
->limit(50)
->get();
->orderByDesc('id');
if ($this->focusPostItId) {
$query->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [$this->focusPostItId]);
}
return $query->limit(50)->get();
} catch (QueryException) {
return collect();
}

View File

@ -3,6 +3,7 @@
use App\Filament\Pages\Contabilita\EstrattoContoSoggetto;
use App\Filament\Pages\Gescon\RubricaUniversaleScheda;
use App\Filament\Pages\Strumenti\PostItGestione;
use App\Models\CategoriaTicket;
use App\Models\ChiamataPostIt;
use App\Models\PbxClickToCallRequest;
@ -68,9 +69,17 @@ class TicketMobile extends Page
/** @var array<int, mixed> */
public array $newTicketCameraShots = [];
/** @var array<int, mixed> */
public array $pendingTicketAttachments = [];
/** @var array<int, mixed> */
public array $pendingTicketCameraShots = [];
/** @var array<int, string> */
public array $newTicketAttachmentDescriptions = [];
public string $newTicketDraftProtocol = '';
public ?string $newTicketFotoNote = null;
/** @var \Illuminate\Support\Collection<int, Ticket> */
@ -122,6 +131,7 @@ public function mount(): void
$this->callerMatches = collect();
$this->newTicketPriorita = 'Media';
$this->newTicketTipoIntervento = 'altro';
$this->resetDraftUploadState();
$this->loadCategorieTicketOptions();
if (request()->query('status') === 'all') {
@ -160,7 +170,7 @@ public function refreshLiveCallBanner(): void
return;
}
$this->liveIncomingCall = app(LiveIncomingCallService::class)->getForUser($authUser);
$this->liveIncomingCall = app(LiveIncomingCallService::class)->getForUser($authUser);
$this->liveCallCanClickToCall = (bool) ($this->liveIncomingCall['can_click_to_call'] ?? false);
}
@ -188,7 +198,7 @@ public function creaPostItDaChiamataInIngresso(): void
$line = (string) ($this->liveIncomingCall['line'] ?? '');
$this->prefillTicketFromLiveCall($phone, $line);
ChiamataPostIt::query()->create([
$postIt = ChiamataPostIt::query()->create([
'stabile_id' => (int) $stabileId,
'creato_da_user_id' => (int) $user->id,
'rubrica_id' => (int) ($this->selectedCallerId ?? 0) ?: null,
@ -208,6 +218,14 @@ public function creaPostItDaChiamataInIngresso(): void
->title('Post-it creato dalla chiamata in arrivo')
->success()
->send();
$this->redirect(
PostItGestione::getUrl([
'tab' => 'storico',
'focus_post_it' => (int) $postIt->id,
], panel: 'admin-filament'),
navigate: true,
);
}
public function usaChiamataInIngresso(): void
@ -365,15 +383,15 @@ private function loadTickets(): void
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
$stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all');
if ($stabileIds === []) {
$this->tickets = collect();
return;
}
$query = Ticket::query()
->with(['categoriaTicket'])
->where('stabile_id', $stabileId);
->whereIn('stabile_id', $stabileIds);
if ($this->status === 'open') {
$query->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione']);
@ -395,13 +413,13 @@ private function loadCounters(): void
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
$stabileIds = $this->resolveTicketScopeStabileIds($this->status === 'all');
if ($stabileIds === []) {
$this->ticketCounters = ['open' => 0, 'urgent' => 0, 'closed' => 0, 'all' => 0];
return;
}
$base = Ticket::query()->where('stabile_id', $stabileId);
$base = Ticket::query()->whereIn('stabile_id', $stabileIds);
$this->ticketCounters = [
'open' => (clone $base)->whereIn('stato', ['Aperto', 'Preso in Carico', 'In Lavorazione'])->count(),
@ -484,22 +502,22 @@ public function creaTicketRapido(): void
'newTicketAttachmentDescriptions' => ['nullable', 'array'],
'newTicketAttachmentDescriptions.*' => ['nullable', 'string', 'max:255'],
], [
'newTicketTitolo.required' => 'Inserisci il titolo del ticket.',
'newTicketTitolo.max' => 'Il titolo puo contenere al massimo 255 caratteri.',
'newTicketDescrizione.required' => 'Inserisci una descrizione del problema.',
'newTicketLuogo.max' => 'Il luogo intervento puo contenere al massimo 255 caratteri.',
'newTicketPriorita.in' => 'Seleziona una priorita valida.',
'newTicketCategoriaId.exists' => 'La categoria selezionata non e valida.',
'newTicketTipoIntervento.max' => 'Il tipo intervento selezionato non e valido.',
'newTicketFotoNote.max' => 'La nota generale foto puo contenere al massimo 2000 caratteri.',
'newTicketCameraShots.max' => 'Puoi caricare al massimo 10 foto per volta.',
'newTicketCameraShots.*.file' => 'Ogni foto deve essere un file valido.',
'newTicketCameraShots.*.max' => 'Ogni foto deve pesare al massimo 10 MB.',
'newTicketCameraShots.*.mimes' => 'Le foto devono essere JPG, PNG o WEBP.',
'newTicketAttachments.max' => 'Puoi caricare al massimo 10 allegati per volta.',
'newTicketAttachments.*.file' => 'Ogni allegato deve essere un file valido.',
'newTicketAttachments.*.max' => 'Ogni allegato deve pesare al massimo 10 MB.',
'newTicketAttachments.*.mimes' => 'Gli allegati devono essere immagini, PDF o documenti Office/testo.',
'newTicketTitolo.required' => 'Inserisci il titolo del ticket.',
'newTicketTitolo.max' => 'Il titolo puo contenere al massimo 255 caratteri.',
'newTicketDescrizione.required' => 'Inserisci una descrizione del problema.',
'newTicketLuogo.max' => 'Il luogo intervento puo contenere al massimo 255 caratteri.',
'newTicketPriorita.in' => 'Seleziona una priorita valida.',
'newTicketCategoriaId.exists' => 'La categoria selezionata non e valida.',
'newTicketTipoIntervento.max' => 'Il tipo intervento selezionato non e valido.',
'newTicketFotoNote.max' => 'La nota generale foto puo contenere al massimo 2000 caratteri.',
'newTicketCameraShots.max' => 'Puoi caricare al massimo 10 foto per volta.',
'newTicketCameraShots.*.file' => 'Ogni foto deve essere un file valido.',
'newTicketCameraShots.*.max' => 'Ogni foto deve pesare al massimo 10 MB.',
'newTicketCameraShots.*.mimes' => 'Le foto devono essere JPG, PNG o WEBP.',
'newTicketAttachments.max' => 'Puoi caricare al massimo 10 allegati per volta.',
'newTicketAttachments.*.file' => 'Ogni allegato deve essere un file valido.',
'newTicketAttachments.*.max' => 'Ogni allegato deve pesare al massimo 10 MB.',
'newTicketAttachments.*.mimes' => 'Gli allegati devono essere immagini, PDF o documenti Office/testo.',
'newTicketAttachmentDescriptions.*.max' => 'Ogni descrizione allegato puo contenere al massimo 255 caratteri.',
], [
'newTicketTitolo' => 'titolo',
@ -595,27 +613,25 @@ public function creaTicketRapido(): void
->success()
->send();
$this->newTicketTitolo = null;
$this->newTicketDescrizione = null;
$this->newTicketLuogo = null;
$this->newTicketPriorita = 'Media';
$this->newTicketCategoriaId = null;
$this->newTicketTipoIntervento = 'altro';
$this->newTicketCameraShots = [];
$this->newTicketAttachments = [];
$this->newTicketAttachmentDescriptions = [];
$this->newTicketFotoNote = null;
$this->newTicketTitolo = null;
$this->newTicketDescrizione = null;
$this->newTicketLuogo = null;
$this->newTicketPriorita = 'Media';
$this->newTicketCategoriaId = null;
$this->newTicketTipoIntervento = 'altro';
$this->newTicketFotoNote = null;
$this->resetDraftUploadState();
$this->refreshData();
}
public function updatedNewTicketCameraShots(): void
public function updatedPendingTicketCameraShots(): void
{
$this->syncAttachmentDescriptionSlots();
$this->appendPendingUploads('camera');
}
public function updatedNewTicketAttachments(): void
public function updatedPendingTicketAttachments(): void
{
$this->syncAttachmentDescriptionSlots();
$this->appendPendingUploads('attachment');
}
public function removeNewTicketFile(int $index): void
@ -635,7 +651,7 @@ public function removeNewTicketFile(int $index): void
}
/**
* @return array<int, array{index:int,name:string,mime:string,is_image:bool,preview_url:?string,description:string}>
* @return array<int, array{index:int,name:string,original_name:string,protocol:string,mime:string,is_image:bool,preview_url:?string,description:string}>
*/
public function getSelectedUploadsProperty(): array
{
@ -647,9 +663,10 @@ public function getSelectedUploadsProperty(): array
continue;
}
$name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index));
$mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream');
$isImage = str_starts_with($mime, 'image/');
$name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index));
$mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream');
$isImage = str_starts_with($mime, 'image/');
$protocol = $this->buildDraftProtocolCode((int) $index, $isImage);
$previewUrl = null;
if ($isImage && method_exists($file, 'temporaryUrl')) {
@ -661,12 +678,14 @@ public function getSelectedUploadsProperty(): array
}
$rows[] = [
'index' => (int) $index,
'name' => $name,
'mime' => $mime,
'is_image' => $isImage,
'preview_url' => $previewUrl,
'description' => (string) ($this->newTicketAttachmentDescriptions[$index] ?? ''),
'index' => (int) $index,
'name' => $this->buildDraftUploadDisplayName((int) $index, $name, $isImage),
'original_name' => $name,
'protocol' => $protocol,
'mime' => $mime,
'is_image' => $isImage,
'preview_url' => $previewUrl,
'description' => (string) ($this->newTicketAttachmentDescriptions[$index] ?? ''),
];
}
@ -702,7 +721,11 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int
}
try {
$stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-mobile/' . $ticket->id);
$displayName = $this->buildStoredUploadDisplayName($ticket, (int) $index, $file);
$stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-mobile/' . $ticket->id, [
'stored_basename' => pathinfo($displayName, PATHINFO_FILENAME),
'display_name' => $displayName,
]);
TicketAttachment::query()->create([
'ticket_id' => (int) $ticket->id,
@ -712,7 +735,12 @@ private function salvaAllegatiTicket(Ticket $ticket, int $userId): int
'original_file_name' => $stored['original_name'],
'mime_type' => $stored['mime'],
'size' => $stored['size'],
'description' => $this->resolveAttachmentDescription((int) $index),
'description' => $this->resolveAttachmentDescription(
(int) $index,
is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [],
$stored['original_name'] ?? null,
$stored['source_original_name'] ?? null,
),
]);
$saved++;
@ -739,18 +767,152 @@ private function syncAttachmentDescriptionSlots(): void
$this->newTicketAttachmentDescriptions = $next;
}
private function resolveAttachmentDescription(int $index): string
private function resolveAttachmentDescription(int $index, array $metadata = [], ?string $displayName = null, ?string $sourceOriginalName = null): string
{
$parts = [];
$specific = trim((string) ($this->newTicketAttachmentDescriptions[$index] ?? ''));
if ($specific !== '') {
return $specific;
$parts[] = $specific;
} elseif (filled($this->newTicketFotoNote)) {
$parts[] = 'Nota foto: ' . trim((string) $this->newTicketFotoNote);
} else {
$parts[] = 'Allegato da Ticket Mobile';
}
if (filled($this->newTicketFotoNote)) {
return 'Nota foto: ' . trim((string) $this->newTicketFotoNote);
if (filled($displayName)) {
$parts[] = 'File: ' . trim((string) $displayName);
}
return 'Allegato da Ticket Mobile';
$sourceName = trim((string) $sourceOriginalName);
if ($sourceName !== '' && $sourceName !== trim((string) $displayName)) {
$parts[] = 'Orig: ' . $sourceName;
}
$exifDate = trim((string) ($metadata['exif_datetime'] ?? ''));
if ($exifDate !== '') {
$parts[] = 'EXIF: ' . $exifDate;
}
$device = trim((string) ($metadata['device'] ?? ''));
if ($device !== '') {
$parts[] = 'Device: ' . $device;
}
$lat = $metadata['gps_decimal']['lat'] ?? null;
$lng = $metadata['gps_decimal']['lng'] ?? null;
if (is_numeric($lat) && is_numeric($lng)) {
$parts[] = 'GPS: ' . number_format((float) $lat, 5, '.', '') . ',' . number_format((float) $lng, 5, '.', '');
$mapsUrl = trim((string) ($metadata['google_maps_url'] ?? ''));
if ($mapsUrl !== '') {
$parts[] = 'Maps: ' . $mapsUrl;
}
}
return Str::limit(implode(' | ', array_filter($parts)), 255);
}
private function appendPendingUploads(string $source): void
{
$pendingProperty = $source === 'camera' ? 'pendingTicketCameraShots' : 'pendingTicketAttachments';
$targetProperty = $source === 'camera' ? 'newTicketCameraShots' : 'newTicketAttachments';
$pending = array_values(array_filter($this->{$pendingProperty}, fn($file) => is_object($file)));
if ($pending === []) {
return;
}
$currentTotal = count($this->newTicketCameraShots) + count($this->newTicketAttachments);
$available = max(0, 10 - $currentTotal);
if ($available <= 0) {
$this->{$pendingProperty} = [];
Notification::make()
->title('Limite allegati raggiunto')
->body('Puoi tenere in coda al massimo 10 file totali per ticket.')
->warning()
->send();
return;
}
$accepted = array_slice($pending, 0, $available);
$this->{$targetProperty} = array_values(array_merge($this->{$targetProperty}, $accepted));
$this->{$pendingProperty} = [];
$this->syncAttachmentDescriptionSlots();
$discarded = count($pending) - count($accepted);
if ($discarded > 0) {
Notification::make()
->title('Coda allegati ridotta')
->body('Sono stati accodati solo i primi ' . count($accepted) . ' file disponibili: limite massimo 10.')
->warning()
->send();
}
}
private function resetDraftUploadState(): void
{
$this->newTicketCameraShots = [];
$this->newTicketAttachments = [];
$this->pendingTicketCameraShots = [];
$this->pendingTicketAttachments = [];
$this->newTicketAttachmentDescriptions = [];
$this->newTicketDraftProtocol = 'TM-' . now()->format('Ymd-His');
}
private function buildDraftProtocolCode(int $index, bool $isImage): string
{
return $this->newTicketDraftProtocol . '-' . ($isImage ? 'F' : 'A') . str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT);
}
private function buildDraftUploadDisplayName(int $index, string $originalName, bool $isImage): string
{
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
return $this->buildDraftProtocolCode($index, $isImage) . ($extension !== '' ? '.' . $extension : '');
}
private function buildStoredUploadDisplayName(Ticket $ticket, int $index, object $file): string
{
$originalName = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index));
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
$mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream');
$isImage = str_starts_with($mime, 'image/');
$baseName = 'ticket-' . str_pad((string) $ticket->id, 6, '0', STR_PAD_LEFT) . '-' . ($isImage ? 'foto-' : 'all-') . str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT);
return $baseName . ($extension !== '' ? '.' . $extension : '');
}
/**
* @return array<int, int>
*/
private function resolveTicketScopeStabileIds(bool $includeAllAccessible = false): array
{
$user = Auth::user();
if (! $user instanceof User) {
return [];
}
$accessibleIds = StabileContext::accessibleStabili($user)
->pluck('id')
->map(fn($value) => (int) $value)
->filter(fn(int $value) => $value > 0)
->values()
->all();
if ($accessibleIds === []) {
return [];
}
if ($includeAllAccessible) {
return $accessibleIds;
}
$activeId = StabileContext::resolveActiveStabileId($user);
return $activeId ? [$activeId] : [(int) $accessibleIds[0]];
}
private function prefillTicketFromLiveCall(string $phone, string $line): void
@ -822,14 +984,14 @@ private function aggiornaStatoTicket(int $ticketId, string $nuovoStato, bool $as
return;
}
$stabileId = StabileContext::resolveActiveStabileId($user);
if (! $stabileId) {
$stabileIds = $this->resolveTicketScopeStabileIds(true);
if ($stabileIds === []) {
return;
}
$ticket = Ticket::query()
->where('id', $ticketId)
->where('stabile_id', $stabileId)
->whereIn('stabile_id', $stabileIds)
->first();
if (! $ticket) {

View File

@ -1,7 +1,7 @@
<?php
namespace App\Livewire\Filament;
use App\Filament\Pages\Strumenti\PostItGestione;
use App\Models\ChiamataPostIt;
use App\Models\User;
use App\Services\Cti\LiveIncomingCallService;
@ -44,7 +44,7 @@ public function createPostIt(): void
return;
}
ChiamataPostIt::query()->create([
$postIt = ChiamataPostIt::query()->create([
'stabile_id' => (int) $stabileId,
'creato_da_user_id' => (int) $user->id,
'rubrica_id' => (int) ($this->liveIncomingCall['rubrica_id'] ?? 0) ?: null,
@ -61,6 +61,14 @@ public function createPostIt(): void
]);
Notification::make()->title('Post-it creato dalla testata')->success()->send();
$this->redirect(
PostItGestione::getUrl([
'tab' => 'storico',
'focus_post_it' => (int) $postIt->id,
], panel: 'admin-filament'),
navigate: true,
);
}
public function openTicketMobile()
@ -76,4 +84,4 @@ public function render()
{
return view('livewire.filament.topbar-live-call');
}
}
}

View File

@ -15,9 +15,12 @@
<button type="button" wire:click="$set('activeTab', 'storico')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'storico' ? 'bg-indigo-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
Storico Post-it
</button>
<button type="button" wire:click="$set('activeTab', 'tecnico')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'tecnico' ? 'bg-indigo-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
<button type="button" wire:click="$set('activeTab', 'smdr')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'smdr' ? 'bg-indigo-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
Chiamate Tecniche SMDR
</button>
<button type="button" wire:click="$set('activeTab', 'csta')" class="rounded-md px-3 py-1.5 text-xs font-medium {{ $activeTab === 'csta' ? 'bg-indigo-700 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }}">
CTI / CSTA e Click-to-Call
</button>
</div>
</div>
@ -32,10 +35,16 @@
<div class="space-y-3">
@forelse($this->recenti as $riga)
<div class="rounded-lg border p-3">
<div @class([
'rounded-lg border p-3',
'border-emerald-400 bg-emerald-50/60 ring-1 ring-emerald-200' => (int) ($this->focusPostItId ?? 0) === (int) $riga->id,
])>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="font-medium">{{ $riga->oggetto ?: 'Senza oggetto' }}</div>
@if((int) ($this->focusPostItId ?? 0) === (int) $riga->id)
<div class="mt-1 text-[11px] font-semibold uppercase tracking-wide text-emerald-700">Post-it appena creato</div>
@endif
<div class="text-sm text-gray-600">
{{ $riga->nome_chiamante ?: 'Chiamante non identificato' }}
@if($riga->telefono)
@ -93,10 +102,59 @@
@endforelse
</div>
</div>
@else
@elseif(in_array($activeTab, ['smdr', 'csta'], true))
<div class="rounded-xl border bg-white p-4">
<h2 class="mb-1 text-base font-semibold">Elenco tecnico chiamate (1 riga = 1 evento)</h2>
<p class="mb-3 text-xs text-gray-500">Qui vedi in colonne i dati disponibili dal centralino: interno, linea/trunk, numero, durata, costo, utente assegnato, stabile, raw line, ecc.</p>
@php($isCstaTab = $activeTab === 'csta')
<h2 class="mb-1 text-base font-semibold">{{ $isCstaTab ? 'Elenco tecnico CTI / CSTA' : 'Elenco tecnico chiamate SMDR' }}</h2>
<p class="mb-3 text-xs text-gray-500">
{{ $isCstaTab
? 'Vista test PBX per eventi CSTA, provider, interno coinvolto e richieste click-to-call. Il provider viene letto dai metadati così il cruscotto resta riusabile anche con FreePBX o altri centralini.'
: 'Qui vedi in colonne i dati disponibili dal centralino via SMDR: interno, linea/trunk, numero, durata, costo, utente assegnato, stabile e raw line.' }}
</p>
@if($isCstaTab)
<div class="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3 text-xs text-blue-900">
<div class="font-semibold">Controllo chiamate PBX</div>
<div class="mt-1">Da questo tab puoi verificare gli eventi CSTA e registrare richieste click-to-call verso il PBX dell'interno assegnato. La UI non e vincolata a Panasonic: il provider e esposto come metadato.</div>
</div>
<div class="mb-4 rounded-lg border border-slate-200 bg-slate-50 p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Ultime richieste click-to-call</div>
<div class="text-[11px] text-slate-500">Fonte: coda locale PBX</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full border text-[11px]">
<thead class="bg-white">
<tr>
<th class="border px-2 py-1 text-left">Richiesta</th>
<th class="border px-2 py-1 text-left">Interno</th>
<th class="border px-2 py-1 text-left">Numero</th>
<th class="border px-2 py-1 text-left">Stato</th>
<th class="border px-2 py-1 text-left">Provider</th>
<th class="border px-2 py-1 text-left">Quando</th>
</tr>
</thead>
<tbody>
@forelse($this->recentClickToCallRequests as $req)
<tr>
<td class="border px-2 py-1">#{{ $req->id }}</td>
<td class="border px-2 py-1">{{ $req->source_extension ?: '-' }}</td>
<td class="border px-2 py-1">{{ $req->target_number ?: '-' }}</td>
<td class="border px-2 py-1">{{ $req->status ?: '-' }}</td>
<td class="border px-2 py-1">{{ strtoupper(str_replace('_', ' ', (string) data_get($req->metadata, 'provider', data_get($req->metadata, 'source_channel', '-')))) }}</td>
<td class="border px-2 py-1">{{ optional($req->requested_at)->format('d/m/Y H:i:s') ?: optional($req->created_at)->format('d/m/Y H:i:s') }}</td>
</tr>
@empty
<tr>
<td colspan="6" class="border px-2 py-3 text-center text-slate-500">Nessuna richiesta click-to-call registrata per il contesto utente corrente.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
<div class="mb-4 grid grid-cols-1 gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3 md:grid-cols-4">
<div>
@ -137,10 +195,11 @@
<tr>
<th class="border px-2 py-1 text-left">ID</th>
<th class="border px-2 py-1 text-left">Data/Ora</th>
<th class="border px-2 py-1 text-left">Provider</th>
<th class="border px-2 py-1 text-left">Direzione</th>
<th class="border px-2 py-1 text-left">Ambito</th>
<th class="border px-2 py-1 text-left">Interno</th>
<th class="border px-2 py-1 text-left">Linea/CO</th>
<th class="border px-2 py-1 text-left">Linea / Evento</th>
<th class="border px-2 py-1 text-left">Numero</th>
<th class="border px-2 py-1 text-left">Nominativo</th>
<th class="border px-2 py-1 text-left">Durata</th>
@ -162,10 +221,11 @@
<tr>
<td class="border px-2 py-1">{{ $m->id }}</td>
<td class="border px-2 py-1">{{ optional($m->received_at)->format('d/m/Y H:i:s') }}</td>
<td class="border px-2 py-1">{{ $this->getTecnicoProviderLabel($m) }}</td>
<td class="border px-2 py-1">{{ $m->direction }}</td>
<td class="border px-2 py-1">{{ $this->getTecnicoScopeLabel($m) }}</td>
<td class="border px-2 py-1">{{ $this->getTecnicoInternoLabel($m) }}</td>
<td class="border px-2 py-1">{{ (string) ($smdr['co'] ?? '-') }}</td>
<td class="border px-2 py-1">{{ $this->getTecnicoLineaLabel($m) }}</td>
<td class="border px-2 py-1">{{ (string) ($m->phone_number ?: ($smdr['dial_number'] ?? '-')) }}</td>
<td class="border px-2 py-1">{{ $rubricaNomeTech ?: '-' }}</td>
<td class="border px-2 py-1">{{ $duration !== '' ? $duration : '-' }}</td>
@ -179,6 +239,9 @@
@if($rubricaUrlTech && ! $this->isInternalSmdrMessage($m))
<a href="{{ $rubricaUrlTech }}" class="inline-flex items-center rounded-md border px-2 py-1 text-[11px] text-indigo-700 hover:bg-indigo-50">Apri Rubrica</a>
@endif
@if($activeTab === 'csta')
<x-filament::button size="xs" color="info" wire:click="richiediClickToCallDaMessaggio({{ (int) $m->id }})">Click-to-call</x-filament::button>
@endif
@if(!$m->post_it_id)
<x-filament::button size="xs" wire:click="creaPostItDaMessaggio({{ (int) $m->id }})">Crea Post-it</x-filament::button>
@else
@ -188,7 +251,7 @@
</tr>
@empty
<tr>
<td colspan="15" class="border px-2 py-3 text-center text-gray-500">Nessun evento SMDR disponibile.</td>
<td colspan="16" class="border px-2 py-3 text-center text-gray-500">{{ $isCstaTab ? 'Nessun evento CSTA disponibile.' : 'Nessun evento SMDR disponibile.' }}</td>
</tr>
@endforelse
</tbody>