feat: traccia sessioni e catalogo fornitore

This commit is contained in:
michele 2026-04-06 16:03:21 +00:00
parent 05c10a6f8a
commit 1ca7314f6b
17 changed files with 1479 additions and 26 deletions

View File

@ -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

View File

@ -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()

View File

@ -0,0 +1,147 @@
<?php
namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Fornitore;
use App\Models\Product;
use App\Models\ProductIdentifier;
use App\Models\ProductOffer;
use App\Models\User;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class ProdottiCatalogo extends Page
{
use ResolvesOperatoreContext;
protected static ?string $navigationLabel = 'Prodotti';
protected static ?string $title = 'Catalogo prodotti fornitore';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-archive-box';
protected static UnitEnum|string|null $navigationGroup = 'Fornitore';
protected static ?int $navigationSort = 4;
protected static ?string $slug = 'fornitore/prodotti';
protected string $view = 'filament.pages.fornitore.prodotti-catalogo';
public ?int $fornitoreId = null;
public ?string $fornitoreLabel = null;
public bool $missingAdminContext = false;
public string $search = '';
/** @var array<int, array<string, mixed>> */
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);
}
}

View File

@ -0,0 +1,218 @@
<?php
namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Fornitore;
use App\Models\TicketIntervento;
use App\Models\User;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use UnitEnum;
class RubricaClienti extends Page
{
use ResolvesOperatoreContext;
protected static ?string $navigationLabel = 'Rubrica clienti';
protected static ?string $title = 'Rubrica clienti fornitore';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-phone';
protected static UnitEnum|string|null $navigationGroup = 'Fornitore';
protected static ?int $navigationSort = 3;
protected static ?string $slug = 'fornitore/rubrica-clienti';
protected string $view = 'filament.pages.fornitore.rubrica-clienti';
public ?int $fornitoreId = null;
public ?string $fornitoreLabel = null;
public bool $missingAdminContext = false;
public string $search = '';
/** @var array<int, array<string, mixed>> */
public array $rows = [];
/** @var array<string, mixed>|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<string, string>
*/
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<string, mixed>|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'] ?? ''),
];
}
}

View File

@ -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<int, string> */
public array $articoliUtilizzati = ['', '', ''];
/** @var array<int, array<string, mixed>> */
public array $materialiUtilizzati = [];
public string $materialSearch = '';
/** @var array<int, array<string, mixed>> */
public array $materialSuggestions = [];
public string $apparatoMarca = '';
public string $apparatoModello = '';
@ -94,6 +106,15 @@ class TicketInterventoScheda extends Page
public ?array $attachmentPreview = null;
/** @var array<int, array<string, mixed>> */
public array $sessionRows = [];
/** @var array<string, mixed>|null */
public ?array $lastGeoSnapshot = null;
/** @var array<string, mixed>|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<string,mixed>}
*/
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<string, mixed>|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}
*/

View File

@ -80,8 +80,13 @@ class TicketMobile extends Page
/** @var array<int, string> */
public array $newTicketAttachmentDescriptions = [];
/** @var array<string, string> */
public array $newTicketUploadCodes = [];
public string $newTicketDraftProtocol = '';
public int $newTicketDraftSequence = 1;
public ?string $newTicketFotoNote = null;
/** @var \Illuminate\Support\Collection<int, Ticket> */
@ -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<int, mixed> $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

View File

@ -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);
}
}

View File

@ -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;

View File

@ -0,0 +1,80 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TicketInterventoSessione extends Model
{
use HasFactory;
protected $table = 'ticket_intervento_sessioni';
protected $fillable = [
'ticket_intervento_id',
'ticket_id',
'fornitore_id',
'fornitore_dipendente_id',
'created_by_user_id',
'closed_by_user_id',
'session_type',
'note',
'started_at',
'ended_at',
'duration_minutes',
'start_latitude',
'start_longitude',
'start_accuracy_meters',
'start_source',
'end_latitude',
'end_longitude',
'end_accuracy_meters',
'end_source',
'geo_payload',
];
protected $casts = [
'started_at' => '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');
}
}

View File

@ -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);

View File

@ -0,0 +1,60 @@
<?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_intervento_sessioni')) {
Schema::create('ticket_intervento_sessioni', function (Blueprint $table) {
$table->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');
}
}
};

View File

@ -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
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

View File

@ -15,6 +15,8 @@
<div class="flex flex-wrap gap-2">
<a href="{{ $this->getTicketsUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Ticket operativi</a>
<a href="{{ $this->getCollaboratoriUrl() }}" 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">Collaboratori</a>
<a href="{{ $this->getRubricaUrl() }}" 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">Rubrica clienti</a>
<a href="{{ $this->getProdottiUrl() }}" 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">Prodotti</a>
</div>
</div>
</div>

