feat: traccia sessioni e catalogo fornitore
This commit is contained in:
parent
05c10a6f8a
commit
1ca7314f6b
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
147
app/Filament/Pages/Fornitore/ProdottiCatalogo.php
Normal file
147
app/Filament/Pages/Fornitore/ProdottiCatalogo.php
Normal 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);
|
||||
}
|
||||
}
|
||||
218
app/Filament/Pages/Fornitore/RubricaClienti.php
Normal file
218
app/Filament/Pages/Fornitore/RubricaClienti.php
Normal 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'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
80
app/Models/TicketInterventoSessione.php
Normal file
80
app/Models/TicketInterventoSessione.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user