Compare commits

...

2 Commits

16 changed files with 1566 additions and 97 deletions

View File

@ -34,13 +34,23 @@ public function handle(TecnoRepairMdbReader $reader, FornitoreProductCatalogServ
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)) {
$this->error('Archivio MDB non trovato: ' . $mdbPath);
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'));
$limit = max(0, (int) $this->option('limit'));
$dryRun = (bool) $this->option('dry-run');

View File

@ -4,12 +4,15 @@
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Filament\Pages\Gescon\FornitoreScheda;
use App\Models\Fornitore;
use App\Models\FornitoreStabileImpostazione;
use App\Models\Stabile;
use App\Models\User;
use App\Support\ArchivioPaths;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
@ -53,6 +56,19 @@ class ImpostazioniArchivio extends Page
/** @var array<string, mixed>|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
{
$user = auth()->user();
@ -167,6 +183,8 @@ public function getProdottiUrl(): string
public function getGoogleConnectUrl(): string
{
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(),
]);
}
@ -174,14 +192,161 @@ public function getGoogleConnectUrl(): string
public function getGoogleDisconnectUrl(): string
{
return route('oauth.google.disconnect', [
'account_key' => 'fornitore-' . (int) ($this->fornitore?->id ?? 0),
'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
{
$relativeBase = $this->getArchiveRelativeBase();
$absoluteBase = $relativeBase ? storage_path('app/' . $relativeBase) : null;
$this->operationalConfigAvailable = Schema::hasColumn('fornitori', 'operational_config');
if ($relativeBase !== null) {
foreach ([
@ -222,16 +387,154 @@ private function refreshArchiveSummary(): void
$stats = $this->fornitore?->tecnorepair_stats ?? ['total' => 0, 'open' => 0, 'closed' => 0, 'serials' => 0];
$this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus();
$this->loadOperationalWorkflowSettings();
$this->moduleRows = [
['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' => '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' => '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' => '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
*/

View File

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

View File

@ -8,6 +8,7 @@
use App\Filament\Pages\Gescon\StabileScheda;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\FornitoreStabileImpostazione;
use App\Models\Product;
use App\Models\ProductIdentifier;
use App\Models\ProductOffer;
@ -86,6 +87,12 @@ class TicketInterventoScheda extends Page
/** @var array<int, array<string, mixed>> */
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 $apparatoModello = '';
@ -124,6 +131,13 @@ class TicketInterventoScheda extends Page
/** @var array<string, mixed>|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 static function canAccess(): bool
@ -439,6 +453,66 @@ public function removeMateriale(int $index): void
$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
{
$validated = $this->validate([
@ -708,6 +782,8 @@ public function getProdottiUrl(): string
public function getGoogleConnectUrl(): string
{
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(),
]);
}
@ -715,6 +791,7 @@ public function getGoogleConnectUrl(): string
public function getGoogleDisconnectUrl(): string
{
return route('oauth.google.disconnect', [
'account_key' => 'fornitore-' . (int) $this->fornitore->id,
'return_to' => request()->getRequestUri(),
]);
}
@ -815,6 +892,7 @@ protected function buildStoredUploadDisplayName(int $index, bool $isImage, strin
protected function reloadIntervento(): void
{
$this->sessionTrackingAvailable = Schema::hasTable('ticket_intervento_sessioni');
$this->operationalConfigAvailable = Schema::hasColumn('fornitori', 'operational_config');
$relations = [
'ticket.stabile',
@ -919,6 +997,8 @@ protected function reloadIntervento(): void
]
: null;
$this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus();
$this->loadAccessSummary();
$this->loadMaterialMemory();
$this->qrToken = '';
}
@ -1012,6 +1092,8 @@ protected function formatGeoPoint(mixed $lat, mixed $lng, mixed $accuracy): stri
protected function refreshMaterialSuggestions(): void
{
$term = trim($this->materialSearch);
$this->loadMaterialMemory();
if ($term === '') {
$this->materialSuggestions = [];
return;
@ -1060,6 +1142,251 @@ protected function refreshMaterialSuggestions(): void
})->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
{
$query = ['k' => trim($term)];

View File

@ -884,6 +884,9 @@ private function searchFornitoreMatches(): void
return;
}
$digits = preg_replace('/\D+/', '', $raw) ?: '';
$canonicalNeedles = $this->normalizeSearchTags($raw);
$query = $this->getTableQuery()
->withCount('dipendenti')
->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [(int) ($this->selectedFornitoreId ?? 0)])
@ -891,9 +894,134 @@ private function searchFornitoreMatches(): void
->orderBy('cognome')
->orderBy('nome');
$this->fornitoreMatches = $this->applyFornitoreSearch($query, $raw)
->limit(12)
->get();
$matches = $this->applyFornitoreSearch($query, $raw)
->limit(60)
->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

View File

@ -4,16 +4,26 @@
use App\Http\Controllers\Condomino\Concerns\ResolvesCondominoAccess;
use App\Http\Controllers\Controller;
use App\Models\CategoriaTicket;
use App\Models\Documento;
use App\Models\Ticket;
use App\Models\TicketAttachment;
use App\Models\TicketMessage;
use App\Models\User;
use App\Services\Documenti\ImageTextExtractionService;
use App\Services\Documenti\PdfTextExtractionService;
use App\Services\Support\TicketAttachmentUploadService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
class TicketController extends Controller
{
use ResolvesCondominoAccess;
/** @var array<int, string>|null */
private ?array $documentiColumnsCache = null;
public function index()
{
$user = Auth::user();
@ -57,6 +67,8 @@ public function store(Request $request)
'luogo_intervento' => 'nullable|string|max:255',
'priorita' => 'required|in:Bassa,Media,Alta,Urgente',
'allegati.*' => 'nullable|file|max:20480', // 20MB per file
'attachment_descriptions' => 'nullable|array',
'attachment_descriptions.*' => 'nullable|string|max:255',
]);
$unitaImmobiliare = $this->resolveUserUnita($user)
@ -84,22 +96,42 @@ public function store(Request $request)
// Gestione allegati
if ($request->hasFile('allegati')) {
foreach ($request->file('allegati') as $file) {
$supportsAttachmentMetadata = Schema::hasColumn('ticket_attachments', 'metadata');
$attachmentDescriptions = (array) $request->input('attachment_descriptions', []);
foreach ($request->file('allegati') as $index => $file) {
if (! $file) {
continue;
}
$path = $file->store('ticket-attachments', 'public');
$displayName = $this->buildStoredAttachmentDisplayName($ticket, (int) $index, (string) $file->getClientOriginalName());
$stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-attachments/' . $ticket->id, [
'stored_basename' => pathinfo($displayName, PATHINFO_FILENAME),
'display_name' => $displayName,
]);
$ticket->attachments()->create([
$attachmentPayload = [
'ticket_update_id' => null,
'user_id' => $user->id,
'file_path' => $path,
'original_file_name' => $file->getClientOriginalName(),
'mime_type' => (string) $file->getMimeType(),
'size' => (int) $file->getSize(),
'description' => 'Upload da area condomino',
]);
'file_path' => $stored['path'],
'original_file_name' => $stored['original_name'],
'mime_type' => $stored['mime'],
'size' => $stored['size'],
'description' => $this->resolveAttachmentDescription(
(string) ($attachmentDescriptions[$index] ?? ''),
$stored,
),
];
if ($supportsAttachmentMetadata) {
$attachmentPayload['metadata'] = is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [];
}
$attachment = $ticket->attachments()->create($attachmentPayload);
if ($attachment instanceof TicketAttachment) {
$this->archiveTicketAttachmentDocument($ticket, $attachment);
}
}
}
@ -128,4 +160,161 @@ public function show(Ticket $ticket)
return view('condomino.tickets.show', compact('ticket'));
}
private function buildStoredAttachmentDisplayName(Ticket $ticket, int $index, string $originalName): string
{
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
return sprintf(
'ticket-%06d-all-%02d%s',
(int) $ticket->id,
$index + 1,
$extension !== '' ? '.' . $extension : ''
);
}
/**
* @param array<string, mixed> $stored
*/
private function resolveAttachmentDescription(string $specificDescription, array $stored): string
{
$specificDescription = trim($specificDescription);
if ($specificDescription !== '') {
return $specificDescription;
}
$metadata = is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [];
$parts = ['Upload da area condomino'];
$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;
}
$exifDate = trim((string) ($metadata['exif_datetime'] ?? ''));
if ($exifDate !== '') {
$parts[] = 'EXIF: ' . $exifDate;
}
$device = trim((string) ($metadata['device'] ?? ''));
if ($device !== '') {
$parts[] = 'Device: ' . $device;
}
$displayName = trim((string) ($stored['original_name'] ?? ''));
if ($displayName !== '') {
$parts[] = 'File: ' . $displayName;
}
$sourceName = trim((string) ($stored['source_original_name'] ?? ''));
if ($sourceName !== '' && $sourceName !== $displayName) {
$parts[] = 'Orig: ' . $sourceName;
}
return mb_substr(implode(' | ', array_filter($parts)), 0, 255);
}
private function archiveTicketAttachmentDocument(Ticket $ticket, TicketAttachment $attachment): void
{
if (! Schema::hasTable('documenti')) {
return;
}
$publicPath = (string) ($attachment->file_path ?? '');
if ($publicPath === '' || ! Storage::disk('public')->exists($publicPath)) {
return;
}
$localPath = Storage::disk('public')->path($publicPath);
$contents = (string) Storage::disk('public')->get($publicPath);
$extension = strtolower((string) pathinfo($localPath, PATHINFO_EXTENSION));
$archiveReference = 'ticket-' . (int) $ticket->id . '-attachment-' . (int) $attachment->id;
$attachmentMeta = is_array($attachment->metadata ?? null) ? $attachment->metadata : [];
$payload = [
'documentable_type' => Ticket::class,
'documentable_id' => (int) $ticket->id,
'stabile_id' => (int) $ticket->stabile_id,
'utente_id' => Auth::id(),
'nome' => (string) ($attachment->original_file_name ?: ('Allegato ticket #' . (int) $attachment->id)),
'nome_file' => (string) ($attachment->original_file_name ?: basename($localPath)),
'path_file' => $localPath,
'percorso_file' => $localPath,
'tipo_documento' => 'ticket_allegato',
'tipologia' => $attachment->isImage() ? 'foto' : ($attachment->isPdf() ? 'comunicazione' : 'altro'),
'mime_type' => $attachment->resolvedMimeType(),
'descrizione' => (string) ($attachment->description ?: 'Allegato ticket #' . (int) $ticket->id),
'note' => 'Ticket #' . (int) $ticket->id . ' · ' . (string) ($ticket->titolo ?: 'senza titolo'),
'dimensione_file' => (int) ($attachment->size ?? strlen($contents)),
'estensione' => $extension !== '' ? $extension : null,
'hash_file' => hash('sha256', $contents),
'xml_data' => [
'ticket_id' => (int) $ticket->id,
'ticket_attachment_id' => (int) $attachment->id,
'source_public_path' => $publicPath,
'archive_reference' => $archiveReference,
'archive_scope' => 'ticket',
'visibility_scope' => 'interno',
'attachment_metadata' => $attachmentMeta,
],
'metadati_ocr' => $attachment->isImage()
? ['status' => 'pending_image_ocr', 'source' => 'ticket_attachment']
: ['source' => 'ticket_attachment'],
'data_upload' => now(),
'approvato' => true,
'archiviato' => true,
'urgente' => false,
'is_demo' => false,
'archive_reference' => $archiveReference,
'visibility_scope' => 'interno',
'visibility_groups' => ['supporto', 'amministrazione', 'stabile:' . (int) $ticket->stabile_id],
'visibility_roles' => ['super-admin', 'admin', 'amministratore', 'collaboratore'],
];
$documento = Documento::query()->updateOrCreate(
[
'documentable_type' => Ticket::class,
'documentable_id' => (int) $ticket->id,
'path_file' => $localPath,
],
$this->filterDocumentoPayload($payload)
);
if ($attachment->isPdf()) {
app(PdfTextExtractionService::class)->extractAndStore($documento, false);
} elseif ($attachment->isImage()) {
app(ImageTextExtractionService::class)->extractAndStore($documento, false);
}
}
/**
* @return array<int, string>
*/
private function documentiColumns(): array
{
if ($this->documentiColumnsCache === null) {
$this->documentiColumnsCache = Schema::hasTable('documenti')
? Schema::getColumnListing('documenti')
: [];
}
return $this->documentiColumnsCache;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function filterDocumentoPayload(array $payload): array
{
$allowed = array_flip($this->documentiColumns());
return array_intersect_key($payload, $allowed);
}
}