View File

@ -0,0 +1,53 @@
<x-filament-panels::page>
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-lg font-semibold">Catalogo prodotti fornitore</div>
<div class="text-sm text-gray-600">
@if($this->fornitoreLabel)
Catalogo operativo per {{ $this->fornitoreLabel }}, pronto per barcode, rapportino e prezzi interni.
@else
Seleziona un fornitore per aprire il catalogo.
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $this->getLavorazioniUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Lavorazioni</a>
<a href="{{ $this->getRubricaUrl() }}" 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">Rubrica clienti</a>
</div>
</div>
</div>
@if($this->missingAdminContext)
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-800">
Questa vista richiede un fornitore selezionato.
</div>
@else
<div class="rounded-xl border bg-white p-4">
<label class="block text-sm">
<span class="mb-1 block font-medium">Cerca prodotto, codice interno o barcode</span>
<input type="text" wire:model.live.debounce.300ms="search" class="w-full rounded-lg border-gray-300" placeholder="Es. lampadina, E27, ART-000123, barcode" />
</label>
<div class="mt-2 text-xs text-gray-500">Compatibile gia con scanner barcode HID/Bluetooth che scrivono nel campo come una tastiera.</div>
</div>
<div class="grid gap-3 lg:grid-cols-2">
@forelse($rows as $row)
<div class="rounded-xl border bg-white p-4 shadow-sm">
<div class="font-semibold">{{ $row['label'] }}</div>
<div class="mt-1 text-xs text-gray-500">Codice interno: {{ $row['internal_code'] !== '' ? $row['internal_code'] : '-' }}</div>
<div class="mt-1 text-xs text-gray-500">Barcode: {{ $row['barcode'] !== '' ? $row['barcode'] : 'non presente' }}</div>
<div class="mt-1 text-xs text-gray-500">Prezzo: {{ $row['price'] }} · Fonte: {{ $row['source'] }}</div>
<div class="mt-3 flex flex-wrap gap-2">
<a href="{{ $row['external_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 fonte / referral</a>
<a href="{{ $this->getLavorazioniUrl() }}" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Usa nelle lavorazioni</a>
</div>
</div>
@empty
<div class="rounded-xl border border-dashed p-6 text-sm text-gray-500 lg:col-span-2">Nessun prodotto disponibile per il filtro corrente.</div>
@endforelse
</div>
@endif
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,84 @@
<x-filament-panels::page>
<div class="space-y-4">
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-lg font-semibold">Rubrica clienti fornitore</div>
<div class="text-sm text-gray-600">
@if($this->fornitoreLabel)
Contatti emersi dai ticket e dalle lavorazioni di {{ $this->fornitoreLabel }}.
@else
Seleziona un fornitore per aprire la rubrica clienti.
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ $this->getLavorazioniUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Lavorazioni</a>
<a href="{{ $this->getProdottiUrl() }}" 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">Prodotti</a>
</div>
</div>
</div>
@if($this->missingAdminContext)
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-800">
Questa vista richiede un fornitore selezionato.
</div>
@else
<div class="grid gap-4 xl:grid-cols-[2fr,1fr]">
<div class="rounded-xl border bg-white p-4">
<label class="block text-sm">
<span class="mb-1 block font-medium">Cerca contatto</span>
<input type="text" wire:model.live.debounce.300ms="search" class="w-full rounded-lg border-gray-300" placeholder="Nome, telefono, email, stabile" />
</label>
<div class="mt-4 space-y-3">
@forelse($rows as $row)
<div class="rounded-xl border p-4 text-sm shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="font-semibold">{{ $row['contatto'] }}</div>
<div class="mt-1 text-xs text-gray-500">Origine: {{ $row['origine'] }} · Interventi: {{ $row['totale_interventi'] }}</div>
<div class="mt-2 text-xs text-gray-500">Stabile: {{ $row['stabile'] }}</div>
<div class="mt-1 text-xs text-gray-500">Ultimo aggiornamento: {{ $row['updated_at'] }}</div>
</div>
<div class="flex flex-wrap gap-2">
@if($row['telefono'] !== '')
<a href="tel:{{ preg_replace('/\s+/', '', (string) $row['telefono']) }}" 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">Chiama</a>
@endif
@if($row['email'] !== '')
<a href="mailto:{{ $row['email'] }}" 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">Email</a>
@endif
<a href="{{ $row['ultima_scheda_url'] }}" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Ultima scheda</a>
</div>
</div>
</div>
@empty
<div class="rounded-xl border border-dashed p-6 text-sm text-gray-500">Nessun contatto disponibile per il filtro corrente.</div>
@endforelse
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Google workspace</div>
@if($googleWorkspaceStatus)
<div class="mt-3 space-y-2 text-sm">
<div><span class="font-medium">Stato:</span> {{ $googleWorkspaceStatus['connected'] ? 'Collegato' : 'Non collegato' }}</div>
<div><span class="font-medium">Account:</span> {{ $googleWorkspaceStatus['email'] !== '' ? $googleWorkspaceStatus['email'] : '-' }}</div>
<div><span class="font-medium">Nome:</span> {{ $googleWorkspaceStatus['name'] !== '' ? $googleWorkspaceStatus['name'] : '-' }}</div>
<div><span class="font-medium">Ultimo collegamento:</span> {{ $googleWorkspaceStatus['connected_at'] !== '' ? $googleWorkspaceStatus['connected_at'] : '-' }}</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<a href="{{ $this->getGoogleConnectUrl() }}" 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">Collega / aggiorna Google</a>
@if($googleWorkspaceStatus['connected'])
<a href="{{ $this->getGoogleDisconnectUrl() }}" class="inline-flex items-center rounded-md bg-rose-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-rose-500">Scollega</a>
@endif
</div>
<div class="mt-3 text-xs text-gray-500">Questo blocco prepara l'uso fornitore di rubrica, calendario e Drive sullo stesso backend Google gia presente nel progetto.</div>
@else
<div class="mt-3 text-sm text-gray-500">Fornitore senza amministratore collegato: stato Google non disponibile.</div>
@endif
</div>
</div>
@endif
</div>
</x-filament-panels::page>

