Add supplier workflow memory and provider search fixes

This commit is contained in:
michele 2026-04-12 18:40:46 +00:00
parent c3efa1754b
commit baaadd660b
10 changed files with 1073 additions and 7 deletions

View File

@ -34,13 +34,23 @@ public function handle(TecnoRepairMdbReader $reader, FornitoreProductCatalogServ
return self::FAILURE; return self::FAILURE;
} }
$mdbPath = realpath((string) $this->option('mdb')) ?: (string) $this->option('mdb'); $rawMdbPath = trim((string) $this->option('mdb'));
$mdbPath = $rawMdbPath !== '' && file_exists($rawMdbPath)
? (realpath($rawMdbPath) ?: $rawMdbPath)
: $rawMdbPath;
if ($mdbPath === '' || ! is_file($mdbPath)) { if ($mdbPath === '' || ! is_file($mdbPath)) {
$this->error('Archivio MDB non trovato: ' . $mdbPath); $this->error('Archivio MDB non trovato: ' . $mdbPath);
return self::FAILURE; return self::FAILURE;
} }
if (! is_readable($mdbPath)) {
$this->error('Archivio MDB non leggibile: ' . $mdbPath);
return self::FAILURE;
}
$fornitore = $this->resolveFornitore($admin, (string) $this->option('fornitore-id'), (string) $this->option('fornitore-term')); $fornitore = $this->resolveFornitore($admin, (string) $this->option('fornitore-id'), (string) $this->option('fornitore-term'));
$limit = max(0, (int) $this->option('limit')); $limit = max(0, (int) $this->option('limit'));
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');

View File