View File

@ -1,11 +1,11 @@
<?php
namespace App\Http\Controllers\PublicAccess;
use App\Http\Controllers\Controller;
use App\Models\StabileServizio;
use App\Models\StabileServizioLettura;
use App\Models\UnitaImmobiliare;
use App\Services\Support\TicketAttachmentUploadService;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
@ -17,8 +17,8 @@ class WaterReadingRequestController extends Controller
public function show(Request $request): View
{
$servizioId = (int) $request->integer('servizio');
$unitaId = (int) $request->integer('unita');
$requestId = (int) $request->integer('request');
$unitaId = (int) $request->integer('unita');
$requestId = (int) $request->integer('request');
$servizio = StabileServizio::query()
->where('id', $servizioId)
@ -33,41 +33,41 @@ public function show(Request $request): View
$pendingRequest = $requestId > 0
? StabileServizioLettura::query()
->where('id', $requestId)
->where('stabile_servizio_id', $servizioId)
->where('unita_immobiliare_id', $unitaId)
->first()
->where('id', $requestId)
->where('stabile_servizio_id', $servizioId)
->where('unita_immobiliare_id', $unitaId)
->first()
: null;
$previous = StabileServizioLettura::query()
->where('stabile_servizio_id', $servizioId)
->where('unita_immobiliare_id', $unitaId)
->whereNotNull('lettura_fine')
->when($pendingRequest, fn ($query) => $query->where('id', '!=', (int) $pendingRequest->id))
->when($pendingRequest, fn($query) => $query->where('id', '!=', (int) $pendingRequest->id))
->orderByDesc('periodo_al')
->orderByDesc('id')
->first();
$submitUrl = URL::temporarySignedRoute('public.water-reading.store', now()->addDays(30), array_filter([
'servizio' => $servizioId,
'unita' => $unitaId,
'request' => $pendingRequest?->id,
'unita' => $unitaId,
'request' => $pendingRequest?->id,
]));
return view('public.water-reading.show', [
'servizio' => $servizio,
'unita' => $unita,
'servizio' => $servizio,
'unita' => $unita,
'pendingRequest' => $pendingRequest,
'previous' => $previous,
'submitUrl' => $submitUrl,
'previous' => $previous,
'submitUrl' => $submitUrl,
]);
}
public function store(Request $request): RedirectResponse
{
$servizioId = (int) $request->integer('servizio');
$unitaId = (int) $request->integer('unita');
$requestId = (int) $request->integer('request');
$unitaId = (int) $request->integer('unita');
$requestId = (int) $request->integer('request');
$servizio = StabileServizio::query()
->where('id', $servizioId)
@ -80,35 +80,35 @@ public function store(Request $request): RedirectResponse
->firstOrFail();
$validated = $request->validate([
'lettura_attuale' => ['required', 'numeric', 'min:0'],
'data_lettura' => ['nullable', 'date'],
'rilevatore_nome' => ['required', 'string', 'max:255'],
'lettura_attuale' => ['required', 'numeric', 'min:0'],
'data_lettura' => ['nullable', 'date'],
'rilevatore_nome' => ['required', 'string', 'max:255'],
'rilevatore_contatto' => ['nullable', 'string', 'max:255'],
'note' => ['nullable', 'string', 'max:2000'],
'foto_1' => ['nullable', 'image', 'max:8192'],
'foto_2' => ['nullable', 'image', 'max:8192'],
'note' => ['nullable', 'string', 'max:2000'],
'foto_1' => ['nullable', 'image', 'max:8192'],
'foto_2' => ['nullable', 'image', 'max:8192'],
]);
$pendingRequest = $requestId > 0
? StabileServizioLettura::query()
->where('id', $requestId)
->where('stabile_servizio_id', $servizioId)
->where('unita_immobiliare_id', $unitaId)
->first()
->where('id', $requestId)
->where('stabile_servizio_id', $servizioId)
->where('unita_immobiliare_id', $unitaId)
->first()
: null;
$previous = StabileServizioLettura::query()
->where('stabile_servizio_id', $servizioId)
->where('unita_immobiliare_id', $unitaId)
->whereNotNull('lettura_fine')
->when($pendingRequest, fn ($query) => $query->where('id', '!=', (int) $pendingRequest->id))
->when($pendingRequest, fn($query) => $query->where('id', '!=', (int) $pendingRequest->id))
->orderByDesc('periodo_al')
->orderByDesc('id')
->first();
$letturaInizio = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null;
$letturaFine = (float) $validated['lettura_attuale'];
$consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null;
$letturaFine = (float) $validated['lettura_attuale'];
$consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null;
if ($consumo !== null && $consumo < 0) {
return back()->withErrors([
@ -116,43 +116,72 @@ public function store(Request $request): RedirectResponse
])->withInput();
}
$photoPaths = [];
$photoAssets = [];
foreach (['foto_1', 'foto_2'] as $photoField) {
if ($request->hasFile($photoField)) {
$photoPaths[] = $request->file($photoField)?->store('letture-acqua-public', 'public');
$file = $request->file($photoField);
if (! $file) {
continue;
}
$stored = app(TicketAttachmentUploadService::class)->store(
$file,
'letture-acqua-public/' . $servizio->id . '/' . $unita->id,
[
'stored_basename' => 'water-reading-' . $servizio->id . '-' . $unita->id . '-' . $photoField,
]
);
$photoAssets[] = [
'field' => $photoField,
'path' => $stored['path'],
'original_name' => $stored['original_name'] ?? null,
'source_original_name' => $stored['source_original_name'] ?? null,
'mime' => $stored['mime'] ?? null,
'size' => $stored['size'] ?? null,
'metadata' => is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [],
];
}
}
$photoPaths = array_values(array_filter(array_map(
static fn(array $photo): ?string => is_string($photo['path'] ?? null) ? $photo['path'] : null,
$photoAssets
)));
$payload = [
'stabile_id' => (int) $servizio->stabile_id,
'stabile_servizio_id' => (int) $servizio->id,
'unita_immobiliare_id' => (int) $unita->id,
'fornitore_id' => $servizio->fornitore_id,
'periodo_dal' => $previous?->periodo_al,
'periodo_al' => isset($validated['data_lettura']) ? Carbon::parse((string) $validated['data_lettura'])->toDateString() : now()->toDateString(),
'tipologia_lettura' => 'autolettura_link_pubblico',
'canale_acquisizione' => 'portale_pubblico',
'workflow_stato' => 'ricevuta',
'riferimento_acquisizione' => $pendingRequest?->riferimento_acquisizione ?: ('PUBREAD:' . (int) $servizio->id . ':' . (int) $unita->id . ':' . now()->format('YmdHis')),
'rilevatore_tipo' => 'link_pubblico',
'rilevatore_nome' => trim((string) $validated['rilevatore_nome']),
'stabile_id' => (int) $servizio->stabile_id,
'stabile_servizio_id' => (int) $servizio->id,
'unita_immobiliare_id' => (int) $unita->id,
'fornitore_id' => $servizio->fornitore_id,
'periodo_dal' => $previous?->periodo_al,
'periodo_al' => isset($validated['data_lettura']) ? Carbon::parse((string) $validated['data_lettura'])->toDateString() : now()->toDateString(),
'tipologia_lettura' => 'autolettura_link_pubblico',
'canale_acquisizione' => 'portale_pubblico',
'workflow_stato' => 'ricevuta',
'riferimento_acquisizione' => $pendingRequest?->riferimento_acquisizione ?: ('PUBREAD:' . (int) $servizio->id . ':' . (int) $unita->id . ':' . now()->format('YmdHis')),
'rilevatore_tipo' => 'link_pubblico',
'rilevatore_nome' => trim((string) $validated['rilevatore_nome']),
'lettura_precedente_valore' => $letturaInizio,
'lettura_inizio' => $letturaInizio,
'lettura_fine' => $letturaFine,
'lettura_foto_path' => $photoPaths[0] ?? null,
'lettura_foto_metadata' => [
'paths' => $photoPaths,
'lettura_inizio' => $letturaInizio,
'lettura_fine' => $letturaFine,
'lettura_foto_path' => $photoPaths[0] ?? null,
'lettura_foto_original_name'=> $photoAssets[0]['original_name'] ?? null,
'lettura_foto_metadata' => [
'photos' => $photoAssets,
'paths' => $photoPaths,
'contact' => trim((string) ($validated['rilevatore_contatto'] ?? '')),
'note' => trim((string) ($validated['note'] ?? '')),
'note' => trim((string) ($validated['note'] ?? '')),
],
'consumo_valore' => $consumo,
'consumo_unita' => 'mc',
'raw' => [
'source' => 'public_water_reading_link',
'contact' => trim((string) ($validated['rilevatore_contatto'] ?? '')),
'note' => trim((string) ($validated['note'] ?? '')),
'consumo_valore' => $consumo,
'consumo_unita' => 'mc',
'raw' => [
'source' => 'public_water_reading_link',
'contact' => trim((string) ($validated['rilevatore_contatto'] ?? '')),
'note' => trim((string) ($validated['note'] ?? '')),
'photo_assets'=> $photoAssets,
'photo_paths' => $photoPaths,
'request_id' => $pendingRequest?->id,
'request_id' => $pendingRequest?->id,
],
];
@ -165,4 +194,4 @@ public function store(Request $request): RedirectResponse
return redirect()->back()->with('success', 'Lettura acqua inviata correttamente.');
}
}
}

