diff --git a/CHANGELOG.md b/CHANGELOG.md index f72eade..313157d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ ## [Unreleased] - Extended Scheda Amministratore PBX/TAPI with observed tokens, multi-line collaborator mapping, and inline PBX flag editing. - Included safe rubrica-link QA repair in the Git-first staging sync flow from Supporto > Modifiche. - Documented the modular coworking direction of NetGescon and the first server-distribution path for isolated debug nodes. +- Added multi-session supplier intervention tracking with start/stop/checkpoint events and browser GPS capture when available. +- Extended supplier intervention reports with structured materials linked to the product catalog, barcode-friendly lookup, and external product fallback links. +- Added first supplier pages for customer address book and product catalog, plus supplier-side visibility of Google Workspace connection status/reuse. +- Verified legacy supplier tag import state: dry-run succeeds and current staging data already contains imported tags/legacy descriptions for the matched rows. ## [0.1.0] - 2026-01-17 diff --git a/app/Filament/Pages/Fornitore/LavorazioniOperative.php b/app/Filament/Pages/Fornitore/LavorazioniOperative.php index 7ce5adf..e504d03 100644 --- a/app/Filament/Pages/Fornitore/LavorazioniOperative.php +++ b/app/Filament/Pages/Fornitore/LavorazioniOperative.php @@ -144,6 +144,24 @@ public function getCollaboratoriUrl(): string return Collaboratori::getUrl(panel: 'admin-filament'); } + public function getRubricaUrl(): string + { + if ($this->fornitoreId) { + return RubricaClienti::getUrl(['fornitore' => $this->fornitoreId], panel: 'admin-filament'); + } + + return RubricaClienti::getUrl(panel: 'admin-filament'); + } + + public function getProdottiUrl(): string + { + if ($this->fornitoreId) { + return ProdottiCatalogo::getUrl(['fornitore' => $this->fornitoreId], panel: 'admin-filament'); + } + + return ProdottiCatalogo::getUrl(panel: 'admin-filament'); + } + protected function buildBaseQuery(Fornitore $fornitore, ?FornitoreDipendente $dipendente): Builder { $query = TicketIntervento::query() diff --git a/app/Filament/Pages/Fornitore/ProdottiCatalogo.php b/app/Filament/Pages/Fornitore/ProdottiCatalogo.php new file mode 100644 index 0000000..5d5d0e2 --- /dev/null +++ b/app/Filament/Pages/Fornitore/ProdottiCatalogo.php @@ -0,0 +1,147 @@ +> */ + public array $rows = []; + + public static function canAccess(): bool + { + $user = Auth::user(); + + return $user instanceof User + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']); + } + + public function mount(): void + { + [$fornitore] = $this->resolveOperatoreContext(allowAdminWithoutSupplier: true); + + if (! $fornitore instanceof Fornitore) { + $this->missingAdminContext = true; + return; + } + + $this->fornitoreId = (int) $fornitore->id; + $this->fornitoreLabel = $this->getFornitoreLabel($fornitore); + $this->refreshRows(); + } + + public function updatedSearch(): void + { + $this->refreshRows(); + } + + public function refreshRows(): void + { + if (! $this->fornitoreId) { + $this->rows = []; + return; + } + + $term = trim($this->search); + $normalized = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper($term)) ?? ''; + + $products = Product::query() + ->with(['identifiers', 'offers']) + ->where('is_active', true) + ->where(function ($query): void { + $query->whereNull('default_fornitore_id') + ->orWhere('default_fornitore_id', (int) $this->fornitoreId) + ->orWhereHas('offers', fn($offerQuery) => $offerQuery->where('fornitore_id', (int) $this->fornitoreId)); + }) + ->when($term !== '', function ($query) use ($term, $normalized): void { + $query->where(function ($builder) use ($term, $normalized): void { + $builder->where('name', 'like', '%' . $term . '%') + ->orWhere('brand', 'like', '%' . $term . '%') + ->orWhere('model', 'like', '%' . $term . '%') + ->orWhere('internal_code', 'like', '%' . $term . '%'); + + if ($normalized !== '') { + $builder->orWhereHas('identifiers', function ($identifierQuery) use ($normalized): void { + $identifierQuery->where('normalized_code', $normalized) + ->orWhere('code_value', 'like', '%' . $normalized . '%'); + }); + } + }); + }) + ->limit(120) + ->get(); + + $this->rows = $products->map(function (Product $product): array { + $identifier = $product->identifiers + ->first(fn(ProductIdentifier $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitoreId) + ?? $product->identifiers->first(); + $offer = $product->offers + ->first(fn(ProductOffer $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitoreId) + ?? $product->offers->first(); + + return [ + 'id' => (int) $product->id, + 'internal_code' => (string) ($product->internal_code ?? ''), + 'label' => trim(implode(' ', array_filter([(string) $product->name, (string) ($product->brand ?? ''), (string) ($product->model ?? '')]))), + 'barcode' => (string) ($identifier->code_value ?? ''), + 'source' => (string) ($offer?->source_name ?: 'Catalogo interno'), + 'price' => $offer?->price_amount !== null ? number_format((float) $offer->price_amount, 2, ',', '.') . ' ' . (string) ($offer->currency ?? 'EUR') : '-', + 'external_url' => (string) ($offer?->referral_url ?: $offer?->external_url ?: $this->buildAmazonSearchUrl((string) $product->name)), + ]; + })->all(); + } + + public function getLavorazioniUrl(): string + { + return LavorazioniOperative::getUrl(['fornitore' => (int) $this->fornitoreId], panel: 'admin-filament'); + } + + public function getRubricaUrl(): string + { + return RubricaClienti::getUrl(['fornitore' => (int) $this->fornitoreId], panel: 'admin-filament'); + } + + protected function buildAmazonSearchUrl(string $term): string + { + $query = ['k' => trim($term)]; + $tag = trim((string) config('services.amazon.referral_tag', '')); + if ($tag !== '') { + $query['tag'] = $tag; + } + + return 'https://www.amazon.it/s?' . http_build_query($query); + } +} \ No newline at end of file diff --git a/app/Filament/Pages/Fornitore/RubricaClienti.php b/app/Filament/Pages/Fornitore/RubricaClienti.php new file mode 100644 index 0000000..97e3cfc --- /dev/null +++ b/app/Filament/Pages/Fornitore/RubricaClienti.php @@ -0,0 +1,218 @@ +> */ + public array $rows = []; + + /** @var array|null */ + public ?array $googleWorkspaceStatus = null; + + public static function canAccess(): bool + { + $user = Auth::user(); + + return $user instanceof User + && $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']); + } + + public function mount(): void + { + [$fornitore] = $this->resolveOperatoreContext(allowAdminWithoutSupplier: true); + + if (! $fornitore instanceof Fornitore) { + $this->missingAdminContext = true; + return; + } + + $this->fornitoreId = (int) $fornitore->id; + $this->fornitoreLabel = $this->getFornitoreLabel($fornitore); + $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus($fornitore); + $this->refreshRows(); + } + + public function updatedSearch(): void + { + $this->refreshRows(); + } + + public function refreshRows(): void + { + if (! $this->fornitoreId) { + $this->rows = []; + return; + } + + $term = Str::lower(trim($this->search)); + + $items = TicketIntervento::query() + ->with(['ticket.stabile', 'ticket.soggettoRichiedente', 'ticket.apertoDaUser']) + ->where('fornitore_id', $this->fornitoreId) + ->latest('updated_at') + ->limit(250) + ->get(); + + $grouped = []; + + foreach ($items as $intervento) { + $contact = $this->resolveCallerDataForRubrica($intervento); + $key = $contact['identity_key']; + + if ($key === '') { + $key = 'ticket-' . (int) $intervento->ticket_id; + } + + if ($term !== '') { + $haystack = Str::lower(implode(' ', [ + $contact['contatto'], + $contact['telefono'], + $contact['email'], + $contact['stabile'], + $contact['origine'], + ])); + + if (! Str::contains($haystack, $term)) { + continue; + } + } + + if (! isset($grouped[$key])) { + $grouped[$key] = [ + 'contatto' => $contact['contatto'], + 'telefono' => $contact['telefono'], + 'email' => $contact['email'], + 'stabile' => $contact['stabile'], + 'origine' => $contact['origine'], + 'ultimo_ticket_id' => (int) $intervento->ticket_id, + 'ultima_scheda_url' => TicketInterventoScheda::getUrl(['record' => (int) $intervento->id], panel: 'admin-filament'), + 'updated_at' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-', + 'totale_interventi' => 1, + ]; + continue; + } + + $grouped[$key]['totale_interventi']++; + } + + $this->rows = array_values($grouped); + } + + public function getLavorazioniUrl(): string + { + return LavorazioniOperative::getUrl(['fornitore' => (int) $this->fornitoreId], panel: 'admin-filament'); + } + + public function getProdottiUrl(): string + { + return ProdottiCatalogo::getUrl(['fornitore' => (int) $this->fornitoreId], panel: 'admin-filament'); + } + + public function getGoogleConnectUrl(): string + { + return route('oauth.google.redirect'); + } + + public function getGoogleDisconnectUrl(): string + { + return route('oauth.google.disconnect'); + } + + /** + * @return array + */ + protected function resolveCallerDataForRubrica(TicketIntervento $intervento): array + { + $ticket = $intervento->ticket; + $parsed = $this->extractCallerData((string) ($ticket?->descrizione ?? '')); + + $contatto = $parsed['contatto']; + $telefono = $parsed['telefono']; + $email = ''; + $origine = 'Descrizione ticket'; + + if ($ticket?->soggettoRichiedente) { + $soggetto = $ticket->soggettoRichiedente; + $contatto = trim((string) ($soggetto->ragione_sociale ?: trim(($soggetto->nome ?? '') . ' ' . ($soggetto->cognome ?? '')))) ?: $contatto; + $telefono = trim((string) ($soggetto->telefono ?? '')) ?: $telefono; + $email = trim((string) ($soggetto->email ?? '')); + $origine = 'Richiedente ticket'; + } elseif ($ticket?->apertoDaUser) { + $contatto = trim((string) ($ticket->apertoDaUser->name ?? '')) ?: $contatto; + $email = trim((string) ($ticket->apertoDaUser->email ?? '')); + $origine = 'Utente apertura ticket'; + } + + $identityKey = ''; + if ($ticket?->soggetto_richiedente_id) { + $identityKey = 'soggetto-' . (int) $ticket->soggetto_richiedente_id; + } elseif ($email !== '') { + $identityKey = 'email-' . Str::lower($email); + } elseif ($telefono !== '') { + $identityKey = 'phone-' . preg_replace('/\D+/', '', $telefono); + } + + return [ + 'contatto' => $contatto !== '' ? $contatto : 'Contatto non identificato', + 'telefono' => $telefono, + 'email' => $email, + 'origine' => $origine, + 'stabile' => (string) ($ticket?->stabile?->denominazione ?? '-'), + 'identity_key' => $identityKey, + ]; + } + + /** + * @return array|null + */ + protected function resolveGoogleWorkspaceStatus(Fornitore $fornitore): ?array + { + $amministratore = $fornitore->amministratore; + if (! $amministratore) { + return null; + } + + $oauth = (array) (($amministratore->impostazioni['google']['oauth'] ?? null) ?: []); + + return [ + 'connected' => (bool) ($oauth['connected'] ?? false), + 'email' => (string) ($oauth['email'] ?? ''), + 'name' => (string) ($oauth['name'] ?? ''), + 'connected_at' => (string) ($oauth['connected_at'] ?? ''), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php index 370a0eb..f187a2c 100644 --- a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php +++ b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php @@ -4,9 +4,13 @@ use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext; use App\Models\Fornitore; use App\Models\FornitoreDipendente; +use App\Models\Product; +use App\Models\ProductIdentifier; +use App\Models\ProductOffer; use App\Models\Ticket; use App\Models\TicketAttachment; use App\Models\TicketIntervento; +use App\Models\TicketInterventoSessione; use App\Models\User; use BackedEnum; use Filament\Notifications\Notification; @@ -65,6 +69,14 @@ class TicketInterventoScheda extends Page /** @var array */ public array $articoliUtilizzati = ['', '', '']; + /** @var array> */ + public array $materialiUtilizzati = []; + + public string $materialSearch = ''; + + /** @var array> */ + public array $materialSuggestions = []; + public string $apparatoMarca = ''; public string $apparatoModello = ''; @@ -94,6 +106,15 @@ class TicketInterventoScheda extends Page public ?array $attachmentPreview = null; + /** @var array> */ + public array $sessionRows = []; + + /** @var array|null */ + public ?array $lastGeoSnapshot = null; + + /** @var array|null */ + public ?array $googleWorkspaceStatus = null; + public static function canAccess(): bool { $user = Auth::user(); @@ -183,11 +204,41 @@ public function assignDipendente(): void public function startIntervento(): void { + $this->startInterventoWithGeo([]); + } + + public function startInterventoWithGeo(array $geo = []): void + { + $activeSession = $this->getActiveSession(); + if ($activeSession instanceof TicketInterventoSessione) { + Notification::make()->title('Esiste gia una sessione aperta')->body('Ferma prima la sessione attiva o registra un checkpoint.')->warning()->send(); + return; + } + + $startedAt = now(); + $geoData = $this->normalizeGeoPayload($geo); + + TicketInterventoSessione::query()->create([ + 'ticket_intervento_id' => (int) $this->intervento->id, + 'ticket_id' => (int) $this->intervento->ticket_id, + 'fornitore_id' => (int) $this->fornitore->id, + 'fornitore_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id, + 'created_by_user_id' => Auth::id(), + 'session_type' => 'work', + 'note' => 'Avvio intervento', + 'started_at' => $startedAt, + 'start_latitude' => $geoData['lat'], + 'start_longitude' => $geoData['lng'], + 'start_accuracy_meters' => $geoData['accuracy'], + 'start_source' => $geoData['source'], + 'geo_payload' => $geoData['payload'], + ]); + $this->intervento->update([ 'stato' => 'in_corso', 'preso_in_carico_da_user_id' => Auth::id(), 'eseguito_da_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id, - 'iniziato_at' => $this->intervento->iniziato_at ?? now(), + 'iniziato_at' => $this->intervento->iniziato_at ?? $startedAt, 'terminato_at' => null, ]); @@ -195,29 +246,78 @@ public function startIntervento(): void $this->reloadIntervento(); - Notification::make()->title('Intervento preso in carico')->success()->send(); + Notification::make()->title('Intervento preso in carico')->body($this->formatGeoNotification($geoData, 'Posizione avvio'))->success()->send(); } public function stopIntervento(): void { - if (! $this->intervento->iniziato_at) { + $this->stopInterventoWithGeo([]); + } + + public function stopInterventoWithGeo(array $geo = []): void + { + $activeSession = $this->getActiveSession(); + if (! $activeSession instanceof TicketInterventoSessione) { Notification::make()->title('Avvia prima il timer di intervento')->warning()->send(); return; } $endedAt = now(); - $minutes = max(1, $this->intervento->iniziato_at->diffInMinutes($endedAt)); + $minutes = max(1, $activeSession->started_at?->diffInMinutes($endedAt) ?? 1); + $geoData = $this->normalizeGeoPayload($geo); + + $activeSession->update([ + 'ended_at' => $endedAt, + 'duration_minutes' => $minutes, + 'closed_by_user_id' => Auth::id(), + 'end_latitude' => $geoData['lat'], + 'end_longitude' => $geoData['lng'], + 'end_accuracy_meters' => $geoData['accuracy'], + 'end_source' => $geoData['source'], + 'geo_payload' => array_merge((array) ($activeSession->geo_payload ?? []), $geoData['payload']), + ]); + + $totalMinutes = $this->calculateTrackedMinutes(); $this->intervento->update([ 'terminato_at' => $endedAt, - 'tempo_minuti' => $minutes, + 'tempo_minuti' => $totalMinutes, 'stato' => $this->intervento->stato === 'assegnato' ? 'in_corso' : $this->intervento->stato, ]); - $this->tempoMinuti = $minutes; + $this->tempoMinuti = $totalMinutes; $this->reloadIntervento(); - Notification::make()->title('Timer intervento fermato')->body('Tempo rilevato: ' . $minutes . ' minuti.')->success()->send(); + Notification::make()->title('Timer intervento fermato')->body('Sessione: ' . $minutes . ' minuti. Totale rilevato: ' . $totalMinutes . ' minuti. ' . $this->formatGeoNotification($geoData, 'Posizione stop'))->success()->send(); + } + + public function addPresenceCheckpoint(array $geo = []): void + { + $geoData = $this->normalizeGeoPayload($geo); + $stamp = now(); + + TicketInterventoSessione::query()->create([ + 'ticket_intervento_id' => (int) $this->intervento->id, + 'ticket_id' => (int) $this->intervento->ticket_id, + 'fornitore_id' => (int) $this->fornitore->id, + 'fornitore_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id, + 'created_by_user_id' => Auth::id(), + 'closed_by_user_id' => Auth::id(), + 'session_type' => 'checkpoint', + 'note' => 'Checkpoint presenza', + 'started_at' => $stamp, + 'ended_at' => $stamp, + 'duration_minutes' => 0, + 'start_latitude' => $geoData['lat'], + 'start_longitude' => $geoData['lng'], + 'start_accuracy_meters' => $geoData['accuracy'], + 'start_source' => $geoData['source'], + 'geo_payload' => $geoData['payload'], + ]); + + $this->reloadIntervento(); + + Notification::make()->title('Checkpoint presenza registrato')->body($this->formatGeoNotification($geoData, 'Posizione checkpoint'))->success()->send(); } public function updatedFotoLavoro(): void @@ -230,6 +330,76 @@ public function updatedDocumentiLavoro(): void $this->syncRapportoDescriptionSlots(); } + public function updatedMaterialSearch(): void + { + $this->refreshMaterialSuggestions(); + } + + public function addSuggestedMaterial(int $productId): void + { + $product = Product::query() + ->with(['identifiers', 'offers' => fn($query) => $query->where(function ($builder): void { + $builder->whereNull('fornitore_id') + ->orWhere('fornitore_id', (int) $this->fornitore->id); + })->orderBy('is_internal', 'desc')->orderBy('price_amount')]) + ->find($productId); + + if (! $product instanceof Product) { + return; + } + + $identifier = $product->identifiers + ->first(fn(ProductIdentifier $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitore->id) + ?? $product->identifiers->first(); + + $offer = $product->offers->first(); + + $this->materialiUtilizzati[] = [ + 'product_id' => (int) $product->id, + 'descrizione' => trim(implode(' ', array_filter([(string) $product->name, (string) ($product->brand ?? ''), (string) ($product->model ?? '')]))), + 'barcode' => (string) ($identifier->code_value ?? ''), + 'qty' => 1, + 'unit_price' => $offer?->price_amount !== null ? (float) $offer->price_amount : null, + 'currency' => (string) ($offer->currency ?? 'EUR'), + 'source_type' => 'catalog', + 'source_label' => (string) ($offer?->source_name ?: 'Catalogo fornitore'), + 'external_url' => (string) ($offer?->referral_url ?: $offer?->external_url ?: $this->buildAmazonSearchUrl((string) $product->name)), + ]; + + $this->materialSearch = ''; + $this->materialSuggestions = []; + } + + public function addManualMaterial(): void + { + $term = trim($this->materialSearch); + if ($term === '') { + Notification::make()->title('Inserisci un prodotto o barcode')->warning()->send(); + return; + } + + $this->materialiUtilizzati[] = [ + 'product_id' => null, + 'descrizione' => $term, + 'barcode' => '', + 'qty' => 1, + 'unit_price' => null, + 'currency' => 'EUR', + 'source_type' => 'manual', + 'source_label' => 'Inserimento manuale', + 'external_url' => $this->buildAmazonSearchUrl($term), + ]; + + $this->materialSearch = ''; + $this->materialSuggestions = []; + } + + public function removeMateriale(int $index): void + { + unset($this->materialiUtilizzati[$index]); + $this->materialiUtilizzati = array_values($this->materialiUtilizzati); + } + public function saveRapporto(): void { $validated = $this->validate([ @@ -237,6 +407,16 @@ public function saveRapporto(): void 'tempoMinuti' => ['nullable', 'integer', 'min:1', 'max:1440'], 'articoliUtilizzati' => ['nullable', 'array'], 'articoliUtilizzati.*' => ['nullable', 'string', 'max:120'], + 'materialiUtilizzati' => ['nullable', 'array'], + 'materialiUtilizzati.*.product_id' => ['nullable', 'integer'], + 'materialiUtilizzati.*.descrizione' => ['required', 'string', 'max:255'], + 'materialiUtilizzati.*.barcode' => ['nullable', 'string', 'max:120'], + 'materialiUtilizzati.*.qty' => ['nullable', 'numeric', 'min:0.01', 'max:9999'], + 'materialiUtilizzati.*.unit_price' => ['nullable', 'numeric', 'min:0', 'max:999999.99'], + 'materialiUtilizzati.*.currency' => ['nullable', 'string', 'max:8'], + 'materialiUtilizzati.*.source_type' => ['nullable', 'string', 'max:32'], + 'materialiUtilizzati.*.source_label' => ['nullable', 'string', 'max:120'], + 'materialiUtilizzati.*.external_url' => ['nullable', 'string', 'max:1000'], 'fotoLavoro' => ['nullable', 'array', 'max:10'], 'fotoLavoro.*' => ['nullable', 'image', 'max:10240'], 'documentiLavoro' => ['nullable', 'array', 'max:10'], @@ -280,12 +460,46 @@ public function saveRapporto(): void 'eseguito_da_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id, ]; + $materiali = collect($validated['materialiUtilizzati'] ?? []) + ->map(function ($item): array { + return [ + 'product_id' => isset($item['product_id']) ? (int) $item['product_id'] : null, + 'descrizione' => trim((string) ($item['descrizione'] ?? '')), + 'barcode' => trim((string) ($item['barcode'] ?? '')), + 'qty' => isset($item['qty']) && is_numeric($item['qty']) ? (float) $item['qty'] : 1.0, + 'unit_price' => isset($item['unit_price']) && is_numeric($item['unit_price']) ? round((float) $item['unit_price'], 2) : null, + 'currency' => trim((string) ($item['currency'] ?? 'EUR')) ?: 'EUR', + 'source_type' => trim((string) ($item['source_type'] ?? 'manual')) ?: 'manual', + 'source_label' => trim((string) ($item['source_label'] ?? '')), + 'external_url' => trim((string) ($item['external_url'] ?? '')), + ]; + }) + ->filter(fn(array $item): bool => $item['descrizione'] !== '') + ->values() + ->all(); + $articoli = collect($validated['articoliUtilizzati'] ?? []) ->map(fn($item) => trim((string) $item)) ->filter() ->values() ->all(); + if ($materiali !== []) { + $payload['materiali_utilizzati'] = $materiali; + + $materialiLabels = collect($materiali) + ->map(function (array $item): string { + $label = $item['descrizione']; + $qty = (float) ($item['qty'] ?? 1); + + return $label . ($qty > 0 ? (' x' . rtrim(rtrim(number_format($qty, 2, '.', ''), '0'), '.')) : ''); + }) + ->values() + ->all(); + + $articoli = array_values(array_unique(array_merge($articoli, $materialiLabels))); + } + if ($articoli !== []) { $payload['articoli_utilizzati'] = $articoli; } @@ -375,6 +589,9 @@ public function saveRapporto(): void $this->fotoLavoro = []; $this->documentiLavoro = []; $this->descrizioneFile = []; + $this->materialiUtilizzati = []; + $this->materialSearch = ''; + $this->materialSuggestions = []; $this->messaggioVeloce = ''; $this->proformaCodice = ''; $this->proformaNote = ''; @@ -439,6 +656,26 @@ public function getCollaboratoriUrl(): string return Collaboratori::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament'); } + public function getRubricaUrl(): string + { + return RubricaClienti::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament'); + } + + public function getProdottiUrl(): string + { + return ProdottiCatalogo::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament'); + } + + public function getGoogleConnectUrl(): string + { + return route('oauth.google.redirect'); + } + + public function getGoogleDisconnectUrl(): string + { + return route('oauth.google.disconnect'); + } + public function canAssignDipendente(): bool { return ! ($this->dipendente instanceof FornitoreDipendente) && count($this->dipendentiOptions) > 0; @@ -544,6 +781,7 @@ protected function reloadIntervento(): void 'ticket.assegnatoAUser', 'ticket.assegnatoAFornitore', 'eseguitoDaDipendente', + 'sessioni', ]); $this->intervento->refresh(); @@ -557,6 +795,7 @@ protected function reloadIntervento(): void 'ticket.assegnatoAUser', 'ticket.assegnatoAFornitore', 'eseguitoDaDipendente', + 'sessioni', ]); $this->caller = $this->resolveCallerData($this->intervento->ticket); @@ -595,11 +834,216 @@ protected function reloadIntervento(): void $this->dipendenteId = (int) ($this->intervento->eseguito_da_dipendente_id ?? 0) ?: null; $this->rapportoFornitore = (string) ($this->intervento->rapporto_fornitore ?? ''); - $this->tempoMinuti = $this->intervento->tempo_minuti ? (int) $this->intervento->tempo_minuti : null; + $this->tempoMinuti = $this->calculateTrackedMinutes(); $this->articoliUtilizzati = array_pad((array) ($this->intervento->articoli_utilizzati ?? []), 3, ''); + $this->materialiUtilizzati = collect((array) ($this->intervento->materiali_utilizzati ?? [])) + ->map(fn(array $item): array => [ + 'product_id' => isset($item['product_id']) ? (int) $item['product_id'] : null, + 'descrizione' => (string) ($item['descrizione'] ?? ''), + 'barcode' => (string) ($item['barcode'] ?? ''), + 'qty' => isset($item['qty']) ? (float) $item['qty'] : 1, + 'unit_price' => isset($item['unit_price']) ? (float) $item['unit_price'] : null, + 'currency' => (string) ($item['currency'] ?? 'EUR'), + 'source_type' => (string) ($item['source_type'] ?? 'manual'), + 'source_label' => (string) ($item['source_label'] ?? ''), + 'external_url' => (string) ($item['external_url'] ?? ''), + ]) + ->values() + ->all(); + $this->sessionRows = $this->intervento->sessioni + ->sortByDesc(fn(TicketInterventoSessione $sessione) => $sessione->started_at?->getTimestamp() ?? 0) + ->values() + ->map(function (TicketInterventoSessione $sessione): array { + return [ + 'id' => (int) $sessione->id, + 'type' => (string) $sessione->session_type, + 'note' => (string) ($sessione->note ?? ''), + 'started_at' => optional($sessione->started_at)->format('d/m/Y H:i:s') ?: '-', + 'ended_at' => optional($sessione->ended_at)->format('d/m/Y H:i:s') ?: 'Aperta', + 'duration_minutes' => $sessione->duration_minutes, + 'start_geo' => $this->formatGeoPoint($sessione->start_latitude, $sessione->start_longitude, $sessione->start_accuracy_meters), + 'end_geo' => $this->formatGeoPoint($sessione->end_latitude, $sessione->end_longitude, $sessione->end_accuracy_meters), + ]; + }) + ->all(); + $latestSession = $this->intervento->sessioni + ->sortByDesc(fn(TicketInterventoSessione $sessione) => $sessione->updated_at?->getTimestamp() ?? 0) + ->first(); + $this->lastGeoSnapshot = $latestSession instanceof TicketInterventoSessione + ? [ + 'label' => (string) ($latestSession->session_type === 'checkpoint' ? 'Ultimo checkpoint' : 'Ultimo evento sessione'), + 'start_geo' => $this->formatGeoPoint($latestSession->start_latitude, $latestSession->start_longitude, $latestSession->start_accuracy_meters), + 'end_geo' => $this->formatGeoPoint($latestSession->end_latitude, $latestSession->end_longitude, $latestSession->end_accuracy_meters), + 'updated_at' => optional($latestSession->updated_at)->format('d/m/Y H:i:s') ?: '-', + ] + : null; + $this->googleWorkspaceStatus = $this->resolveGoogleWorkspaceStatus(); $this->qrToken = ''; } + protected function getActiveSession(): ?TicketInterventoSessione + { + return TicketInterventoSessione::query() + ->where('ticket_intervento_id', (int) $this->intervento->id) + ->where('session_type', 'work') + ->whereNull('ended_at') + ->latest('started_at') + ->first(); + } + + protected function calculateTrackedMinutes(): ?int + { + $total = (int) TicketInterventoSessione::query() + ->where('ticket_intervento_id', (int) $this->intervento->id) + ->sum('duration_minutes'); + + $activeSession = $this->getActiveSession(); + if ($activeSession instanceof TicketInterventoSessione && $activeSession->started_at) { + $total += max(1, $activeSession->started_at->diffInMinutes(now())); + } + + return $total > 0 ? $total : ($this->intervento->tempo_minuti ? (int) $this->intervento->tempo_minuti : null); + } + + /** + * @return array{lat:?float,lng:?float,accuracy:?int,source:string,payload:array} + */ + protected function normalizeGeoPayload(array $geo): array + { + $lat = isset($geo['lat']) && is_numeric($geo['lat']) ? round((float) $geo['lat'], 7) : null; + $lng = isset($geo['lng']) && is_numeric($geo['lng']) ? round((float) $geo['lng'], 7) : null; + $accuracy = isset($geo['accuracy']) && is_numeric($geo['accuracy']) ? max(0, (int) round((float) $geo['accuracy'])) : null; + $source = trim((string) ($geo['source'] ?? 'browser')) ?: 'browser'; + $error = trim((string) ($geo['error'] ?? '')); + + return [ + 'lat' => $lat, + 'lng' => $lng, + 'accuracy' => $accuracy, + 'source' => $source, + 'payload' => array_filter([ + 'lat' => $lat, + 'lng' => $lng, + 'accuracy' => $accuracy, + 'source' => $source, + 'error' => $error !== '' ? $error : null, + 'captured_at' => now()->toDateTimeString(), + ], fn($value) => $value !== null && $value !== ''), + ]; + } + + protected function formatGeoNotification(array $geoData, string $prefix): string + { + if ($geoData['lat'] === null || $geoData['lng'] === null) { + return $prefix . ': geolocalizzazione non disponibile dal browser.'; + } + + $message = $prefix . ': ' . number_format((float) $geoData['lat'], 5, '.', '') . ', ' . number_format((float) $geoData['lng'], 5, '.', ''); + if (is_int($geoData['accuracy'])) { + $message .= ' ±' . $geoData['accuracy'] . ' m'; + } + + return $message; + } + + protected function formatGeoPoint(mixed $lat, mixed $lng, mixed $accuracy): string + { + if (! is_numeric($lat) || ! is_numeric($lng)) { + return '-'; + } + + $label = number_format((float) $lat, 5, '.', '') . ', ' . number_format((float) $lng, 5, '.', ''); + if (is_numeric($accuracy)) { + $label .= ' ±' . (int) $accuracy . ' m'; + } + + return $label; + } + + protected function refreshMaterialSuggestions(): void + { + $term = trim($this->materialSearch); + if ($term === '') { + $this->materialSuggestions = []; + return; + } + + $normalized = preg_replace('/[^A-Za-z0-9]+/', '', strtoupper($term)) ?? ''; + + $products = Product::query() + ->with(['identifiers', 'offers' => fn($query) => $query->orderBy('is_internal', 'desc')->orderBy('price_amount')]) + ->where('is_active', true) + ->where(function ($query) use ($term, $normalized): void { + $query->where('name', 'like', '%' . $term . '%') + ->orWhere('brand', 'like', '%' . $term . '%') + ->orWhere('model', 'like', '%' . $term . '%') + ->orWhere('internal_code', 'like', '%' . $term . '%'); + + if ($normalized !== '') { + $query->orWhereHas('identifiers', function ($identifierQuery) use ($normalized): void { + $identifierQuery->where('normalized_code', $normalized) + ->orWhere('code_value', 'like', '%' . $normalized . '%'); + }); + } + }) + ->where(function ($query): void { + $query->whereNull('default_fornitore_id') + ->orWhere('default_fornitore_id', (int) $this->fornitore->id) + ->orWhereHas('offers', fn($offerQuery) => $offerQuery->where('fornitore_id', (int) $this->fornitore->id)); + }) + ->limit(8) + ->get(); + + $this->materialSuggestions = $products->map(function (Product $product): array { + $identifier = $product->identifiers + ->first(fn(ProductIdentifier $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitore->id) + ?? $product->identifiers->first(); + $offer = $product->offers + ->first(fn(ProductOffer $item): bool => (int) ($item->fornitore_id ?? 0) === (int) $this->fornitore->id) + ?? $product->offers->first(); + + return [ + 'id' => (int) $product->id, + 'label' => trim(implode(' ', array_filter([(string) $product->name, (string) ($product->brand ?? ''), (string) ($product->model ?? '')]))), + 'barcode' => (string) ($identifier->code_value ?? ''), + 'price' => $offer?->price_amount !== null ? number_format((float) $offer->price_amount, 2, ',', '.') . ' ' . (string) ($offer->currency ?? 'EUR') : null, + 'source' => (string) ($offer?->source_name ?: 'Catalogo'), + 'external_url' => (string) ($offer?->referral_url ?: $offer?->external_url ?: $this->buildAmazonSearchUrl((string) $product->name)), + ]; + })->all(); + } + + protected function buildAmazonSearchUrl(string $term): string + { + $query = ['k' => trim($term)]; + $tag = trim((string) config('services.amazon.referral_tag', '')); + if ($tag !== '') { + $query['tag'] = $tag; + } + + return 'https://www.amazon.it/s?' . http_build_query($query); + } + + /** + * @return array|null + */ + protected function resolveGoogleWorkspaceStatus(): ?array + { + $amministratore = $this->fornitore->amministratore; + if (! $amministratore) { + return null; + } + + $oauth = (array) (($amministratore->impostazioni['google']['oauth'] ?? null) ?: []); + + return [ + 'connected' => (bool) ($oauth['connected'] ?? false), + 'email' => (string) ($oauth['email'] ?? ''), + 'name' => (string) ($oauth['name'] ?? ''), + 'connected_at' => (string) ($oauth['connected_at'] ?? ''), + ]; + } + /** * @return array{contatto:string,telefono:string,email:string,problema:string,sorgente:string,riferimento:string} */ diff --git a/app/Filament/Pages/Supporto/TicketMobile.php b/app/Filament/Pages/Supporto/TicketMobile.php index 848b1c5..cecdf8a 100644 --- a/app/Filament/Pages/Supporto/TicketMobile.php +++ b/app/Filament/Pages/Supporto/TicketMobile.php @@ -80,8 +80,13 @@ class TicketMobile extends Page /** @var array */ public array $newTicketAttachmentDescriptions = []; + /** @var array */ + public array $newTicketUploadCodes = []; + public string $newTicketDraftProtocol = ''; + public int $newTicketDraftSequence = 1; + public ?string $newTicketFotoNote = null; /** @var \Illuminate\Support\Collection */ @@ -640,17 +645,24 @@ public function updatedPendingTicketAttachments(): void public function removeNewTicketFile(int $index): void { + $file = null; $cameraCount = count($this->newTicketCameraShots); if ($index < $cameraCount) { + $file = $this->newTicketCameraShots[$index] ?? null; unset($this->newTicketCameraShots[$index]); $this->newTicketCameraShots = array_values($this->newTicketCameraShots); } else { $docIndex = $index - $cameraCount; + $file = $this->newTicketAttachments[$docIndex] ?? null; unset($this->newTicketAttachments[$docIndex]); $this->newTicketAttachments = array_values($this->newTicketAttachments); } + if (is_object($file)) { + unset($this->newTicketUploadCodes[$this->resolveUploadQueueKey($file, $index)]); + } + $this->syncAttachmentDescriptionSlots(); } @@ -670,7 +682,7 @@ public function getSelectedUploadsProperty(): array $name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index)); $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); $isImage = str_starts_with($mime, 'image/'); - $protocol = $this->buildDraftProtocolCode((int) $index, $isImage); + $protocol = $this->resolveDraftUploadCode($file, (int) $index, $isImage); $previewUrl = null; if ($isImage && method_exists($file, 'temporaryUrl')) { @@ -683,7 +695,7 @@ public function getSelectedUploadsProperty(): array $rows[] = [ 'index' => (int) $index, - 'name' => $this->buildDraftUploadDisplayName((int) $index, $name, $isImage), + 'name' => $this->buildDraftUploadDisplayName($protocol, $name), 'original_name' => $name, 'protocol' => $protocol, 'mime' => $mime, @@ -844,14 +856,13 @@ private function appendPendingUploads(string $source): void $accepted = array_slice($pending, 0, $available); $this->{$targetProperty} = array_values(array_merge($this->{$targetProperty}, $accepted)); $this->{$pendingProperty} = []; + $this->assignDraftUploadCodes($accepted, $currentTotal); $this->syncAttachmentDescriptionSlots(); $discarded = count($pending) - count($accepted); if (count($accepted) > 0) { $queueTotal = count($this->newTicketCameraShots) + count($this->newTicketAttachments); - $this->dispatch('ticket-mobile-local-previews-clear', source: $source); - Notification::make() ->title('File accodati alla bozza ticket') ->body(count($accepted) . ' file aggiunti. Totale attuale in coda: ' . $queueTotal . '.') @@ -875,19 +886,68 @@ private function resetDraftUploadState(): void $this->pendingTicketCameraShots = []; $this->pendingTicketAttachments = []; $this->newTicketAttachmentDescriptions = []; + $this->newTicketUploadCodes = []; $this->newTicketDraftProtocol = 'TM-' . now()->format('Ymd-His'); + $this->newTicketDraftSequence = 1; } - private function buildDraftProtocolCode(int $index, bool $isImage): string + private function buildDraftProtocolCode(int $sequence, bool $isImage): string { - return $this->newTicketDraftProtocol . '-' . ($isImage ? 'F' : 'A') . str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT); + return $this->newTicketDraftProtocol . '-' . ($isImage ? 'F' : 'A') . str_pad((string) $sequence, 2, '0', STR_PAD_LEFT); } - private function buildDraftUploadDisplayName(int $index, string $originalName, bool $isImage): string + private function buildDraftUploadDisplayName(string $protocol, string $originalName): string { $extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION)); - return $this->buildDraftProtocolCode($index, $isImage) . ($extension !== '' ? '.' . $extension : ''); + return $protocol . ($extension !== '' ? '.' . $extension : ''); + } + + /** + * @param array $files + */ + private function assignDraftUploadCodes(array $files, int $fallbackIndexStart): void + { + foreach ($files as $offset => $file) { + if (! is_object($file)) { + continue; + } + + $key = $this->resolveUploadQueueKey($file, $fallbackIndexStart + $offset); + if (isset($this->newTicketUploadCodes[$key])) { + continue; + } + + $mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream'); + $isImage = str_starts_with($mime, 'image/'); + + $this->newTicketUploadCodes[$key] = $this->buildDraftProtocolCode($this->newTicketDraftSequence, $isImage); + $this->newTicketDraftSequence++; + } + } + + private function resolveDraftUploadCode(object $file, int $fallbackIndex, bool $isImage): string + { + $key = $this->resolveUploadQueueKey($file, $fallbackIndex); + + if (! isset($this->newTicketUploadCodes[$key])) { + $this->newTicketUploadCodes[$key] = $this->buildDraftProtocolCode($this->newTicketDraftSequence, $isImage); + $this->newTicketDraftSequence++; + } + + return $this->newTicketUploadCodes[$key]; + } + + private function resolveUploadQueueKey(object $file, int $fallbackIndex): string + { + if (method_exists($file, 'getFilename')) { + $filename = trim((string) $file->getFilename()); + if ($filename !== '') { + return $filename; + } + } + + return spl_object_hash($file) ?: 'upload-' . $fallbackIndex; } private function buildStoredUploadDisplayName(Ticket $ticket, int $index, object $file): string diff --git a/app/Http/Controllers/Integrations/GoogleOAuthController.php b/app/Http/Controllers/Integrations/GoogleOAuthController.php index 5f03c72..36e5bc9 100644 --- a/app/Http/Controllers/Integrations/GoogleOAuthController.php +++ b/app/Http/Controllers/Integrations/GoogleOAuthController.php @@ -3,6 +3,8 @@ use App\Http\Controllers\Controller; use App\Models\Amministratore; +use App\Models\Fornitore; +use App\Models\FornitoreDipendente; use App\Models\Stabile; use App\Models\User; use App\Support\StabileContext; @@ -184,6 +186,53 @@ private function resolveAmministratore(): ?Amministratore } } + if (! $amministratore instanceof Amministratore && $user->hasRole('fornitore')) { + $fornitore = $this->resolveCurrentUserSupplier($user); + if ($fornitore instanceof Fornitore && $fornitore->amministratore instanceof Amministratore) { + $amministratore = $fornitore->amministratore; + } + } + return $amministratore instanceof Amministratore ? $amministratore : null; } + + private function resolveCurrentUserSupplier(User $user): ?Fornitore + { + $email = mb_strtolower(trim((string) $user->email)); + if ($email === '') { + return $this->resolveCurrentUserSupplierFromCollaboratore($user); + } + + $fornitore = Fornitore::query() + ->whereRaw('LOWER(email) = ?', [$email]) + ->first(); + + if ($fornitore instanceof Fornitore) { + return $fornitore; + } + + return $this->resolveCurrentUserSupplierFromCollaboratore($user); + } + + private function resolveCurrentUserSupplierFromCollaboratore(User $user): ?Fornitore + { + $dipendente = FornitoreDipendente::query() + ->where('attivo', true) + ->where(function ($query) use ($user): void { + $query->where('user_id', (int) $user->id); + + $email = mb_strtolower(trim((string) $user->email)); + if ($email !== '') { + $query->orWhereRaw('LOWER(email) = ?', [$email]); + } + }) + ->latest('id') + ->first(); + + if (! $dipendente instanceof FornitoreDipendente) { + return null; + } + + return Fornitore::query()->find((int) $dipendente->fornitore_id); + } } diff --git a/app/Models/TicketIntervento.php b/app/Models/TicketIntervento.php index ac7ad0c..8f6f477 100644 --- a/app/Models/TicketIntervento.php +++ b/app/Models/TicketIntervento.php @@ -20,6 +20,7 @@ class TicketIntervento extends Model 'note_amministratore', 'rapporto_fornitore', 'articoli_utilizzati', + 'materiali_utilizzati', 'tempo_minuti', 'iniziato_at', 'terminato_at', @@ -41,6 +42,7 @@ class TicketIntervento extends Model protected $casts = [ 'articoli_utilizzati' => 'array', + 'materiali_utilizzati' => 'array', 'iniziato_at' => 'datetime', 'terminato_at' => 'datetime', 'qr_scansionato_at' => 'datetime', @@ -79,6 +81,11 @@ public function eseguitoDaDipendente() return $this->belongsTo(FornitoreDipendente::class, 'eseguito_da_dipendente_id'); } + public function sessioni() + { + return $this->hasMany(TicketInterventoSessione::class, 'ticket_intervento_id'); + } + public function getCompletabileAttribute(): bool { return $this->check_foto_ok && $this->check_qr_ok && $this->check_admin_ok; diff --git a/app/Models/TicketInterventoSessione.php b/app/Models/TicketInterventoSessione.php new file mode 100644 index 0000000..589a039 --- /dev/null +++ b/app/Models/TicketInterventoSessione.php @@ -0,0 +1,80 @@ + 'datetime', + 'ended_at' => 'datetime', + 'duration_minutes' => 'integer', + 'start_latitude' => 'decimal:7', + 'start_longitude' => 'decimal:7', + 'start_accuracy_meters' => 'integer', + 'end_latitude' => 'decimal:7', + 'end_longitude' => 'decimal:7', + 'end_accuracy_meters' => 'integer', + 'geo_payload' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function intervento() + { + return $this->belongsTo(TicketIntervento::class, 'ticket_intervento_id'); + } + + public function ticket() + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } + + public function fornitore() + { + return $this->belongsTo(Fornitore::class, 'fornitore_id'); + } + + public function dipendente() + { + return $this->belongsTo(FornitoreDipendente::class, 'fornitore_dipendente_id'); + } + + public function createdByUser() + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function closedByUser() + { + return $this->belongsTo(User::class, 'closed_by_user_id'); + } +} \ No newline at end of file diff --git a/app/Services/Support/TicketAttachmentUploadService.php b/app/Services/Support/TicketAttachmentUploadService.php index 43057df..ae13ee7 100644 --- a/app/Services/Support/TicketAttachmentUploadService.php +++ b/app/Services/Support/TicketAttachmentUploadService.php @@ -47,7 +47,9 @@ public function store(object $file, string $directory, array $options = []): arr if (str_starts_with($mime, 'image/')) { $metadata = $this->extractImageMetadata($path); - $this->optimizeImage($path, $mime, $metadata); + if ($this->shouldOptimizeImage($metadata, $options)) { + $this->optimizeImage($path, $mime, $metadata); + } $mime = TicketAttachment::normalizeMimeType($mime, $originalName, $path); } @@ -69,6 +71,23 @@ public function store(object $file, string $directory, array $options = []): arr ]; } + private function shouldOptimizeImage(array $metadata, array $options = []): bool + { + if (array_key_exists('optimize_image', $options)) { + return (bool) $options['optimize_image']; + } + + return ! $this->hasEmbeddedImageMetadata($metadata); + } + + private function hasEmbeddedImageMetadata(array $metadata): bool + { + return filled($metadata['exif_datetime'] ?? null) + || filled($metadata['device'] ?? null) + || isset($metadata['gps_decimal']) + || ((int) ($metadata['orientation'] ?? 1)) !== 1; + } + private function optimizeImage(string $path, string $mime, array $metadata = []): void { $absolutePath = Storage::disk('public')->path($path); diff --git a/database/migrations/2026_04_06_160000_add_sessions_and_materials_to_ticket_interventi.php b/database/migrations/2026_04_06_160000_add_sessions_and_materials_to_ticket_interventi.php new file mode 100644 index 0000000..96b2c7a --- /dev/null +++ b/database/migrations/2026_04_06_160000_add_sessions_and_materials_to_ticket_interventi.php @@ -0,0 +1,60 @@ +id(); + $table->foreignId('ticket_intervento_id')->constrained('ticket_interventi')->cascadeOnDelete(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('fornitore_id')->constrained('fornitori')->cascadeOnDelete(); + $table->foreignId('fornitore_dipendente_id')->nullable()->constrained('fornitore_dipendenti')->nullOnDelete(); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('closed_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('session_type', 24)->default('work')->index(); + $table->string('note', 255)->nullable(); + $table->timestamp('started_at')->nullable()->index(); + $table->timestamp('ended_at')->nullable()->index(); + $table->unsignedInteger('duration_minutes')->nullable(); + $table->decimal('start_latitude', 10, 7)->nullable(); + $table->decimal('start_longitude', 10, 7)->nullable(); + $table->unsignedInteger('start_accuracy_meters')->nullable(); + $table->string('start_source', 24)->nullable(); + $table->decimal('end_latitude', 10, 7)->nullable(); + $table->decimal('end_longitude', 10, 7)->nullable(); + $table->unsignedInteger('end_accuracy_meters')->nullable(); + $table->string('end_source', 24)->nullable(); + $table->json('geo_payload')->nullable(); + $table->timestamps(); + + $table->index(['fornitore_id', 'started_at'], 'ticket_int_sessioni_forn_start_idx'); + $table->index(['ticket_intervento_id', 'session_type'], 'ticket_int_sessioni_type_idx'); + }); + } + + Schema::table('ticket_interventi', function (Blueprint $table) { + if (! Schema::hasColumn('ticket_interventi', 'materiali_utilizzati')) { + $table->json('materiali_utilizzati')->nullable()->after('articoli_utilizzati'); + } + }); + } + + public function down(): void + { + Schema::table('ticket_interventi', function (Blueprint $table) { + if (Schema::hasColumn('ticket_interventi', 'materiali_utilizzati')) { + $table->dropColumn('materiali_utilizzati'); + } + }); + + if (Schema::hasTable('ticket_intervento_sessioni')) { + Schema::dropIfExists('ticket_intervento_sessioni'); + } + } +}; \ No newline at end of file diff --git a/docs/support/FORNITORE-OPERATIVITA-ROADMAP-2026-04.md b/docs/support/FORNITORE-OPERATIVITA-ROADMAP-2026-04.md index 93cdafc..f6ad211 100644 --- a/docs/support/FORNITORE-OPERATIVITA-ROADMAP-2026-04.md +++ b/docs/support/FORNITORE-OPERATIVITA-ROADMAP-2026-04.md @@ -21,6 +21,13 @@ ## 2. Stato gia rilasciato - ricerca subfornitore/collaboratore con pattern coerente ai ticket - start/stop timer intervento e salvataggio allegati fornitore con naming/preview piu robusti - primo slice pagina `Lavorazioni operative` come contenitore unico per interventi da ticket e future lavorazioni interne/ricorrenti +- sessioni intervento multiple con eventi `start`, `stop` e `checkpoint` sulla stessa scheda fornitore +- acquisizione posizione browser su start/stop/checkpoint quando il dispositivo concede geolocalizzazione +- prima rubrica clienti fornitore costruita dai ticket/interventi gia lavorati, con azioni chiamata/mail e link rapido alla scheda intervento +- prima pagina `Prodotti` fornitore agganciata al catalogo interno esistente, con ricerca per nome/codice/barcode +- materiali strutturati nel rapportino fornitore, con quantita, prezzo, barcode, sorgente e link esterno/fallback +- esposizione lato fornitore dello stato connessione Google gia presente nel backend, con riuso del flusso OAuth anche dal contesto fornitore +- verifica import TAG legacy fornitore: comando funzionante, nessun delta residuo sulle righe gia importate in staging ## 3. Rubrica unica condivisa @@ -115,6 +122,18 @@ ## 8. Catalogo operativo interno - il codice pubblicato/operativo deve essere quello interno NetGescon - i riferimenti esterni restano alias o sorgenti di import, non chiavi principali dell archivio +Slice gia attivo: + +- lookup prodotti dalla scheda intervento fornitore +- uso immediato nel rapportino di materiali gia codificati +- fallback manuale per prodotto non censito +- fallback link esterno di ricerca prodotto su Amazon in attesa del referral definitivo/configurato + +Nota pratica: + +- la scansione barcode lato browser e gia compatibile con lettori HID/Bluetooth che scrivono nel campo come tastiera +- la lettura camera nativa del barcode puo essere aggiunta in uno step successivo con libreria dedicata o app/PWA piu spinta + ## 9. Scheda fornitore per pagine separate Separazione richiesta per la scheda fornitore: @@ -139,8 +158,8 @@ ## 9. Scheda fornitore per pagine separate ## 10. Prossimi slice consigliati -1. pagina ticket interni/lavorazioni unificate del fornitore -2. estensione collaboratori con disponibilita/orari -3. separazione scheda fornitore in tab/pagine dedicate -4. staging SQL TecnoRepair per rubrica, schede e seriali -5. catalogo prodotti unificato con vendor multipli e serializzazione \ No newline at end of file +1. trasformare le sessioni intervento in foglio presenza/manodopera con timbrature multiple per giorno/mese e consuntivo fatturabile +2. estendere checkpoint presenza con QR/BLE/NFC dove il device/browser lo consente davvero +3. estensione collaboratori con disponibilita/orari +4. ticket interni/lavorazioni ricorrenti dentro `Lavorazioni operative` +5. staging SQL TecnoRepair per rubrica, schede e seriali \ No newline at end of file diff --git a/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php b/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php index 124171b..cc7fcc1 100644 --- a/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php +++ b/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php @@ -15,6 +15,8 @@ diff --git a/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php b/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php new file mode 100644 index 0000000..17a982c --- /dev/null +++ b/resources/views/filament/pages/fornitore/prodotti-catalogo.blade.php @@ -0,0 +1,53 @@ + +
+
+
+
+
Catalogo prodotti fornitore
+
+ @if($this->fornitoreLabel) + Catalogo operativo per {{ $this->fornitoreLabel }}, pronto per barcode, rapportino e prezzi interni. + @else + Seleziona un fornitore per aprire il catalogo. + @endif +
+
+ +
+
+ + @if($this->missingAdminContext) +
+ Questa vista richiede un fornitore selezionato. +
+ @else +
+ +
Compatibile gia con scanner barcode HID/Bluetooth che scrivono nel campo come una tastiera.
+
+ +
+ @forelse($rows as $row) +
+
{{ $row['label'] }}
+
Codice interno: {{ $row['internal_code'] !== '' ? $row['internal_code'] : '-' }}
+
Barcode: {{ $row['barcode'] !== '' ? $row['barcode'] : 'non presente' }}
+
Prezzo: {{ $row['price'] }} · Fonte: {{ $row['source'] }}
+ +
+ @empty +
Nessun prodotto disponibile per il filtro corrente.
+ @endforelse +
+ @endif +
+
\ No newline at end of file diff --git a/resources/views/filament/pages/fornitore/rubrica-clienti.blade.php b/resources/views/filament/pages/fornitore/rubrica-clienti.blade.php new file mode 100644 index 0000000..8ff935f --- /dev/null +++ b/resources/views/filament/pages/fornitore/rubrica-clienti.blade.php @@ -0,0 +1,84 @@ + +
+
+
+
+
Rubrica clienti fornitore
+
+ @if($this->fornitoreLabel) + Contatti emersi dai ticket e dalle lavorazioni di {{ $this->fornitoreLabel }}. + @else + Seleziona un fornitore per aprire la rubrica clienti. + @endif +
+
+ +
+
+ + @if($this->missingAdminContext) +
+ Questa vista richiede un fornitore selezionato. +
+ @else +
+
+ + +
+ @forelse($rows as $row) +
+
+
+
{{ $row['contatto'] }}
+
Origine: {{ $row['origine'] }} · Interventi: {{ $row['totale_interventi'] }}
+
Stabile: {{ $row['stabile'] }}
+
Ultimo aggiornamento: {{ $row['updated_at'] }}
+
+
+ @if($row['telefono'] !== '') + Chiama + @endif + @if($row['email'] !== '') + Email + @endif + Ultima scheda +
+
+
+ @empty +
Nessun contatto disponibile per il filtro corrente.
+ @endforelse +
+
+ +
+
Google workspace
+ @if($googleWorkspaceStatus) +
+
Stato: {{ $googleWorkspaceStatus['connected'] ? 'Collegato' : 'Non collegato' }}
+
Account: {{ $googleWorkspaceStatus['email'] !== '' ? $googleWorkspaceStatus['email'] : '-' }}
+
Nome: {{ $googleWorkspaceStatus['name'] !== '' ? $googleWorkspaceStatus['name'] : '-' }}
+
Ultimo collegamento: {{ $googleWorkspaceStatus['connected_at'] !== '' ? $googleWorkspaceStatus['connected_at'] : '-' }}
+
+
+ Collega / aggiorna Google + @if($googleWorkspaceStatus['connected']) + Scollega + @endif +
+
Questo blocco prepara l'uso fornitore di rubrica, calendario e Drive sullo stesso backend Google gia presente nel progetto.
+ @else +
Fornitore senza amministratore collegato: stato Google non disponibile.
+ @endif +
+
+ @endif +
+
\ No newline at end of file diff --git a/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php b/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php index 7ca450e..3df77f0 100644 --- a/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php +++ b/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php @@ -7,6 +7,7 @@ x-data="{ localFotoPreviews: [], localDocumentoPreviews: [], + geoBusy: false, revoke(items) { (items || []).forEach(item => { if (item && item.url) { URL.revokeObjectURL(item.url); } }); }, mapFiles(fileList) { return Array.from(fileList || []).map(file => { @@ -24,7 +25,39 @@ }, setPreviews(event, type) { const key = type === 'foto' ? 'localFotoPreviews' : 'localDocumentoPreviews'; this.revoke(this[key]); this[key] = this.mapFiles(event.target.files); }, clearPreviews(type = null) { if (type === null || type === 'foto') { this.revoke(this.localFotoPreviews); this.localFotoPreviews = []; } if (type === null || type === 'documenti') { this.revoke(this.localDocumentoPreviews); this.localDocumentoPreviews = []; } }, - formatSize(bytes) { if (!bytes) return '0 KB'; const units = ['B', 'KB', 'MB', 'GB']; let value = bytes; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex++; } return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; } + formatSize(bytes) { if (!bytes) return '0 KB'; const units = ['B', 'KB', 'MB', 'GB']; let value = bytes; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex++; } return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; }, + captureGeo(action) { + return new Promise((resolve) => { + if (!navigator.geolocation) { + resolve({ source: 'browser', error: `geolocation_not_supported_${action}` }); + return; + } + + navigator.geolocation.getCurrentPosition( + (position) => resolve({ + source: 'browser', + lat: position.coords?.latitude ?? null, + lng: position.coords?.longitude ?? null, + accuracy: position.coords?.accuracy ?? null, + action, + }), + (error) => resolve({ source: 'browser', error: error?.message || `geolocation_failed_${action}`, action }), + { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }, + ); + }); + }, + async runGeoAction(action) { + this.geoBusy = true; + const payload = await this.captureGeo(action); + if (action === 'start') { + await $wire.startInterventoWithGeo(payload); + } else if (action === 'stop') { + await $wire.stopInterventoWithGeo(payload); + } else if (action === 'checkpoint') { + await $wire.addPresenceCheckpoint(payload); + } + this.geoBusy = false; + } }" x-on:fornitore-local-previews-clear.window="clearPreviews()"> @@ -96,14 +131,17 @@
Operativo e rapportino
@if($this->intervento->stato === 'assegnato' || ! $this->intervento->iniziato_at || $this->intervento->terminato_at) - Start intervento + Start intervento @endif @if($this->intervento->iniziato_at && ! $this->intervento->terminato_at) - Stop intervento + Stop intervento @endif + Checkpoint posizione
+
Start, stop e checkpoint provano a salvare la posizione del browser. Se il GPS non e disponibile o negato, l'evento viene comunque registrato senza coordinate.
+
Timer avvio
@@ -168,6 +206,92 @@
+
+
+
+
Materiali e prodotti
+
Puoi cercare per nome, codice interno o barcode. I lettori Bluetooth che si comportano come tastiera funzionano gia su questo campo.
+
+ Apri catalogo prodotti +
+ +
+ +
+ Aggiungi manuale +
+
+ + @if(count($materialSuggestions) > 0) +
+ @foreach($materialSuggestions as $suggestion) +
+
{{ $suggestion['label'] }}
+
Barcode: {{ $suggestion['barcode'] !== '' ? $suggestion['barcode'] : 'non presente' }}
+
Prezzo: {{ $suggestion['price'] ?: 'non disponibile' }} · Fonte: {{ $suggestion['source'] }}
+
+ + @if(!empty($suggestion['external_url'])) + Apri fonte + @endif +
+
+ @endforeach +
+ @elseif($materialSearch !== '') +
+ Nessun prodotto trovato nel catalogo corrente. Puoi inserirlo manualmente adesso e usare il link Amazon di ricerca nel rapportino, poi completare l'anagrafica prodotto dal catalogo. +
+ @endif + + @if(count($materialiUtilizzati) > 0) +
+ @foreach($materialiUtilizzati as $index => $materiale) +
+
+ + + + + + +
+ +
+ @if(!empty($materiale['external_url'])) + Apri riferimento esterno + @else + Nessun riferimento esterno + @endif + +
+
+ @endforeach +
+ @endif +
+
@foreach($articoliUtilizzati as $index => $item)
+
+
+
Sessioni e presenza
+
{{ count($sessionRows) }} eventi
+
+ + @if($lastGeoSnapshot) +
+
{{ $lastGeoSnapshot['label'] }}
+
Start: {{ $lastGeoSnapshot['start_geo'] }}
+
Stop: {{ $lastGeoSnapshot['end_geo'] }}
+
Aggiornato: {{ $lastGeoSnapshot['updated_at'] }}
+
+ @endif + +
+ @forelse($sessionRows as $row) +
+
+ {{ $row['type'] === 'checkpoint' ? 'Checkpoint' : 'Sessione lavoro' }} + {{ $row['duration_minutes'] !== null ? ($row['duration_minutes'] . ' min') : '-' }} +
+
{{ $row['note'] !== '' ? $row['note'] : 'Nessuna nota' }}
+
Start: {{ $row['started_at'] }} · {{ $row['start_geo'] }}
+
Stop: {{ $row['ended_at'] }} · {{ $row['end_geo'] }}
+
+ @empty +
Nessuna sessione registrata.
+ @endforelse +
+
+ +
+
Google workspace
+ @if($googleWorkspaceStatus) +
+
Stato: {{ $googleWorkspaceStatus['connected'] ? 'Collegato' : 'Non collegato' }}
+
Account: {{ $googleWorkspaceStatus['email'] !== '' ? $googleWorkspaceStatus['email'] : '-' }}
+
Nome: {{ $googleWorkspaceStatus['name'] !== '' ? $googleWorkspaceStatus['name'] : '-' }}
+
Ultimo collegamento: {{ $googleWorkspaceStatus['connected_at'] !== '' ? $googleWorkspaceStatus['connected_at'] : '-' }}
+
+
+ Collega / aggiorna Google + @if($googleWorkspaceStatus['connected']) + Scollega + @endif +
+
Questo e il primo aggancio operativo per riusare contatti, calendario e Drive nello spazio fornitore con lo stesso account Google gia gestito dal backend.
+ @else +
Nessun contesto amministratore collegato al fornitore: collegamento Google non disponibile.
+ @endif +
+
Allegati ticket
diff --git a/resources/views/filament/pages/supporto/ticket-mobile.blade.php b/resources/views/filament/pages/supporto/ticket-mobile.blade.php index bd45f77..201f85e 100644 --- a/resources/views/filament/pages/supporto/ticket-mobile.blade.php +++ b/resources/views/filament/pages/supporto/ticket-mobile.blade.php @@ -252,6 +252,8 @@
+
In attesa di accodamento
@@ -366,6 +377,7 @@
+
In attesa di accodamento