@ -4,12 +4,15 @@
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext; use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Filament\Pages\Gescon\FornitoreScheda; use App\Filament\Pages\Gescon\FornitoreScheda;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\FornitoreStabileImpostazione;
use App\Models\Stabile;
use App\Models\User; use App\Models\User;
use App\Support\ArchivioPaths; use App\Support\ArchivioPaths;
use BackedEnum; use BackedEnum;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
@ -53,6 +56,19 @@ class ImpostazioniArchivio extends Page
/** @var array<string, mixed>|null */ /** @var array<string, mixed>|null */
public ?array $googleWorkspaceStatus = null; public ?array $googleWorkspaceStatus = null;
public bool $operationalConfigAvailable = false;
/** @var array<int, string> */
public array $stabileOptions = [];
/** @var array<int, array<string, mixed>> */
public array $stabileAccessRows = [];
public string $dispatchBoardNote = '';
/** @var array<int, array<string, mixed>> */
public array $preferredMaterialRows = [];
public static function canAccess(): bool public static function canAccess(): bool
{ {
$user = auth()->user(); $user = auth()->user();
@ -167,6 +183,8 @@ public function getProdottiUrl(): string
public function getGoogleConnectUrl(): string public function getGoogleConnectUrl(): string
{ {
return route('oauth.google.redirect', [ return route('oauth.google.redirect', [
'account_key' => 'fornitore-' . (int) ($this->fornitore?->id ?? 0),
'label' => trim((string) ($this->fornitore?->ragione_sociale ?? '')) ?: ('Fornitore #' . (int) ($this->fornitore?->id ?? 0)),
'return_to' => request()->getRequestUri(), 'return_to' => request()->getRequestUri(),
]); ]);
} }
@ -174,14 +192,161 @@ public function getGoogleConnectUrl(): string
public function getGoogleDisconnectUrl(): string public function getGoogleDisconnectUrl(): string
{ {
return route('oauth.google.disconnect', [ return route('oauth.google.disconnect', [
'account_key' => 'fornitore-' . (int) ($this->fornitore?->id ?? 0),
'return_to' => request()->getRequestUri(), 'return_to' => request()->getRequestUri(),
]); ]);
} }
public function addStabileAccessRow(): void
{
$unusedStableId = null;
$usedIds = collect($this->stabileAccessRows)
->pluck('stabile_id')
->filter(fn($value): bool => (int) $value > 0)
->map(fn($value): int => (int) $value)
->all();
foreach (array_keys($this->stabileOptions) as $stabileId) {
if (! in_array((int) $stabileId, $usedIds, true)) {
$unusedStableId = (int) $stabileId;
break;
}
}
$this->stabileAccessRows[] = [
'stabile_id' => $unusedStableId,
'chi_suonare' => '',
'contatto_nome' => '',
'contatto_telefono' => '',
'dove_sono_chiavi' => '',
'istruzioni_accesso' => '',
'fascia_reperibilita' => '',
'note_urgenza' => '',
'is_priority' => false,
];
}
public function removeStabileAccessRow(int $index): void
{
unset($this->stabileAccessRows[$index]);
$this->stabileAccessRows = array_values($this->stabileAccessRows);
}
public function addPreferredMaterialRow(): void
{
$this->preferredMaterialRows[] = [
'label' => '',
'brand_model' => '',
'barcode' => '',
'internal_code' => '',
'preferred_source' => '',
'preferred_url' => '',
'default_qty' => 1,
'last_price' => null,
'currency' => 'EUR',
'note' => '',
];
}
public function removePreferredMaterialRow(int $index): void
{
unset($this->preferredMaterialRows[$index]);
$this->preferredMaterialRows = array_values($this->preferredMaterialRows);
}
public function saveOperationalWorkflow(): void
{
if (! $this->fornitore instanceof Fornitore) {
return;
}
$normalizedAccessRows = collect($this->stabileAccessRows)
->map(fn(array $row): ?array => $this->normalizeStabileAccessRow($row))
->filter()
->values()
->all();
$selectedStableIds = collect($normalizedAccessRows)
->pluck('stabile_id')
->map(fn($value): int => (int) $value)
->all();
$existingSettings = FornitoreStabileImpostazione::query()
->where('fornitore_id', (int) $this->fornitore->id)
->get()
->keyBy(fn(FornitoreStabileImpostazione $setting): int => (int) $setting->stabile_id);
foreach ($normalizedAccessRows as $row) {
$setting = $existingSettings->get((int) $row['stabile_id']) ?? new FornitoreStabileImpostazione([
'fornitore_id' => (int) $this->fornitore->id,
'stabile_id' => (int) $row['stabile_id'],
]);
$meta = is_array($setting->meta ?? null) ? $setting->meta : [];
$meta['access_operativo'] = $row;
$setting->fornitore_id = (int) $this->fornitore->id;
$setting->stabile_id = (int) $row['stabile_id'];
$setting->meta = $meta;
$setting->save();
}
foreach ($existingSettings as $stabileId => $setting) {
if (in_array((int) $stabileId, $selectedStableIds, true)) {
continue;
}
$meta = is_array($setting->meta ?? null) ? $setting->meta : [];
if (! array_key_exists('access_operativo', $meta)) {
continue;
}
unset($meta['access_operativo']);
if ($meta === [] && ! $setting->voce_spesa_default_id && ! $setting->conto_costo_default_id) {
$setting->delete();
continue;
}
$setting->meta = $meta;
$setting->save();
}
if ($this->operationalConfigAvailable) {
$config = $this->fornitore->operational_config_safe;
$dispatchBoardNote = trim($this->dispatchBoardNote);
if ($dispatchBoardNote === '') {
unset($config['dispatch_board_note']);
} else {
$config['dispatch_board_note'] = $dispatchBoardNote;
}
$config['preferred_materials'] = collect($this->preferredMaterialRows)
->map(fn(array $row): ?array => $this->normalizePreferredMaterialRow($row))
->filter()
->values()
->all();
$this->fornitore->operational_config = $config;
$this->fornitore->save();
}
$this->refreshArchiveSummary();
Notification::make()
->title('Workflow operativo aggiornato')
->body($this->operationalConfigAvailable
? 'Chiavi, reperibilità e memoria materiali sono stati salvati.'
: 'Chiavi e reperibilità per stabile salvate. Esegui la migrazione per attivare anche i preferiti materiali.')
->success()
->send();
}
private function refreshArchiveSummary(): void private function refreshArchiveSummary(): void
{ {
$relativeBase = $this->getArchiveRelativeBase(); $relativeBase = $this->getArchiveRelativeBase();
$absoluteBase = $relativeBase ? storage_path('app/' . $relativeBase) : null; $absoluteBase = $relativeBase ? storage_path('app/' . $relativeBase) : null;
$this->operationalConfigAvailable = Schema::hasColumn('fornitori', 'operational_config');
if ($relativeBase !== null) { if ($relativeBase !== null) {
foreach ([ foreach ([
@ -222,16 +387,154 @@ private function refreshArchiveSummary(): void
$stats = $this->fornitore?->tecnorepair_stats ?? ['total' => 0, 'open' => 0, 'closed' => 0, 'serials' => 0]; $stats = $this->fornitore?->tecnorepair_stats ?? ['total' => 0, 'open' => 0, 'closed' => 0, 'serials' => 0];
$this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus(); $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus();
$this->loadOperationalWorkflowSettings();
$this->moduleRows = [ $this->moduleRows = [
['module' => 'Rubrica fornitore', 'status' => 'pronto', 'note' => 'Vista fornitore filtrata ma agganciata all\'anagrafica unica.'], ['module' => 'Rubrica fornitore', 'status' => 'pronto', 'note' => 'Vista fornitore filtrata ma agganciata all\'anagrafica unica.'],
['module' => 'Google Workspace', 'status' => data_get($this->googleWorkspaceStatus, 'connected', false) ? 'collegato' : 'da collegare', 'note' => 'Rubrica e sincronizzazioni smartphone riusano l\'account Google del contesto amministratore.'], ['module' => 'Google Workspace', 'status' => data_get($this->googleWorkspaceStatus, 'connected', false) ? 'collegato' : 'da collegare', 'note' => 'Rubrica e sincronizzazioni smartphone riusano l\'account Google del contesto amministratore.'],
['module' => 'TecnoRepair MDB', 'status' => ((int) ($stats['total'] ?? 0) > 0) ? 'importato' : 'da importare', 'note' => 'Schede: ' . (int) ($stats['total'] ?? 0) . ' · aperte: ' . (int) ($stats['open'] ?? 0) . ' · chiuse: ' . (int) ($stats['closed'] ?? 0)], ['module' => 'TecnoRepair MDB', 'status' => ((int) ($stats['total'] ?? 0) > 0) ? 'importato' : 'da importare', 'note' => 'Schede: ' . (int) ($stats['total'] ?? 0) . ' · aperte: ' . (int) ($stats['open'] ?? 0) . ' · chiuse: ' . (int) ($stats['closed'] ?? 0)],
['module' => 'Catalogo fornitore', 'status' => 'attivo', 'note' => 'Il catalogo vero resta nella pagina Prodotti.'], ['module' => 'Catalogo fornitore', 'status' => 'attivo', 'note' => 'Il catalogo vero resta nella pagina Prodotti.'],
['module' => 'Chiavi e reperibilità', 'status' => count($this->stabileAccessRows) > 0 ? 'configurato' : 'da configurare', 'note' => 'Istruzioni per accesso, chi suonare e dove recuperare chiavi per ciascuno stabile.'],
['module' => 'Memoria materiali', 'status' => count($this->preferredMaterialRows) > 0 ? 'attiva' : ($this->operationalConfigAvailable ? 'da compilare' : 'migrazione richiesta'), 'note' => 'Preferiti ricorrenti per lampade, ricambi e prodotti ripetuti dal fornitore.'],
['module' => 'Comunicazioni / Post-it', 'status' => 'predisposto', 'note' => 'Cartelle e contesto archivio pronti per agganciare chiamate, post-it ed email.'], ['module' => 'Comunicazioni / Post-it', 'status' => 'predisposto', 'note' => 'Cartelle e contesto archivio pronti per agganciare chiamate, post-it ed email.'],
['module' => 'FE e documenti', 'status' => 'predisposto', 'note' => 'Archivio separato per FE, documenti e flussi contabili del fornitore.'], ['module' => 'FE e documenti', 'status' => 'predisposto', 'note' => 'Archivio separato per FE, documenti e flussi contabili del fornitore.'],
]; ];
} }
private function loadOperationalWorkflowSettings(): void
{
if (! $this->fornitore instanceof Fornitore) {
$this->stabileOptions = [];
$this->stabileAccessRows = [];
$this->dispatchBoardNote = '';
$this->preferredMaterialRows = [];
return;
}
$adminId = (int) ($this->fornitore->amministratore_id ?? 0);
$stabili = Stabile::query()
->when($adminId > 0, fn($query) => $query->where('amministratore_id', $adminId))
->when(Schema::hasColumn('stabili', 'attivo'), fn($query) => $query->where('attivo', true))
->orderBy('codice_stabile')
->orderBy('denominazione')
->get(['id', 'codice_stabile', 'denominazione']);
$this->stabileOptions = $stabili
->mapWithKeys(fn(Stabile $stabile): array => [
(int) $stabile->id => trim((string) ($stabile->codice_stabile ?: ('#' . $stabile->id)) . ' - ' . (string) ($stabile->denominazione ?: 'Stabile')),
])
->all();
$accessRows = FornitoreStabileImpostazione::query()
->with('stabile:id,codice_stabile,denominazione')
->where('fornitore_id', (int) $this->fornitore->id)
->when($stabili->isNotEmpty(), fn($query) => $query->whereIn('stabile_id', $stabili->pluck('id')->all()))
->get()
->map(function (FornitoreStabileImpostazione $setting): ?array {
$meta = is_array($setting->meta ?? null) ? $setting->meta : [];
$access = is_array($meta['access_operativo'] ?? null) ? $meta['access_operativo'] : null;
if (! is_array($access)) {
return null;
}
return [
'stabile_id' => (int) ($setting->stabile_id ?? 0),
'chi_suonare' => trim((string) ($access['chi_suonare'] ?? '')),
'contatto_nome' => trim((string) ($access['contatto_nome'] ?? '')),
'contatto_telefono' => trim((string) ($access['contatto_telefono'] ?? '')),
'dove_sono_chiavi' => trim((string) ($access['dove_sono_chiavi'] ?? '')),
'istruzioni_accesso' => trim((string) ($access['istruzioni_accesso'] ?? '')),
'fascia_reperibilita' => trim((string) ($access['fascia_reperibilita'] ?? '')),
'note_urgenza' => trim((string) ($access['note_urgenza'] ?? '')),
'is_priority' => (bool) ($access['is_priority'] ?? false),
];
})
->filter()
->sortByDesc(fn(array $row): int => $row['is_priority'] ? 1 : 0)
->values()
->all();
$this->stabileAccessRows = $accessRows;
$config = $this->operationalConfigAvailable ? $this->fornitore->operational_config_safe : [];
$this->dispatchBoardNote = trim((string) ($config['dispatch_board_note'] ?? ''));
$this->preferredMaterialRows = collect((array) ($config['preferred_materials'] ?? []))
->map(function ($row): ?array {
if (! is_array($row)) {
return null;
}
return [
'label' => trim((string) ($row['label'] ?? '')),
'brand_model' => trim((string) ($row['brand_model'] ?? '')),
'barcode' => trim((string) ($row['barcode'] ?? '')),
'internal_code' => trim((string) ($row['internal_code'] ?? '')),
'preferred_source' => trim((string) ($row['preferred_source'] ?? '')),
'preferred_url' => trim((string) ($row['preferred_url'] ?? '')),
'default_qty' => isset($row['default_qty']) && is_numeric($row['default_qty']) ? (float) $row['default_qty'] : 1,
'last_price' => isset($row['last_price']) && is_numeric($row['last_price']) ? (float) $row['last_price'] : null,
'currency' => trim((string) ($row['currency'] ?? 'EUR')) ?: 'EUR',
'note' => trim((string) ($row['note'] ?? '')),
];
})
->filter()
->values()
->all();
}
private function normalizeStabileAccessRow(array $row): ?array
{
$stabileId = (int) ($row['stabile_id'] ?? 0);
if ($stabileId <= 0) {
return null;
}
$normalized = [
'stabile_id' => $stabileId,
'chi_suonare' => trim((string) ($row['chi_suonare'] ?? '')),
'contatto_nome' => trim((string) ($row['contatto_nome'] ?? '')),
'contatto_telefono' => trim((string) ($row['contatto_telefono'] ?? '')),
'dove_sono_chiavi' => trim((string) ($row['dove_sono_chiavi'] ?? '')),
'istruzioni_accesso' => trim((string) ($row['istruzioni_accesso'] ?? '')),
'fascia_reperibilita' => trim((string) ($row['fascia_reperibilita'] ?? '')),
'note_urgenza' => trim((string) ($row['note_urgenza'] ?? '')),
'is_priority' => (bool) ($row['is_priority'] ?? false),
];
$hasContent = collect($normalized)
->except(['stabile_id', 'is_priority'])
->contains(fn($value): bool => is_string($value) && $value !== '');
return $hasContent ? $normalized : null;
}
private function normalizePreferredMaterialRow(array $row): ?array
{
$label = trim((string) ($row['label'] ?? ''));
$brandModel = trim((string) ($row['brand_model'] ?? ''));
$barcode = trim((string) ($row['barcode'] ?? ''));
$internalCode = trim((string) ($row['internal_code'] ?? ''));
if ($label === '' && $brandModel === '' && $barcode === '' && $internalCode === '') {
return null;
}
return [
'label' => $label,
'brand_model' => $brandModel,
'barcode' => $barcode,
'internal_code' => $internalCode,
'preferred_source' => trim((string) ($row['preferred_source'] ?? '')),
'preferred_url' => trim((string) ($row['preferred_url'] ?? '')),
'default_qty' => isset($row['default_qty']) && is_numeric($row['default_qty']) ? max(0.01, round((float) $row['default_qty'], 2)) : 1.0,
'last_price' => isset($row['last_price']) && is_numeric($row['last_price']) ? round((float) $row['last_price'], 2) : null,
'currency' => trim((string) ($row['currency'] ?? 'EUR')) ?: 'EUR',
'note' => trim((string) ($row['note'] ?? '')),
];
}
/** /**
* @return array<string, mixed>|null * @return array<string, mixed>|null
*/ */

View File

@ -147,6 +147,8 @@ public function getProdottiUrl(): string
public function getGoogleConnectUrl(): string public function getGoogleConnectUrl(): string
{ {
return route('oauth.google.redirect', [ return route('oauth.google.redirect', [
'account_key' => 'fornitore-' . (int) ($this->fornitoreId ?? 0),
'label' => trim((string) ($this->fornitoreLabel ?? '')) ?: ('Fornitore #' . (int) ($this->fornitoreId ?? 0)),
'return_to' => request()->getRequestUri(), 'return_to' => request()->getRequestUri(),
]); ]);
} }
@ -154,6 +156,7 @@ public function getGoogleConnectUrl(): string
public function getGoogleDisconnectUrl(): string public function getGoogleDisconnectUrl(): string
{ {
return route('oauth.google.disconnect', [ return route('oauth.google.disconnect', [
'account_key' => 'fornitore-' . (int) ($this->fornitoreId ?? 0),
'return_to' => request()->getRequestUri(), 'return_to' => request()->getRequestUri(),
]); ]);
} }

View File

@ -8,6 +8,7 @@
use App\Filament\Pages\Gescon\StabileScheda; use App\Filament\Pages\Gescon\StabileScheda;
use App\Models\Fornitore; use App\Models\Fornitore;
use App\Models\FornitoreDipendente; use App\Models\FornitoreDipendente;
use App\Models\FornitoreStabileImpostazione;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductIdentifier; use App\Models\ProductIdentifier;
use App\Models\ProductOffer; use App\Models\ProductOffer;
@ -86,6 +87,12 @@ class TicketInterventoScheda extends Page
/** @var array<int, array<string, mixed>> */ /** @var array<int, array<string, mixed>> */
public array $materialSuggestions = []; public array $materialSuggestions = [];
/** @var array<int, array<string, mixed>> */
public array $preferredMaterialRows = [];
/** @var array<int, array<string, mixed>> */
public array $historicalMaterialRows = [];
public string $apparatoMarca = ''; public string $apparatoMarca = '';
public string $apparatoModello = ''; public string $apparatoModello = '';
@ -124,6 +131,13 @@ class TicketInterventoScheda extends Page
/** @var array<string, mixed>|null */ /** @var array<string, mixed>|null */
public ?array $googleWorkspaceStatus = null; public ?array $googleWorkspaceStatus = null;
/** @var array<int, array<string, mixed>> */
public array $accessSummaryRows = [];
public string $dispatchBoardNote = '';
public bool $operationalConfigAvailable = false;
public bool $sessionTrackingAvailable = false; public bool $sessionTrackingAvailable = false;
public static function canAccess(): bool public static function canAccess(): bool
@ -439,6 +453,66 @@ public function removeMateriale(int $index): void
$this->materialiUtilizzati = array_values($this->materialiUtilizzati); $this->materialiUtilizzati = array_values($this->materialiUtilizzati);
} }
public function addPreferredMaterial(int $index): void
{
$row = $this->preferredMaterialRows[$index] ?? null;
if (! is_array($row)) {
return;
}
$this->appendMaterialFromMemory($row, 'Materiale preferito aggiunto');
}
public function addHistoricalMaterial(int $index): void
{
$row = $this->historicalMaterialRows[$index] ?? null;
if (! is_array($row)) {
return;
}
$this->appendMaterialFromMemory($row, 'Materiale storico aggiunto');
}
public function saveCurrentMaterialAsPreferred(int $index): void
{
if (! $this->fornitore instanceof Fornitore) {
return;
}
if (! Schema::hasColumn('fornitori', 'operational_config')) {
Notification::make()->title('Migrazione richiesta')->body('Esegui la migrazione che aggiunge operational_config ai fornitori per attivare i preferiti materiali.')->warning()->send();
return;
}
$current = $this->materialiUtilizzati[$index] ?? null;
if (! is_array($current)) {
return;
}
$normalized = $this->normalizePreferredMaterialConfig([
'label' => (string) ($current['descrizione'] ?? ''),
'brand_model' => '',
'barcode' => (string) ($current['barcode'] ?? ''),
'internal_code' => '',
'preferred_source' => (string) ($current['source_label'] ?? ''),
'preferred_url' => (string) ($current['external_url'] ?? ''),
'default_qty' => isset($current['qty']) ? (float) $current['qty'] : 1,
'last_price' => isset($current['unit_price']) ? (float) $current['unit_price'] : null,
'currency' => (string) ($current['currency'] ?? 'EUR'),
'note' => 'Salvato dalla scheda intervento #' . (int) $this->intervento->ticket_id,
]);
if ($normalized === null) {
Notification::make()->title('Materiale non valido')->warning()->send();
return;
}
$this->persistPreferredMaterial($normalized);
$this->loadMaterialMemory();
Notification::make()->title('Materiale salvato nei preferiti')->body((string) ($normalized['label'] ?? 'Materiale'))->success()->send();
}
public function saveRapporto(): void public function saveRapporto(): void
{ {
$validated = $this->validate([ $validated = $this->validate([
@ -708,6 +782,8 @@ public function getProdottiUrl(): string
public function getGoogleConnectUrl(): string public function getGoogleConnectUrl(): string
{ {
return route('oauth.google.redirect', [ return route('oauth.google.redirect', [
'account_key' => 'fornitore-' . (int) $this->fornitore->id,
'label' => trim((string) ($this->fornitore->ragione_sociale ?? '')) ?: ('Fornitore #' . (int) $this->fornitore->id),
'return_to' => request()->getRequestUri(), 'return_to' => request()->getRequestUri(),
]); ]);
} }
@ -715,6 +791,7 @@ public function getGoogleConnectUrl(): string
public function getGoogleDisconnectUrl(): string public function getGoogleDisconnectUrl(): string
{ {
return route('oauth.google.disconnect', [ return route('oauth.google.disconnect', [
'account_key' => 'fornitore-' . (int) $this->fornitore->id,
'return_to' => request()->getRequestUri(), 'return_to' => request()->getRequestUri(),
]); ]);
} }
@ -815,6 +892,7 @@ protected function buildStoredUploadDisplayName(int $index, bool $isImage, strin
protected function reloadIntervento(): void protected function reloadIntervento(): void
{ {
$this->sessionTrackingAvailable = Schema::hasTable('ticket_intervento_sessioni'); $this->sessionTrackingAvailable = Schema::hasTable('ticket_intervento_sessioni');
$this->operationalConfigAvailable = Schema::hasColumn('fornitori', 'operational_config');
$relations = [ $relations = [
'ticket.stabile', 'ticket.stabile',
@ -919,6 +997,8 @@ protected function reloadIntervento(): void
] ]
: null; : null;
$this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus(); $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus();
$this->loadAccessSummary();
$this->loadMaterialMemory();
$this->qrToken = ''; $this->qrToken = '';
} }
@ -1012,6 +1092,8 @@ protected function formatGeoPoint(mixed $lat, mixed $lng, mixed $accuracy): stri
protected function refreshMaterialSuggestions(): void protected function refreshMaterialSuggestions(): void
{ {
$term = trim($this->materialSearch); $term = trim($this->materialSearch);
$this->loadMaterialMemory();
if ($term === '') { if ($term === '') {
$this->materialSuggestions = []; $this->materialSuggestions = [];
return; return;
@ -1060,6 +1142,251 @@ protected function refreshMaterialSuggestions(): void
})->all(); })->all();
} }
protected function loadAccessSummary(): void
{
$this->accessSummaryRows = [];
$this->dispatchBoardNote = '';
if (! $this->fornitore instanceof Fornitore) {
return;
}
$config = $this->operationalConfigAvailable ? $this->fornitore->operational_config_safe : [];
$this->dispatchBoardNote = trim((string) ($config['dispatch_board_note'] ?? ''));
$stabileId = (int) ($this->intervento->ticket?->stabile_id ?? 0);
if ($stabileId <= 0) {
return;
}
$setting = FornitoreStabileImpostazione::query()
->with('stabile:id,codice_stabile,denominazione')
->where('fornitore_id', (int) $this->fornitore->id)
->where('stabile_id', $stabileId)
->first();
if (! $setting instanceof FornitoreStabileImpostazione) {
return;
}
$meta = is_array($setting->meta ?? null) ? $setting->meta : [];
$access = is_array($meta['access_operativo'] ?? null) ? $meta['access_operativo'] : null;
if (! is_array($access)) {
return;
}
$this->accessSummaryRows = [[
'stabile_label' => trim((string) (($setting->stabile?->codice_stabile ?: ('#' . $stabileId)) . ' - ' . ($setting->stabile?->denominazione ?: 'Stabile'))),
'chi_suonare' => trim((string) ($access['chi_suonare'] ?? '')),
'contatto_nome' => trim((string) ($access['contatto_nome'] ?? '')),
'contatto_telefono' => trim((string) ($access['contatto_telefono'] ?? '')),
'dove_sono_chiavi' => trim((string) ($access['dove_sono_chiavi'] ?? '')),
'istruzioni_accesso' => trim((string) ($access['istruzioni_accesso'] ?? '')),
'fascia_reperibilita' => trim((string) ($access['fascia_reperibilita'] ?? '')),
'note_urgenza' => trim((string) ($access['note_urgenza'] ?? '')),
'is_priority' => (bool) ($access['is_priority'] ?? false),
]];
}
protected function loadMaterialMemory(): void
{
$term = trim($this->materialSearch);
$normalized = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper($term)) ?? '';
$preferredRows = $this->operationalConfigAvailable
? collect((array) ($this->fornitore->operational_config_safe['preferred_materials'] ?? []))
: collect();
$this->preferredMaterialRows = $preferredRows
->map(fn($row): ?array => is_array($row) ? $this->normalizePreferredMaterialConfig($row) : null)
->filter()
->filter(fn(array $row): bool => $this->matchesMaterialSearch($row, $term, $normalized))
->values()
->all();
$recentInterventi = TicketIntervento::query()
->where('fornitore_id', (int) $this->fornitore->id)
->where('id', '!=', (int) $this->intervento->id)
->whereNotNull('materiali_utilizzati')
->latest('updated_at')
->limit(80)
->get(['id', 'ticket_id', 'materiali_utilizzati', 'updated_at']);
$aggregated = [];
foreach ($recentInterventi as $intervento) {
foreach ((array) ($intervento->materiali_utilizzati ?? []) as $item) {
if (! is_array($item)) {
continue;
}
$memory = $this->normalizePreferredMaterialConfig([
'label' => (string) ($item['descrizione'] ?? ''),
'brand_model' => '',
'barcode' => (string) ($item['barcode'] ?? ''),
'internal_code' => '',
'preferred_source' => (string) ($item['source_label'] ?? ''),
'preferred_url' => (string) ($item['external_url'] ?? ''),
'default_qty' => isset($item['qty']) ? (float) $item['qty'] : 1,
'last_price' => isset($item['unit_price']) ? (float) $item['unit_price'] : null,
'currency' => (string) ($item['currency'] ?? 'EUR'),
'note' => '',
]);
if ($memory === null || ! $this->matchesMaterialSearch($memory, $term, $normalized)) {
continue;
}
$key = $this->buildMaterialMemoryKey($memory);
if (! isset($aggregated[$key])) {
$aggregated[$key] = array_merge($memory, [
'usage_count' => 0,
'last_used_at' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-',
'ticket_id' => (int) $intervento->ticket_id,
]);
}
$aggregated[$key]['usage_count']++;
}
}
$preferredKeys = collect($this->preferredMaterialRows)
->map(fn(array $row): string => $this->buildMaterialMemoryKey($row))
->all();
$this->historicalMaterialRows = collect($aggregated)
->reject(fn(array $row, string $key): bool => in_array($key, $preferredKeys, true))
->sortByDesc(fn(array $row): int => (int) ($row['usage_count'] ?? 0))
->take(8)
->values()
->all();
}
protected function appendMaterialFromMemory(array $row, string $notificationTitle): void
{
$material = [
'product_id' => isset($row['product_id']) && is_numeric($row['product_id']) ? (int) $row['product_id'] : null,
'descrizione' => trim((string) ($row['label'] ?? '')),
'barcode' => trim((string) ($row['barcode'] ?? '')),
'qty' => isset($row['default_qty']) && is_numeric($row['default_qty']) ? (float) $row['default_qty'] : 1,
'unit_price' => isset($row['last_price']) && is_numeric($row['last_price']) ? round((float) $row['last_price'], 2) : null,
'currency' => trim((string) ($row['currency'] ?? 'EUR')) ?: 'EUR',
'source_type' => 'memory',
'source_label' => trim((string) ($row['preferred_source'] ?? 'Memoria fornitore')) ?: 'Memoria fornitore',
'external_url' => trim((string) ($row['preferred_url'] ?? '')),
];
if ($material['descrizione'] === '') {
return;
}
$this->materialiUtilizzati[] = $material;
Notification::make()
->title($notificationTitle)
->body($material['descrizione'])
->success()
->send();
}
protected function persistPreferredMaterial(array $row): void
{
if (! $this->fornitore instanceof Fornitore || ! Schema::hasColumn('fornitori', 'operational_config')) {
return;
}
$config = $this->fornitore->operational_config_safe;
$rows = collect((array) ($config['preferred_materials'] ?? []))
->map(fn($item): ?array => is_array($item) ? $this->normalizePreferredMaterialConfig($item) : null)
->filter()
->values();
$key = $this->buildMaterialMemoryKey($row);
$updatedRows = $rows
->reject(fn(array $item): bool => $this->buildMaterialMemoryKey($item) === $key)
->prepend($row)
->take(20)
->values()
->all();
$config['preferred_materials'] = $updatedRows;
$this->fornitore->operational_config = $config;
$this->fornitore->save();
$this->fornitore->refresh();
}
protected function normalizePreferredMaterialConfig(array $row): ?array
{
$label = trim((string) ($row['label'] ?? ''));
$brandModel = trim((string) ($row['brand_model'] ?? ''));
$barcode = trim((string) ($row['barcode'] ?? ''));
$internalCode = trim((string) ($row['internal_code'] ?? ''));
if ($label === '' && $brandModel === '' && $barcode === '' && $internalCode === '') {
return null;
}
return [
'label' => $label !== '' ? $label : $brandModel,
'brand_model' => $brandModel,
'barcode' => $barcode,
'internal_code' => $internalCode,
'preferred_source' => trim((string) ($row['preferred_source'] ?? $row['source_label'] ?? '')),
'preferred_url' => trim((string) ($row['preferred_url'] ?? $row['external_url'] ?? '')),
'default_qty' => isset($row['default_qty']) && is_numeric($row['default_qty']) ? max(0.01, round((float) $row['default_qty'], 2)) : 1.0,
'last_price' => isset($row['last_price']) && is_numeric($row['last_price']) ? round((float) $row['last_price'], 2) : (isset($row['unit_price']) && is_numeric($row['unit_price']) ? round((float) $row['unit_price'], 2) : null),
'currency' => trim((string) ($row['currency'] ?? 'EUR')) ?: 'EUR',
'note' => trim((string) ($row['note'] ?? '')),
];
}
protected function buildMaterialMemoryKey(array $row): string
{
$parts = [
trim((string) ($row['barcode'] ?? '')),
trim((string) ($row['internal_code'] ?? '')),
preg_replace('/[^A-Za-z0-9]+/', '', strtoupper((string) ($row['label'] ?? ''))) ?: '',
];
foreach ($parts as $part) {
if ($part !== '') {
return $part;
}
}
return 'mat-' . md5(json_encode($row));
}
protected function matchesMaterialSearch(array $row, string $term, string $normalized): bool
{
if ($term === '') {
return true;
}
$haystack = mb_strtolower(implode(' ', array_filter([
(string) ($row['label'] ?? ''),
(string) ($row['brand_model'] ?? ''),
(string) ($row['barcode'] ?? ''),
(string) ($row['internal_code'] ?? ''),
(string) ($row['preferred_source'] ?? ''),
(string) ($row['note'] ?? ''),
])));
if (str_contains($haystack, mb_strtolower($term))) {
return true;
}
if ($normalized === '') {
return false;
}
$barcode = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper((string) ($row['barcode'] ?? ''))) ?? '';
$internalCode = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper((string) ($row['internal_code'] ?? ''))) ?? '';
return $barcode === $normalized || str_contains($barcode, $normalized) || $internalCode === $normalized || str_contains($internalCode, $normalized);
}
protected function buildAmazonSearchUrl(string $term): string protected function buildAmazonSearchUrl(string $term): string
{ {
$query = ['k' => trim($term)]; $query = ['k' => trim($term)];

View File

@ -884,6 +884,9 @@ private function searchFornitoreMatches(): void
return; return;
} }
$digits = preg_replace('/\D+/', '', $raw) ?: '';
$canonicalNeedles = $this->normalizeSearchTags($raw);
$query = $this->getTableQuery() $query = $this->getTableQuery()
->withCount('dipendenti') ->withCount('dipendenti')
->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedFornitoreId ?? 0)]) ->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedFornitoreId ?? 0)])
@ -891,9 +894,134 @@ private function searchFornitoreMatches(): void
->orderBy('cognome') ->orderBy('cognome')
->orderBy('nome'); ->orderBy('nome');
$this->fornitoreMatches = $this->applyFornitoreSearch($query, $raw) $matches = $this->applyFornitoreSearch($query, $raw)
->limit(12) ->limit(60)
->get(); ->get()
->sortByDesc(function (Fornitore $fornitore) use ($raw, $digits, $canonicalNeedles): int {
return $this->scoreFornitoreMatch($fornitore, $raw, $digits, $canonicalNeedles);
})
->take(12)
->values();
if ((int) ($this->selectedFornitoreId ?? 0) > 0 && ! $matches->contains('id', (int) $this->selectedFornitoreId)) {
$selected = $this->getTableQuery()->find((int) $this->selectedFornitoreId);
if ($selected instanceof Fornitore) {
$matches->prepend($selected);
$matches = $matches->unique('id')->take(12)->values();
}
}
$this->fornitoreMatches = $matches;
}
/**
* @param array<int, string> $canonicalNeedles
*/
private function scoreFornitoreMatch(Fornitore $fornitore, string $raw, string $digits, array $canonicalNeedles): int
{
$score = (int) ($fornitore->id === (int) ($this->selectedFornitoreId ?? 0) ? 1000 : 0);
$label = $this->getFornitoreLabel($fornitore);
$phone = (string) ($fornitore->telefono ?: $fornitore->cellulare);
$tags = $this->splitFornitoreTags((string) ($fornitore->tags ?? ''));
$haystack = mb_strtolower(implode(' ', array_filter([
$label,
(string) ($fornitore->email ?? ''),
$phone,
implode(', ', $tags),
(string) ($fornitore->note ?? ''),
(string) ($fornitore->partita_iva ?? ''),
(string) ($fornitore->codice_fiscale ?? ''),
])));
if ($raw !== '' && str_contains($haystack, mb_strtolower($raw))) {
$score += 60;
}
if ($digits !== '') {
$phoneDigits = preg_replace('/\D+/', '', $phone) ?: '';
if ($phoneDigits !== '' && str_contains($phoneDigits, $digits)) {
$score += 45;
}
}
$rowTags = array_map(fn(string $tag): string => mb_strtolower($tag), $tags);
foreach ($canonicalNeedles as $canonicalNeedle) {
foreach ($rowTags as $rowTag) {
if ($rowTag === $canonicalNeedle) {
$score += 90;
} elseif (str_contains($rowTag, $canonicalNeedle) || str_contains($canonicalNeedle, $rowTag)) {
$score += 55;
}
}
}
return $score;
}
/**
* @return array<int, string>
*/
private function normalizeSearchTags(string $input): array
{
$tags = [];
foreach ($this->splitFornitoreTags($input) as $tag) {
$normalized = $this->canonicalizeFornitoreTag($tag);
if ($normalized !== null) {
$tags[] = $normalized;
}
}
return array_values(array_unique($tags));
}
/**
* @return array<int, string>
*/
private function splitFornitoreTags(string $value): array
{
$parts = preg_split('/[,;|\n\r\/]+/', $value) ?: [];
return array_values(array_filter(array_map(function (string $part): string {
$clean = trim($part);
$clean = preg_replace('/\s+/', ' ', $clean) ?? '';
return $clean;
}, $parts), fn(string $part): bool => $part !== ''));
}
private function canonicalizeFornitoreTag(string $raw): ?string
{
$clean = trim(mb_strtolower($raw));
if ($clean === '' || mb_strlen($clean) < 3) {
return null;
}
$map = [
'idr' => 'idraulico',
'idraul' => 'idraulico',
'elett' => 'elettricista',
'elettric' => 'elettricista',
'ascens' => 'ascensorista',
'puliz' => 'pulizie',
'giardin' => 'giardiniere',
'assicur' => 'assicurazione',
'manut' => 'manutenzione',
'spurgh' => 'spurghi',
'fogn' => 'spurghi',
'serr' => 'serrature',
'cald' => 'caldaia',
];
foreach ($map as $prefix => $normalized) {
if (str_starts_with($clean, $prefix)) {
return $normalized;
}
}
return $clean;
} }
private function cleanNullable(?string $value): ?string private function cleanNullable(?string $value): ?string