View File

@ -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()">
<div class="rounded-xl border bg-white p-4">
@ -38,6 +71,8 @@
<a href="{{ $this->getTicketsUrl() }}" class="inline-flex items-center rounded-md bg-gray-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700">Torna ai ticket</a>
<a href="{{ \App\Filament\Pages\Fornitore\LavorazioniOperative::getUrl(['fornitore' => (int) $this->fornitore->id], panel: 'admin-filament') }}" 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">Lavorazioni</a>
<a href="{{ $this->getCollaboratoriUrl() }}" class="inline-flex items-center rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-primary-500">Collaboratori</a>
<a href="{{ $this->getRubricaUrl() }}" 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">Rubrica clienti</a>
<a href="{{ $this->getProdottiUrl() }}" 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">Prodotti</a>
</div>
</div>
</div>
@ -96,14 +131,17 @@
<div class="text-sm font-semibold">Operativo e rapportino</div>
<div class="flex flex-wrap gap-2">
@if($this->intervento->stato === 'assegnato' || ! $this->intervento->iniziato_at || $this->intervento->terminato_at)
<x-filament::button size="sm" wire:click="startIntervento">Start intervento</x-filament::button>
<x-filament::button size="sm" x-on:click.prevent="runGeoAction('start')" x-bind:disabled="geoBusy">Start intervento</x-filament::button>
@endif
@if($this->intervento->iniziato_at && ! $this->intervento->terminato_at)
<x-filament::button size="sm" color="gray" wire:click="stopIntervento">Stop intervento</x-filament::button>
<x-filament::button size="sm" color="gray" x-on:click.prevent="runGeoAction('stop')" x-bind:disabled="geoBusy">Stop intervento</x-filament::button>
@endif
<x-filament::button size="sm" color="info" x-on:click.prevent="runGeoAction('checkpoint')" x-bind:disabled="geoBusy">Checkpoint posizione</x-filament::button>
</div>
</div>
<div class="mt-2 text-[11px] text-slate-500">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.</div>
<div class="mt-3 grid gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700 md:grid-cols-3">
<div>
<div class="font-semibold uppercase tracking-wide text-slate-500">Timer avvio</div>
@ -168,6 +206,92 @@
</label>
</div>
<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="text-sm font-semibold text-emerald-900">Materiali e prodotti</div>
<div class="mt-1 text-xs text-emerald-800">Puoi cercare per nome, codice interno o barcode. I lettori Bluetooth che si comportano come tastiera funzionano gia su questo campo.</div>
</div>
<a href="{{ $this->getProdottiUrl() }}" class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-xs font-medium text-emerald-800 ring-1 ring-inset ring-emerald-300 hover:bg-emerald-100">Apri catalogo prodotti</a>
</div>
<div class="mt-3 flex flex-col gap-3 md:flex-row">
<label class="block flex-1 text-sm">
<span class="mb-1 block font-medium">Cerca prodotto o scansiona barcode</span>
<input type="text" wire:model.live.debounce.300ms="materialSearch" inputmode="search" class="w-full rounded-lg border-emerald-300" placeholder="Es. lampadina E27 10W oppure barcode" />
</label>
<div class="flex items-end gap-2">
<x-filament::button color="success" wire:click="addManualMaterial">Aggiungi manuale</x-filament::button>
</div>
</div>
@if(count($materialSuggestions) > 0)
<div class="mt-3 grid gap-3 lg:grid-cols-2">
@foreach($materialSuggestions as $suggestion)
<div class="rounded-xl border bg-white p-3 text-sm shadow-sm">
<div class="font-medium">{{ $suggestion['label'] }}</div>
<div class="mt-1 text-xs text-gray-500">Barcode: {{ $suggestion['barcode'] !== '' ? $suggestion['barcode'] : 'non presente' }}</div>
<div class="mt-1 text-xs text-gray-500">Prezzo: {{ $suggestion['price'] ?: 'non disponibile' }} · Fonte: {{ $suggestion['source'] }}</div>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" wire:click="addSuggestedMaterial({{ (int) $suggestion['id'] }})" 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(!empty($suggestion['external_url']))
<a href="{{ $suggestion['external_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 fonte</a>
@endif
</div>
</div>
@endforeach
</div>
@elseif($materialSearch !== '')
<div class="mt-3 rounded-lg border border-dashed border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
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.
</div>
@endif
@if(count($materialiUtilizzati) > 0)
<div class="mt-4 space-y-3">
@foreach($materialiUtilizzati as $index => $materiale)
<div class="rounded-xl border bg-white p-3 text-sm shadow-sm">
<div class="grid gap-3 md:grid-cols-4">
<label class="block md:col-span-2">
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">Prodotto/materiale</span>
<input type="text" wire:model.defer="materialiUtilizzati.{{ $index }}.descrizione" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block">
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">Barcode</span>
<input type="text" wire:model.defer="materialiUtilizzati.{{ $index }}.barcode" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block">
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">Quantita</span>
<input type="number" step="0.01" min="0.01" wire:model.defer="materialiUtilizzati.{{ $index }}.qty" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block">
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">Prezzo nostro</span>
<input type="number" step="0.01" min="0" wire:model.defer="materialiUtilizzati.{{ $index }}.unit_price" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block">
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">Valuta</span>
<input type="text" wire:model.defer="materialiUtilizzati.{{ $index }}.currency" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block md:col-span-2">
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500">Fonte</span>
<input type="text" wire:model.defer="materialiUtilizzati.{{ $index }}.source_label" class="w-full rounded-lg border-gray-300" />
</label>
</div>
<div class="mt-3 flex flex-wrap items-center justify-between gap-2">
@if(!empty($materiale['external_url']))
<a href="{{ $materiale['external_url'] }}" target="_blank" class="text-xs font-medium text-primary-600 hover:underline">Apri riferimento esterno</a>
@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>
</div>
@endforeach
</div>
@endif
</div>
<div class="mt-4 grid gap-2 md:grid-cols-3">
@foreach($articoliUtilizzati as $index => $item)
<label class="block text-sm">
@ -339,6 +463,59 @@
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="flex items-center justify-between gap-2">
<div class="text-sm font-semibold">Sessioni e presenza</div>
<div class="text-xs text-gray-500">{{ count($sessionRows) }} eventi</div>
</div>
@if($lastGeoSnapshot)
<div class="mt-3 rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs text-sky-900">
<div class="font-semibold">{{ $lastGeoSnapshot['label'] }}</div>
<div class="mt-1">Start: {{ $lastGeoSnapshot['start_geo'] }}</div>
<div class="mt-1">Stop: {{ $lastGeoSnapshot['end_geo'] }}</div>
<div class="mt-1 text-sky-700">Aggiornato: {{ $lastGeoSnapshot['updated_at'] }}</div>
</div>
@endif
<div class="mt-3 space-y-2">
@forelse($sessionRows as $row)
<div class="rounded-lg border p-3 text-xs">
<div class="flex items-center justify-between gap-2">
<span class="inline-flex rounded-full bg-slate-100 px-2 py-1 font-semibold text-slate-700">{{ $row['type'] === 'checkpoint' ? 'Checkpoint' : 'Sessione lavoro' }}</span>
<span class="text-gray-500">{{ $row['duration_minutes'] !== null ? ($row['duration_minutes'] . ' min') : '-' }}</span>
</div>
<div class="mt-2 text-gray-700">{{ $row['note'] !== '' ? $row['note'] : 'Nessuna nota' }}</div>
<div class="mt-2 text-gray-500">Start: {{ $row['started_at'] }} · {{ $row['start_geo'] }}</div>
<div class="mt-1 text-gray-500">Stop: {{ $row['ended_at'] }} · {{ $row['end_geo'] }}</div>
</div>
@empty
<div class="text-sm text-gray-500">Nessuna sessione registrata.</div>
@endforelse
</div>
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Google workspace</div>
@if($googleWorkspaceStatus)
<div class="mt-3 space-y-2 text-sm">
<div><span class="font-medium">Stato:</span> {{ $googleWorkspaceStatus['connected'] ? 'Collegato' : 'Non collegato' }}</div>
<div><span class="font-medium">Account:</span> {{ $googleWorkspaceStatus['email'] !== '' ? $googleWorkspaceStatus['email'] : '-' }}</div>
<div><span class="font-medium">Nome:</span> {{ $googleWorkspaceStatus['name'] !== '' ? $googleWorkspaceStatus['name'] : '-' }}</div>
<div><span class="font-medium">Ultimo collegamento:</span> {{ $googleWorkspaceStatus['connected_at'] !== '' ? $googleWorkspaceStatus['connected_at'] : '-' }}</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<a href="{{ $this->getGoogleConnectUrl() }}" 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">Collega / aggiorna Google</a>
@if($googleWorkspaceStatus['connected'])
<a href="{{ $this->getGoogleDisconnectUrl() }}" class="inline-flex items-center rounded-md bg-rose-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-rose-500">Scollega</a>
@endif
</div>
<div class="mt-2 text-xs text-gray-500">Questo e il primo aggancio operativo per riusare contatti, calendario e Drive nello spazio fornitore con lo stesso account Google gia gestito dal backend.</div>
@else
<div class="mt-3 text-sm text-gray-500">Nessun contesto amministratore collegato al fornitore: collegamento Google non disponibile.</div>
@endif
</div>
<div class="rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">Allegati ticket</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2">