View File

@ -38,6 +38,7 @@ class Fornitore extends Model
'modalita_pagamento_predefinita',
'escludi_righe_fe',
'fe_features',
'operational_config',
'telefono',
'telefono_country_code',
'cellulare',
@ -58,6 +59,7 @@ class Fornitore extends Model
'updated_at' => 'datetime',
'escludi_righe_fe' => 'boolean',
'fe_features' => 'array',
'operational_config' => 'array',
'is_tecnorepair_primary' => 'boolean',
];
@ -77,6 +79,22 @@ public function getFeFeaturesSafeAttribute(): array
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
{
static::creating(function (Fornitore $fornitore) {

View File

@ -11,7 +11,12 @@ class TicketAttachment extends Model
use HasFactory;
protected $table = 'ticket_attachments';
protected $fillable = ['ticket_id', 'ticket_update_id', 'user_id', 'file_path', 'original_file_name', 'mime_type', 'size', 'description'];
protected $fillable = ['ticket_id', 'ticket_update_id', 'user_id', 'file_path', 'original_file_name', 'mime_type', 'size', 'description', 'metadata'];
protected $casts = [
'size' => 'integer',
'metadata' => 'array',
];
public function ticket(): BelongsTo
{

View File

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

@ -0,0 +1,30 @@
<?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
{
if (! Schema::hasTable('ticket_attachments') || Schema::hasColumn('ticket_attachments', 'metadata')) {
return;
}
Schema::table('ticket_attachments', function (Blueprint $table): void {
$table->json('metadata')->nullable()->after('description');
});
}
public function down(): void
{
if (! Schema::hasTable('ticket_attachments') || ! Schema::hasColumn('ticket_attachments', 'metadata')) {
return;
}
Schema::table('ticket_attachments', function (Blueprint $table): void {
$table->dropColumn('metadata');
});
}
};

View File

@ -74,16 +74,33 @@
</select>
</div>
<div>
<label for="allegati_camera" class="block text-sm font-medium">Foto da fotocamera</label>
<input id="allegati_camera" name="allegati[]" type="file" multiple accept="image/*" capture="environment" class="mt-1 block w-full rounded-md border-slate-300">
<p class="mt-1 text-xs text-slate-500">Scatta foto direttamente da smartphone.</p>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<div class="text-sm font-medium">Allegati con anteprima e descrizione</div>
<p class="text-xs text-slate-500">Ogni file compare qui sotto in un box dedicato, così puoi descriverlo subito prima dell'invio.</p>
</div>
<div class="text-xs text-slate-500">Max 20MB per file</div>
</div>
<div>
<label for="allegati_file" class="block text-sm font-medium">File da telefono / PC</label>
<input id="allegati_file" name="allegati[]" type="file" multiple accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip" class="mt-1 block w-full rounded-md border-slate-300">
<p class="mt-1 text-xs text-slate-500">Puoi caricare immagini, PDF e documenti; max 20MB per file.</p>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label for="allegati_camera" class="block text-sm font-medium">Foto da fotocamera</label>
<input id="allegati_camera" name="allegati[]" type="file" multiple accept="image/*" capture="environment" class="mt-1 block w-full rounded-md border-slate-300">
<p class="mt-1 text-xs text-slate-500">Scatta foto direttamente da smartphone.</p>
</div>
<div>
<label for="allegati_file" class="block text-sm font-medium">File da telefono / PC</label>
<input id="allegati_file" name="allegati[]" type="file" multiple accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip" class="mt-1 block w-full rounded-md border-slate-300">
<p class="mt-1 text-xs text-slate-500">Immagini, PDF e documenti. I metadati foto vengono letti in fase di salvataggio.</p>
</div>
</div>
<div id="ticket-attachment-preview-empty" class="mt-4 rounded-lg border border-dashed border-slate-300 bg-white px-4 py-5 text-center text-sm text-slate-500">
Nessun allegato selezionato.
</div>
<div id="ticket-attachment-preview-list" class="mt-4 grid gap-3 sm:grid-cols-2"></div>
</div>
<div class="flex gap-3 pt-2">
@ -92,5 +109,87 @@
</div>
</form>
</div>
<script>
(() => {
const cameraInput = document.getElementById('allegati_camera');
const fileInput = document.getElementById('allegati_file');
const previewList = document.getElementById('ticket-attachment-preview-list');
const emptyState = document.getElementById('ticket-attachment-preview-empty');
if (!cameraInput || !fileInput || !previewList || !emptyState) {
return;
}
const formatBytes = (value) => {
const bytes = Number(value || 0);
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 KB';
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
const currentFiles = () => ([
...Array.from(cameraInput.files || []),
...Array.from(fileInput.files || []),
]);
const renderPreviews = () => {
const files = currentFiles();
previewList.innerHTML = '';
emptyState.classList.toggle('hidden', files.length > 0);
files.forEach((file, index) => {
const card = document.createElement('div');
card.className = 'overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm';
const media = document.createElement(file.type.startsWith('image/') ? 'img' : 'div');
if (file.type.startsWith('image/')) {
media.className = 'h-40 w-full bg-slate-100 object-cover';
media.alt = file.name;
media.src = URL.createObjectURL(file);
} else {
media.className = 'flex h-40 w-full items-center justify-center bg-slate-100 text-sm font-medium text-slate-500';
media.textContent = file.name.split('.').pop()?.toUpperCase() || 'FILE';
}
const body = document.createElement('div');
body.className = 'space-y-3 p-3';
const title = document.createElement('div');
title.className = 'text-sm font-medium text-slate-900';
title.textContent = file.name;
const meta = document.createElement('div');
meta.className = 'text-xs text-slate-500';
meta.textContent = `${file.type || 'file generico'} · ${formatBytes(file.size)}`;
const label = document.createElement('label');
label.className = 'block text-xs font-medium text-slate-700';
label.textContent = 'Descrizione allegato';
const textarea = document.createElement('textarea');
textarea.name = 'attachment_descriptions[]';
textarea.rows = 3;
textarea.className = 'mt-1 block w-full rounded-md border-slate-300 text-sm';
textarea.placeholder = 'Es. perdita vicino al contatore, crepa sul soffitto, dettaglio del guasto';
label.appendChild(textarea);
body.append(title, meta, label);
card.append(media, body);
previewList.appendChild(card);
});
};
cameraInput.addEventListener('change', renderPreviews);
fileInput.addEventListener('change', renderPreviews);
renderPreviews();
})();
</script>
</body>
</html>

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>
@endif
</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 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>
@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="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-semibold">Operativo e rapportino</div>
@ -256,6 +310,54 @@
</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)
<div class="mt-3 grid gap-3 lg:grid-cols-2">
@foreach($materialSuggestions as $suggestion)
@ -315,7 +417,10 @@
@else
<span class="text-xs text-gray-400">Nessun riferimento esterno</span>
@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>
@endforeach

View File

@ -11,7 +11,7 @@
<div class="mx-auto max-w-3xl space-y-4 p-4 md:p-8">
<div>
<h1 class="text-2xl font-semibold">Invio lettura acqua</h1>
<p class="mt-1 text-sm text-slate-600">Compila la lettura attuale del contatore per l'unità indicata. Puoi allegare massimo due foto.</p>
<p class="mt-1 text-sm text-slate-600">Compila la lettura attuale del contatore per l'unità indicata. La lettura resta il campo principale; le foto servono a fissare subito il dato del display.</p>
</div>
@if (session('success'))
@ -42,6 +42,14 @@
<form method="POST" action="{{ $submitUrl }}" enctype="multipart/form-data" class="space-y-4 rounded-lg bg-white p-4 shadow">
@csrf
<div class="rounded-xl border border-blue-100 bg-blue-50 p-4">
<label class="block text-sm">
<span class="mb-1 block text-sm font-semibold text-slate-900">Lettura attuale del contatore</span>
<input type="number" step="0.001" min="0" name="lettura_attuale" value="{{ old('lettura_attuale') }}" class="w-full rounded-md border-slate-300 text-lg font-semibold" required>
</label>
<div class="mt-2 text-xs text-slate-600">Inserisci solo il numero letto sul contatore. Se alleghi foto, il sistema conserva anche i metadati dello scatto.</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Chi sta inviando la lettura</span>
@ -51,25 +59,28 @@
<span class="mb-1 block font-medium">Contatto</span>
<input type="text" name="rilevatore_contatto" value="{{ old('rilevatore_contatto') }}" class="w-full rounded-md border-slate-300" placeholder="Telefono o email">
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Lettura attuale</span>
<input type="number" step="0.001" min="0" name="lettura_attuale" value="{{ old('lettura_attuale') }}" class="w-full rounded-md border-slate-300" required>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Data lettura</span>
<input type="date" name="data_lettura" value="{{ old('data_lettura', now()->toDateString()) }}" class="w-full rounded-md border-slate-300">
</label>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Foto contatore 1</span>
<input type="file" name="foto_1" accept="image/*" capture="environment" class="w-full rounded-md border-slate-300">
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Foto contatore 2</span>
<input type="file" name="foto_2" accept="image/*" capture="environment" class="w-full rounded-md border-slate-300">
</label>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="mb-3 text-sm font-medium">Foto del contatore</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Foto contatore 1</span>
<input id="water-photo-1" type="file" name="foto_1" accept="image/*" capture="environment" class="w-full rounded-md border-slate-300">
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Foto contatore 2</span>
<input id="water-photo-2" type="file" name="foto_2" accept="image/*" capture="environment" class="w-full rounded-md border-slate-300">
</label>
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div id="water-photo-preview-1" class="flex min-h-40 items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white px-4 py-5 text-center text-sm text-slate-500">Nessuna foto 1 selezionata</div>
<div id="water-photo-preview-2" class="flex min-h-40 items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white px-4 py-5 text-center text-sm text-slate-500">Nessuna foto 2 selezionata</div>
</div>
</div>
<label class="block text-sm">
@ -80,5 +91,45 @@
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">Invia lettura</button>
</form>
</div>
<script>
(() => {
const bindPreview = (inputId, previewId, emptyLabel) => {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
if (!input || !preview) {
return;
}
input.addEventListener('change', () => {
const [file] = Array.from(input.files || []);
preview.innerHTML = '';
if (!file) {
preview.textContent = emptyLabel;
preview.className = 'flex min-h-40 items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white px-4 py-5 text-center text-sm text-slate-500';
return;
}
if (file.type.startsWith('image/')) {
const image = document.createElement('img');
image.src = URL.createObjectURL(file);
image.alt = file.name;
image.className = 'h-40 w-full rounded-xl object-cover';
preview.className = 'rounded-xl border border-slate-200 bg-white p-2';
preview.appendChild(image);
return;
}
preview.textContent = file.name;
preview.className = 'flex min-h-40 items-center justify-center rounded-xl border border-slate-200 bg-white px-4 py-5 text-center text-sm text-slate-500';
});
};
bindPreview('water-photo-1', 'water-photo-preview-1', 'Nessuna foto 1 selezionata');
bindPreview('water-photo-2', 'water-photo-preview-2', 'Nessuna foto 2 selezionata');
})();
</script>
</body>
</html>