View File

@ -38,6 +38,7 @@ class Fornitore extends Model
'modalita_pagamento_predefinita', 'modalita_pagamento_predefinita',
'escludi_righe_fe', 'escludi_righe_fe',
'fe_features', 'fe_features',
'operational_config',
'telefono', 'telefono',
'telefono_country_code', 'telefono_country_code',
'cellulare', 'cellulare',
@ -58,6 +59,7 @@ class Fornitore extends Model
'updated_at' => 'datetime', 'updated_at' => 'datetime',
'escludi_righe_fe' => 'boolean', 'escludi_righe_fe' => 'boolean',
'fe_features' => 'array', 'fe_features' => 'array',
'operational_config' => 'array',
'is_tecnorepair_primary' => 'boolean', 'is_tecnorepair_primary' => 'boolean',
]; ];
@ -77,6 +79,22 @@ public function getFeFeaturesSafeAttribute(): array
return is_array($this->fe_features ?? null) ? $this->fe_features : []; return is_array($this->fe_features ?? null) ? $this->fe_features : [];
} }
/**
* @return array<string, mixed>
*/
public function getOperationalConfigSafeAttribute(): array
{
try {
if (! Schema::hasColumn('fornitori', 'operational_config')) {
return [];
}
} catch (\Throwable) {
return [];
}
return is_array($this->operational_config ?? null) ? $this->operational_config : [];
}
protected static function booted(): void protected static function booted(): void
{ {
static::creating(function (Fornitore $fornitore) { static::creating(function (Fornitore $fornitore) {

View File

@ -103,8 +103,8 @@ public function get(Amministratore $amministratore, ?string $accountKey = null):
} }
$resolvedKey = $this->normalizeAccountKey((string) $accountKey); $resolvedKey = $this->normalizeAccountKey((string) $accountKey);
if ($resolvedKey !== '' && isset($accounts[$resolvedKey])) { if ($resolvedKey !== '') {
return $accounts[$resolvedKey]; return $accounts[$resolvedKey] ?? null;
} }
foreach ($accounts as $account) { foreach ($accounts as $account) {

View File

@ -0,0 +1,26 @@
<?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('fornitori', function (Blueprint $table): void {
if (! Schema::hasColumn('fornitori', 'operational_config')) {
$table->json('operational_config')->nullable()->after('fe_features');
}
});
}
public function down(): void
{
Schema::table('fornitori', function (Blueprint $table): void {
if (Schema::hasColumn('fornitori', 'operational_config')) {
$table->dropColumn('operational_config');
}
});
}
};

View File

@ -66,6 +66,152 @@
<div class="mt-4 whitespace-pre-wrap rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-900">{{ $lastImportOutput }}</div> <div class="mt-4 whitespace-pre-wrap rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-900">{{ $lastImportOutput }}</div>
@endif @endif
</div> </div>
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-sm font-semibold">Chiavi, accesso e reperibilità</div>
<div class="mt-1 text-xs text-gray-500">Per ogni stabile del fornitore puoi memorizzare chi suonare, dove sono le chiavi e le istruzioni rapide da mostrare nella scheda intervento.</div>
</div>
<x-filament::button type="button" color="gray" wire:click="addStabileAccessRow">Aggiungi stabile</x-filament::button>
</div>
<label class="mt-4 block text-sm">
<span class="mb-1 block font-medium">Nota regia / dispatch board</span>
<textarea wire:model.defer="dispatchBoardNote" rows="3" class="w-full rounded-lg border-gray-300" placeholder="Esempio: chiamare sempre prima il portiere o l'inquilino prima di usare il pass."></textarea>
</label>
<div class="mt-4 space-y-3">
@forelse($stabileAccessRows as $index => $row)
<div class="rounded-xl border bg-slate-50 p-4">
<div class="grid gap-3 md:grid-cols-2">
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Stabile</span>
<select wire:model.defer="stabileAccessRows.{{ $index }}.stabile_id" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona stabile</option>
@foreach($stabileOptions as $stabileId => $label)
<option value="{{ $stabileId }}">{{ $label }}</option>
@endforeach
</select>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Chi suonare / campanello</span>
<input type="text" wire:model.defer="stabileAccessRows.{{ $index }}.chi_suonare" class="w-full rounded-lg border-gray-300" placeholder="Es. interno 3, portiere, sig. Rossi" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Referente sul posto</span>
<input type="text" wire:model.defer="stabileAccessRows.{{ $index }}.contatto_nome" class="w-full rounded-lg border-gray-300" placeholder="Nome e ruolo" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Telefono referente</span>
<input type="text" wire:model.defer="stabileAccessRows.{{ $index }}.contatto_telefono" class="w-full rounded-lg border-gray-300" placeholder="Cellulare o numero richiamabile" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Dove sono le chiavi</span>
<input type="text" wire:model.defer="stabileAccessRows.{{ $index }}.dove_sono_chiavi" class="w-full rounded-lg border-gray-300" placeholder="Es. cassetta GK-12, amministratore, portineria" />
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Istruzioni accesso</span>
<textarea wire:model.defer="stabileAccessRows.{{ $index }}.istruzioni_accesso" rows="3" class="w-full rounded-lg border-gray-300" placeholder="Passaggi pratici per entrare, zone comuni, allarmi, portoni, cancelli."></textarea>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Fascia reperibilità</span>
<input type="text" wire:model.defer="stabileAccessRows.{{ $index }}.fascia_reperibilita" class="w-full rounded-lg border-gray-300" placeholder="Es. 8:30-12:30 / 15:00-18:00" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Note urgenza</span>
<input type="text" wire:model.defer="stabileAccessRows.{{ $index }}.note_urgenza" class="w-full rounded-lg border-gray-300" placeholder="Es. se non risponde nessuno chiamare subito il custode" />
</label>
</div>
<div class="mt-3 flex flex-wrap items-center justify-between gap-2">
<label class="inline-flex items-center gap-2 text-xs text-slate-600">
<input type="checkbox" wire:model.defer="stabileAccessRows.{{ $index }}.is_priority" class="rounded border-gray-300" />
<span>Mostra come accesso prioritario</span>
</label>
<button type="button" wire:click="removeStabileAccessRow({{ $index }})" class="inline-flex items-center rounded-md bg-rose-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-rose-500">Rimuovi</button>
</div>
</div>
@empty
<div class="rounded-xl border border-dashed p-4 text-sm text-gray-500">Nessuna istruzione di accesso configurata. Aggiungi almeno lo stabile dove serve sapere chi suonare o dove recuperare le chiavi.</div>
@endforelse
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-sm font-semibold">Memoria materiali ricorrenti</div>
<div class="mt-1 text-xs text-gray-500">Preferiti per lampade, ricambi e prodotti che il fornitore usa spesso. Verranno proposti nella scheda intervento.</div>
</div>
<x-filament::button type="button" color="gray" wire:click="addPreferredMaterialRow">Aggiungi materiale</x-filament::button>
</div>
@if(! $operationalConfigAvailable)
<div class="mt-4 rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
La memoria materiali richiede la migrazione del database che aggiunge il campo <span class="font-mono">operational_config</span> ai fornitori.
</div>
@endif
<div class="mt-4 space-y-3">
@forelse($preferredMaterialRows as $index => $row)
<div class="rounded-xl border bg-slate-50 p-4">
<div class="grid gap-3 md:grid-cols-2">
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Etichetta materiale</span>
<input type="text" wire:model.defer="preferredMaterialRows.{{ $index }}.label" class="w-full rounded-lg border-gray-300" placeholder="Es. Lampadina E27 scala A" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Marca / modello</span>
<input type="text" wire:model.defer="preferredMaterialRows.{{ $index }}.brand_model" class="w-full rounded-lg border-gray-300" placeholder="Es. Philips CorePro 10W" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Barcode</span>
<input type="text" wire:model.defer="preferredMaterialRows.{{ $index }}.barcode" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Codice interno</span>
<input type="text" wire:model.defer="preferredMaterialRows.{{ $index }}.internal_code" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Fonte preferita</span>
<input type="text" wire:model.defer="preferredMaterialRows.{{ $index }}.preferred_source" class="w-full rounded-lg border-gray-300" placeholder="Catalogo interno, Amazon, grossista, TecnoRepair" />
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">URL / riferimento acquisto</span>
<input type="text" wire:model.defer="preferredMaterialRows.{{ $index }}.preferred_url" class="w-full rounded-lg border-gray-300" placeholder="Link referral o pagina prodotto" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Quantità tipica</span>
<input type="number" step="0.01" min="0.01" wire:model.defer="preferredMaterialRows.{{ $index }}.default_qty" class="w-full rounded-lg border-gray-300" />
</label>
<div class="grid gap-3 md:grid-cols-[1fr,120px] md:col-span-1">
<label class="block text-sm">
<span class="mb-1 block font-medium">Ultimo prezzo noto</span>
<input type="number" step="0.01" min="0" wire:model.defer="preferredMaterialRows.{{ $index }}.last_price" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Valuta</span>
<input type="text" wire:model.defer="preferredMaterialRows.{{ $index }}.currency" class="w-full rounded-lg border-gray-300" />
</label>
</div>
<label class="block text-sm md:col-span-2">
<span class="mb-1 block font-medium">Nota memoria</span>
<textarea wire:model.defer="preferredMaterialRows.{{ $index }}.note" rows="2" class="w-full rounded-lg border-gray-300" placeholder="Es. usare sempre in vano scala, colore luce calda, alternativa accettata."></textarea>
</label>
</div>
<div class="mt-3 flex justify-end">
<button type="button" wire:click="removePreferredMaterialRow({{ $index }})" class="inline-flex items-center rounded-md bg-rose-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-rose-500">Rimuovi</button>
</div>
</div>
@empty
<div class="rounded-xl border border-dashed p-4 text-sm text-gray-500">Nessun materiale preferito ancora salvato.</div>
@endforelse
</div>
<div class="mt-4">
<x-filament::button type="button" color="primary" wire:click="saveOperationalWorkflow">Salva workflow operativo</x-filament::button>
</div>
</div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">

View File

@ -157,6 +157,60 @@
<div class="mt-4 whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-sm text-gray-700">{{ $ticket->descrizione }}</div> <div class="mt-4 whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-sm text-gray-700">{{ $ticket->descrizione }}</div>
</div> </div>
@if(count($accessSummaryRows) > 0 || filled($dispatchBoardNote))
<div class="rounded-xl border border-amber-200 bg-amber-50/60 p-4">
<div class="text-sm font-semibold text-amber-900">Accesso, chiavi e reperibilità</div>
@if(filled($dispatchBoardNote))
<div class="mt-2 rounded-lg border border-amber-200 bg-white/80 p-3 text-xs text-amber-900">{{ $dispatchBoardNote }}</div>
@endif
<div class="mt-3 space-y-3">
@foreach($accessSummaryRows as $row)
<div class="rounded-xl border bg-white p-4 text-sm shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="font-medium text-slate-900">{{ $row['stabile_label'] }}</div>
@if($row['is_priority'])
<span class="rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold text-amber-800">Prioritario</span>
@endif
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Chi suonare</div>
<div class="mt-1">{{ $row['chi_suonare'] !== '' ? $row['chi_suonare'] : '-' }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Referente</div>
<div class="mt-1">{{ $row['contatto_nome'] !== '' ? $row['contatto_nome'] : '-' }}</div>
@if($row['contatto_telefono'] !== '')
<a href="tel:{{ preg_replace('/\s+/', '', (string) $row['contatto_telefono']) }}" class="mt-1 inline-flex text-xs font-medium text-primary-600 hover:underline">{{ $row['contatto_telefono'] }}</a>
@endif
</div>
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Dove sono le chiavi</div>
<div class="mt-1">{{ $row['dove_sono_chiavi'] !== '' ? $row['dove_sono_chiavi'] : '-' }}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">Fascia reperibilità</div>
<div class="mt-1">{{ $row['fascia_reperibilita'] !== '' ? $row['fascia_reperibilita'] : '-' }}</div>
</div>
@if($row['istruzioni_accesso'] !== '')
<div class="md:col-span-2">
<div class="text-xs uppercase tracking-wide text-slate-500">Istruzioni accesso</div>
<div class="mt-1 whitespace-pre-wrap text-slate-700">{{ $row['istruzioni_accesso'] }}</div>
</div>
@endif
@if($row['note_urgenza'] !== '')
<div class="md:col-span-2 rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-900">
<span class="font-semibold">Nota urgenza:</span> {{ $row['note_urgenza'] }}
</div>
@endif
</div>
</div>
@endforeach
</div>
</div>
@endif
<div class="rounded-xl border bg-white p-4"> <div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-semibold">Operativo e rapportino</div> <div class="text-sm font-semibold">Operativo e rapportino</div>
@ -256,6 +310,54 @@
</div> </div>
</div> </div>
@if(count($preferredMaterialRows) > 0)
<div class="mt-4 rounded-xl border border-emerald-200 bg-white p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-emerald-700">Preferiti fornitore</div>
<div class="mt-3 grid gap-3 lg:grid-cols-2">
@foreach($preferredMaterialRows as $index => $row)
<div class="rounded-xl border border-emerald-100 bg-emerald-50/40 p-3 text-sm shadow-sm">
<div class="font-medium">{{ $row['label'] }}</div>
@if($row['brand_model'] !== '')
<div class="mt-1 text-xs text-slate-500">{{ $row['brand_model'] }}</div>
@endif
<div class="mt-1 text-xs text-slate-500">Barcode: {{ $row['barcode'] !== '' ? $row['barcode'] : 'n/d' }} · Qty tipica: {{ rtrim(rtrim(number_format((float) $row['default_qty'], 2, '.', ''), '0'), '.') }}</div>
<div class="mt-1 text-xs text-slate-500">Fonte: {{ $row['preferred_source'] !== '' ? $row['preferred_source'] : 'Memoria fornitore' }}@if($row['last_price'] !== null) · Prezzo: {{ number_format((float) $row['last_price'], 2, ',', '.') }} {{ $row['currency'] }}@endif</div>
@if($row['note'] !== '')
<div class="mt-2 text-xs text-slate-600">{{ $row['note'] }}</div>
@endif
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" wire:click="addPreferredMaterial({{ $index }})" class="inline-flex items-center rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500">Aggiungi al rapportino</button>
@if($row['preferred_url'] !== '')
<a href="{{ $row['preferred_url'] }}" target="_blank" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Apri riferimento</a>
@endif
</div>
</div>
@endforeach
</div>
</div>
@endif
@if(count($historicalMaterialRows) > 0)
<div class="mt-4 rounded-xl border border-sky-200 bg-white p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-sky-700">Storico materiali usati</div>
<div class="mt-3 grid gap-3 lg:grid-cols-2">
@foreach($historicalMaterialRows as $index => $row)
<div class="rounded-xl border border-sky-100 bg-sky-50/40 p-3 text-sm shadow-sm">
<div class="font-medium">{{ $row['label'] }}</div>
<div class="mt-1 text-xs text-slate-500">Usato {{ $row['usage_count'] }} volte · ultimo ticket #{{ $row['ticket_id'] }} · {{ $row['last_used_at'] }}</div>
<div class="mt-1 text-xs text-slate-500">Barcode: {{ $row['barcode'] !== '' ? $row['barcode'] : 'n/d' }}@if($row['preferred_source'] !== '') · Fonte: {{ $row['preferred_source'] }}@endif</div>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" wire:click="addHistoricalMaterial({{ $index }})" class="inline-flex items-center rounded-md bg-sky-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-500">Riusa nel rapportino</button>
@if($row['preferred_url'] !== '')
<a href="{{ $row['preferred_url'] }}" target="_blank" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50">Apri riferimento</a>
@endif
</div>
</div>
@endforeach
</div>
</div>
@endif
@if(count($materialSuggestions) > 0) @if(count($materialSuggestions) > 0)
<div class="mt-3 grid gap-3 lg:grid-cols-2"> <div class="mt-3 grid gap-3 lg:grid-cols-2">
@foreach($materialSuggestions as $suggestion) @foreach($materialSuggestions as $suggestion)
@ -315,7 +417,10 @@
@else @else
<span class="text-xs text-gray-400">Nessun riferimento esterno</span> <span class="text-xs text-gray-400">Nessun riferimento esterno</span>
@endif @endif
<button type="button" wire:click="removeMateriale({{ (int) $index }})" class="inline-flex items-center rounded-md bg-rose-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-rose-500">Rimuovi</button> <div class="flex flex-wrap gap-2">
<button type="button" wire:click="saveCurrentMaterialAsPreferred({{ (int) $index }})" class="inline-flex items-center rounded-md bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-200">Memorizza</button>
<button type="button" wire:click="removeMateriale({{ (int) $index }})" class="inline-flex items-center rounded-md bg-rose-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-rose-500">Rimuovi</button>
</div>
</div> </div>
</div> </div>
@endforeach @endforeach