View File

@ -252,6 +252,8 @@
<label class="block text-sm md:col-span-2"
x-data="{
draftProtocol: @js($newTicketDraftProtocol),
draftSequence: {{ (int) $newTicketDraftSequence }},
localCameraPreviews: [],
localAttachmentPreviews: [],
revoke(items) {
@ -261,10 +263,19 @@
}
});
},
buildDraftName(file, sequence) {
const sourceName = file?.name || 'file';
const extension = sourceName.includes('.') ? sourceName.split('.').pop().toLowerCase() : '';
const isImage = (file?.type || '').startsWith('image/');
const code = `${this.draftProtocol}-${isImage ? 'F' : 'A'}${String(sequence).padStart(2, '0')}`;
return extension ? `${code}.${extension}` : code;
},
mapFiles(fileList, kind) {
return Array.from(fileList || []).map((file, index) => ({
key: `${kind}-${index}-${file.name}-${file.size}`,
name: file.name,
draftName: this.buildDraftName(file, this.draftSequence + index),
size: file.size || 0,
isImage: (file.type || '').startsWith('image/'),
url: (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null,
@ -297,7 +308,6 @@
}
return `${(bytes / 1048576).toFixed(1)} MB`;
},
}"
x-on:ticket-mobile-local-previews-clear.window="clearPreviews($event.detail?.source || null)"
x-on:ticket-mobile-draft-reset.window="clearPreviews()"
@ -338,6 +348,7 @@
<img x-show="item.url" :src="item.url" :alt="item.name" class="h-full w-full rounded object-cover" />
</div>
<div class="mt-2 font-medium" x-text="item.name"></div>
<div class="mt-1 text-[11px] font-semibold text-emerald-700" x-text="`Nome bozza: ${item.draftName}`"></div>
<div class="mt-1 text-[11px] text-sky-700">In attesa di accodamento</div>
<div class="mt-1 text-[11px] text-gray-500" x-text="formatSize(item.size)"></div>
</div>
@ -366,6 +377,7 @@
</template>
</div>
<div class="mt-2 font-medium" x-text="item.name"></div>
<div class="mt-1 text-[11px] font-semibold text-emerald-700" x-text="`Nome bozza: ${item.draftName}`"></div>
<div class="mt-1 text-[11px] text-sky-700">In attesa di accodamento</div>
<div class="mt-1 text-[11px] text-gray-500" x-text="formatSize(item.size)"></div>
</div>