feat: migliora rapportino fornitore e lavorazioni

This commit is contained in:
michele 2026-04-06 13:50:57 +00:00
parent e44a3f039c
commit 05c10a6f8a
8 changed files with 500 additions and 33 deletions

View File

@ -10,6 +10,7 @@ ## [Unreleased]
- Fixed supplier ticket-detail access by resolving both intervention-id and ticket-id safely in supplier context.
- Added supplier quick modal from ticket list plus ticket-attachment thumbnails and modal preview in supplier scheda.
- Aligned supplier collaborator external-supplier search to the same result-selection pattern already used in ticket assignment.
- Added first-slice supplier operations page for unified work listing, ready to absorb internal and recurring jobs later.
- Added Git-first staging update guidance in Supporto > Modifiche, centered on the `netgescon:git-sync` command and UI sync flow from Gitea.
- Added materialization of `persone` and `persone_unita_relazioni` from imported unit nominatives, plus a dedicated `relazioni` step in `gescon:import-full`.
- Hardened Panasonic Windows live bridge scripts, runtime publish flow, and TAPI diagnostics for local-path deployment and aggregated Panasonic addresses.

View File

@ -0,0 +1,163 @@
<?php
namespace App\Filament\Pages\Fornitore;
use App\Filament\Pages\Fornitore\Concerns\ResolvesOperatoreContext;
use App\Models\Fornitore;
use App\Models\FornitoreDipendente;
use App\Models\TicketIntervento;
use App\Models\User;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class LavorazioniOperative extends Page
{
use ResolvesOperatoreContext;
protected static ?string $navigationLabel = 'Lavorazioni';
protected static ?string $title = 'Lavorazioni operative';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-queue-list';
protected static UnitEnum|string|null $navigationGroup = 'Fornitore';
protected static ?int $navigationSort = 1;
protected static ?string $slug = 'fornitore/lavorazioni';
protected string $view = 'filament.pages.fornitore.lavorazioni-operative';
public ?int $fornitoreId = null;
public ?string $fornitoreLabel = null;
public bool $missingAdminContext = false;
public string $scope = 'tutte';
/** @var array<int, array<string, mixed>> */
public array $rows = [];
/** @var array<string, int> */
public array $totals = [
'tutte' => 0,
'ticket_amministratore' => 0,
'interne' => 0,
];
public static function canAccess(): bool
{
$user = Auth::user();
return $user instanceof User
&& $user->hasAnyRole(['super-admin', 'admin', 'amministratore', 'fornitore']);
}
public function mount(): void
{
$this->scope = (string) request()->query('scope', 'tutte');
[$fornitore] = $this->resolveOperatoreContext(allowAdminWithoutSupplier: true);
if (! $fornitore instanceof Fornitore) {
$this->missingAdminContext = true;
return;
}
$this->fornitoreId = (int) $fornitore->id;
$this->fornitoreLabel = $this->getFornitoreLabel($fornitore);
$this->refreshData();
}
public function updatedScope(): void
{
$this->refreshData();
}
public function setScope(string $scope): void
{
$this->scope = $scope;
$this->refreshData();
}
public function refreshData(): void
{
if (! $this->fornitoreId) {
$this->rows = [];
return;
}
[$fornitore, $dipendente] = $this->resolveOperatoreContext($this->fornitoreId);
$baseQuery = $this->buildBaseQuery($fornitore, $dipendente);
$this->totals = [
'tutte' => (clone $baseQuery)->count(),
'ticket_amministratore' => (clone $baseQuery)->count(),
'interne' => 0,
];
if ($this->scope === 'interne') {
$this->rows = [];
return;
}
$this->rows = $baseQuery
->limit(120)
->get()
->map(function (TicketIntervento $intervento): array {
$base = $this->buildInterventoRow($intervento);
return array_merge($base, [
'id' => (int) $intervento->id,
'ticket_id' => (int) $intervento->ticket_id,
'titolo' => (string) ($intervento->ticket->titolo ?? '-'),
'stato' => (string) $intervento->stato,
'stabile' => (string) ($intervento->ticket->stabile->denominazione ?? '-'),
'operatore' => (string) $intervento->operatore_assegnato_label,
'tempo_minuti' => (int) ($intervento->tempo_minuti ?? 0),
'updated_at' => optional($intervento->updated_at)->format('d/m/Y H:i') ?: '-',
'origine' => 'Ticket amministratore',
'url' => TicketInterventoScheda::getUrl(['record' => (int) $intervento->id], panel: 'admin-filament'),
]);
})
->all();
}
public function getTicketsUrl(): string
{
if ($this->fornitoreId) {
return TicketOperativi::getUrl(['fornitore' => $this->fornitoreId], panel: 'admin-filament');
}
return TicketOperativi::getUrl(panel: 'admin-filament');
}
public function getCollaboratoriUrl(): string
{
if ($this->fornitoreId) {
return Collaboratori::getUrl(['fornitore' => $this->fornitoreId], panel: 'admin-filament');
}
return Collaboratori::getUrl(panel: 'admin-filament');
}
protected function buildBaseQuery(Fornitore $fornitore, ?FornitoreDipendente $dipendente): Builder
{
$query = TicketIntervento::query()
->with(['ticket.stabile', 'eseguitoDaDipendente'])
->where('fornitore_id', (int) $fornitore->id)
->orderByDesc('created_at');
if ($dipendente instanceof FornitoreDipendente) {
$query->where(function (Builder $builder) use ($dipendente): void {
$builder->whereNull('eseguito_da_dipendente_id')
->orWhere('eseguito_da_dipendente_id', (int) $dipendente->id);
});
}
return $query;
}
}

View File

@ -188,6 +188,7 @@ public function startIntervento(): void
'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(),
'terminato_at' => null,
]);
$this->intervento->ticket->update(['stato' => 'In Lavorazione']);
@ -197,6 +198,38 @@ public function startIntervento(): void
Notification::make()->title('Intervento preso in carico')->success()->send();
}
public function stopIntervento(): void
{
if (! $this->intervento->iniziato_at) {
Notification::make()->title('Avvia prima il timer di intervento')->warning()->send();
return;
}
$endedAt = now();
$minutes = max(1, $this->intervento->iniziato_at->diffInMinutes($endedAt));
$this->intervento->update([
'terminato_at' => $endedAt,
'tempo_minuti' => $minutes,
'stato' => $this->intervento->stato === 'assegnato' ? 'in_corso' : $this->intervento->stato,
]);
$this->tempoMinuti = $minutes;
$this->reloadIntervento();
Notification::make()->title('Timer intervento fermato')->body('Tempo rilevato: ' . $minutes . ' minuti.')->success()->send();
}
public function updatedFotoLavoro(): void
{
$this->syncRapportoDescriptionSlots();
}
public function updatedDocumentiLavoro(): void
{
$this->syncRapportoDescriptionSlots();
}
public function saveRapporto(): void
{
$validated = $this->validate([
@ -232,10 +265,17 @@ public function saveRapporto(): void
$rapporto .= "\n\n" . $apparato;
}
$computedTempoMinuti = $validated['tempoMinuti'] ?? null;
$computedTerminatedAt = $this->intervento->terminato_at ?? now();
if (! $computedTempoMinuti && $this->intervento->iniziato_at) {
$computedTempoMinuti = max(1, $this->intervento->iniziato_at->diffInMinutes($computedTerminatedAt));
}
$payload = [
'rapporto_fornitore' => $rapporto,
'tempo_minuti' => $validated['tempoMinuti'] ?? null,
'terminato_at' => now(),
'tempo_minuti' => $computedTempoMinuti,
'terminato_at' => $computedTerminatedAt,
'stato' => ! empty($validated['lavoroStandard']) ? 'fatturabile' : 'in_verifica',
'eseguito_da_dipendente_id' => $this->dipendente?->id ?? $this->intervento->eseguito_da_dipendente_id,
];
@ -250,35 +290,49 @@ public function saveRapporto(): void
$payload['articoli_utilizzati'] = $articoli;
}
if ($this->fotoLavoro !== []) {
$firstPhoto = $this->fotoLavoro[0] ?? null;
if ($firstPhoto) {
$payload['foto_path'] = $firstPhoto->store('ticket-interventi/foto/' . $this->intervento->id, 'public');
}
}
$allFiles = array_merge($this->fotoLavoro, $this->documentiLavoro);
$firstPhotoPath = null;
$allFiles = array_merge($this->fotoLavoro, $this->documentiLavoro);
if ($allFiles !== [] && Schema::hasTable('ticket_attachments')) {
foreach ($allFiles as $index => $file) {
if (! $file) {
if (! is_object($file) || ! method_exists($file, 'store')) {
continue;
}
$path = $file->store('ticket-interventi/allegati/' . $this->intervento->id, 'public');
$originalName = (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/');
$stored = app(\App\Services\Support\TicketAttachmentUploadService::class)->store(
$file,
'ticket-interventi/allegati/' . $this->intervento->id,
[
'stored_basename' => pathinfo($this->buildStoredUploadDisplayName($index, $isImage, $originalName), PATHINFO_FILENAME),
'display_name' => $originalName,
]
);
if ($isImage && $firstPhotoPath === null) {
$firstPhotoPath = (string) $stored['path'];
}
TicketAttachment::query()->create([
'ticket_id' => (int) $this->intervento->ticket_id,
'ticket_update_id' => null,
'user_id' => (int) Auth::id(),
'file_path' => $path,
'original_file_name' => (string) $file->getClientOriginalName(),
'mime_type' => (string) $file->getClientMimeType(),
'size' => (int) $file->getSize(),
'description' => trim((string) ($this->descrizioneFile[$index] ?? '')) ?: 'Allegato rapporto fornitore',
'file_path' => (string) $stored['path'],
'original_file_name' => (string) $stored['original_name'],
'mime_type' => (string) $stored['mime'],
'size' => (int) $stored['size'],
'description' => trim((string) ($this->descrizioneFile[$index] ?? '')) ?: ($isImage ? 'Foto lavoro fornitore' : 'Allegato rapporto fornitore'),
]);
}
}
if ($firstPhotoPath !== null) {
$payload['foto_path'] = $firstPhotoPath;
}
$tokenInserito = trim((string) ($validated['qrToken'] ?? ''));
if ($tokenInserito !== '' && strcasecmp($tokenInserito, (string) $this->intervento->qr_token) === 0) {
$payload['qr_scansionato_at'] = now();
@ -320,7 +374,7 @@ public function saveRapporto(): void
$this->fotoLavoro = [];
$this->documentiLavoro = [];
$this->descrizioneFile = ['', '', '', '', '', ''];
$this->descrizioneFile = [];
$this->messaggioVeloce = '';
$this->proformaCodice = '';
$this->proformaNote = '';
@ -390,6 +444,48 @@ public function canAssignDipendente(): bool
return ! ($this->dipendente instanceof FornitoreDipendente) && count($this->dipendentiOptions) > 0;
}
/**
* @return array<int, array<string, mixed>>
*/
public function getRapportinoUploadsProperty(): array
{
$rows = [];
foreach (array_merge($this->fotoLavoro, $this->documentiLavoro) as $index => $file) {
if (! is_object($file)) {
continue;
}
$name = (string) (method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : ('file-' . $index));
$size = (int) (method_exists($file, 'getSize') ? $file->getSize() : 0);
$mime = (string) (method_exists($file, 'getClientMimeType') ? $file->getClientMimeType() : 'application/octet-stream');
$isImage = str_starts_with($mime, 'image/');
$isPdf = $mime === 'application/pdf' || str_ends_with(strtolower($name), '.pdf');
$previewUrl = null;
if ($isImage && method_exists($file, 'temporaryUrl')) {
try {
$previewUrl = (string) $file->temporaryUrl();
} catch (\Throwable) {
$previewUrl = null;
}
}
$rows[] = [
'index' => $index,
'name' => $name,
'size' => $size,
'mime' => $mime,
'is_image' => $isImage,
'is_pdf' => $isPdf,
'preview_url' => $previewUrl,
'description' => (string) ($this->descrizioneFile[$index] ?? ''),
];
}
return $rows;
}
protected function resolveInterventoRecord(int $recordId, ?Fornitore $fornitore = null, ?FornitoreDipendente $dipendente = null): ?TicketIntervento
{
$query = TicketIntervento::query();
@ -415,6 +511,27 @@ protected function resolveInterventoRecord(int $recordId, ?Fornitore $fornitore
->first();
}
protected function syncRapportoDescriptionSlots(): void
{
$next = [];
foreach (array_merge($this->fotoLavoro, $this->documentiLavoro) as $index => $_file) {
$next[$index] = (string) ($this->descrizioneFile[$index] ?? '');
}
$this->descrizioneFile = $next;
}
protected function buildStoredUploadDisplayName(int $index, bool $isImage, string $originalName): string
{
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
$baseName = 'ticket-' . str_pad((string) $this->intervento->ticket_id, 6, '0', STR_PAD_LEFT)
. '-int-' . str_pad((string) $this->intervento->id, 6, '0', STR_PAD_LEFT)
. '-' . ($isImage ? 'foto-' : 'doc-') . str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT);
return $baseName . ($extension !== '' ? '.' . $extension : '');
}
protected function reloadIntervento(): void
{
$this->intervento->load([

View File

@ -34,7 +34,7 @@ public function store(object $file, string $directory, array $options = []): arr
}
if ($displayName === '') {
$displayName = $storedName !== '' ? $storedName : $originalName;
$displayName = $originalName !== '' ? $originalName : ($storedName !== '' ? $storedName : 'file');
}
$mime = TicketAttachment::normalizeMimeType(

View File

@ -19,6 +19,8 @@ ## 2. Stato gia rilasciato
- miniature allegati ticket e preview modal lato fornitore
- anteprima immediata file in scheda intervento fornitore
- 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
## 3. Rubrica unica condivisa

View File

@ -0,0 +1,110 @@
<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">Lavorazioni operative fornitore</div>
<div class="text-sm text-gray-600">
@if($this->fornitoreLabel)
Elenco unico operativo per {{ $this->fornitoreLabel }}.
@else
Seleziona un fornitore dalla gestione ticket per aprire questa vista.
@endif
</div>
</div>
<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>
</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 per amministratori richiede un fornitore selezionato.
</div>
@else
<div class="rounded-xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900">
Prima base rilasciata: questa pagina unifica gia le lavorazioni arrivate dai ticket amministratore.
Le lavorazioni interne e i lavori ricorrenti verranno agganciati qui nello stesso elenco operativo.
</div>
<div class="grid gap-4 md:grid-cols-3">
<button type="button" wire:click="setScope('tutte')" class="rounded-xl border p-4 text-left {{ $scope === 'tutte' ? 'border-primary-500 bg-primary-50' : 'bg-white' }}">
<div class="text-xs uppercase tracking-wide text-gray-500">Tutte</div>
<div class="mt-1 text-2xl font-semibold">{{ $totals['tutte'] }}</div>
<div class="mt-2 text-sm text-gray-600">Vista unica di tutte le lavorazioni disponibili.</div>
</button>
<button type="button" wire:click="setScope('ticket_amministratore')" class="rounded-xl border p-4 text-left {{ $scope === 'ticket_amministratore' ? 'border-primary-500 bg-primary-50' : 'bg-white' }}">
<div class="text-xs uppercase tracking-wide text-gray-500">Da ticket amministratore</div>
<div class="mt-1 text-2xl font-semibold">{{ $totals['ticket_amministratore'] }}</div>
<div class="mt-2 text-sm text-gray-600">Interventi gia transitati dalla gestione ticket.</div>
</button>
<button type="button" wire:click="setScope('interne')" class="rounded-xl border p-4 text-left {{ $scope === 'interne' ? 'border-primary-500 bg-primary-50' : 'bg-white' }}">
<div class="text-xs uppercase tracking-wide text-gray-500">Interne</div>
<div class="mt-1 text-2xl font-semibold">{{ $totals['interne'] }}</div>
<div class="mt-2 text-sm text-gray-600">Spazio riservato alle lavorazioni interne del manutentore.</div>
</button>
</div>
@if($scope === 'interne')
<div class="rounded-xl border border-dashed bg-white p-6 text-sm text-gray-600">
Nessuna lavorazione interna ancora modellata nel database corrente.
Il prossimo step e aggiungere qui inserimento, elenco e storico delle lavorazioni non nate da ticket amministratore.
</div>
@else
<div class="rounded-xl border bg-white p-0">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-600">Origine</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Ticket</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Contatto</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Problema</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Operatore</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Stato</th>
<th class="px-4 py-3 text-left font-medium text-gray-600">Aggiornato</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
@forelse($rows as $row)
<tr>
<td class="px-4 py-3 align-top">
<span class="inline-flex rounded-full bg-sky-100 px-2 py-1 text-xs font-medium text-sky-700">{{ $row['origine'] }}</span>
</td>
<td class="px-4 py-3 align-top">
<div class="font-medium">#{{ $row['ticket_id'] }} - {{ $row['titolo'] }}</div>
<div class="text-xs text-gray-500">{{ $row['stabile'] }} · ingresso {{ $row['ingresso'] }}</div>
</td>
<td class="px-4 py-3 align-top">
<div>{{ $row['contatto'] }}</div>
<div class="text-xs text-gray-500">{{ $row['telefono'] !== '' ? $row['telefono'] : 'Telefono non disponibile' }}</div>
</td>
<td class="px-4 py-3 align-top">
<div>{{ $row['problema'] }}</div>
<div class="text-xs text-gray-500">Apparato: {{ $row['apparato'] }}</div>
</td>
<td class="px-4 py-3 align-top">{{ $row['operatore'] }}</td>
<td class="px-4 py-3 align-top">
<span class="inline-flex rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">{{ $row['stato'] }}</span>
</td>
<td class="px-4 py-3 align-top text-gray-600">{{ $row['updated_at'] }}</td>
<td class="px-4 py-3 text-right align-top">
<a href="{{ $row['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">Apri scheda</a>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-4 py-10 text-center text-sm text-gray-500">Nessuna lavorazione disponibile per questo filtro.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endif
@endif
</div>
</x-filament-panels::page>

View File

@ -1,12 +1,27 @@
<x-filament-panels::page>
@php($ticket = $this->intervento->ticket)
@php
$ticket = $this->intervento->ticket;
@endphp
<div class="space-y-4"
x-data="{
localFotoPreviews: [],
localDocumentoPreviews: [],
revoke(items) { (items || []).forEach(item => { if (item && item.url) { URL.revokeObjectURL(item.url); } }); },
mapFiles(fileList) { return Array.from(fileList || []).map(file => ({ name: file.name, size: file.size || 0, isImage: (file.type || '').startsWith('image/'), url: (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null })); },
mapFiles(fileList) {
return Array.from(fileList || []).map(file => {
const mime = file.type || '';
const isImage = mime.startsWith('image/');
const isPdf = mime === 'application/pdf' || (file.name || '').toLowerCase().endsWith('.pdf');
return {
name: file.name,
size: file.size || 0,
isImage,
isPdf,
url: (isImage || isPdf) ? URL.createObjectURL(file) : null,
};
});
},
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]}`; }
@ -21,6 +36,7 @@
</div>
<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">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>
</div>
</div>
@ -78,9 +94,29 @@
<div class="rounded-xl border bg-white p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-semibold">Operativo e rapportino</div>
@if($this->intervento->stato === 'assegnato')
<x-filament::button size="sm" wire:click="startIntervento">Prendi in carico</x-filament::button>
@endif
<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>
@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>
@endif
</div>
</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>
<div class="mt-1">{{ optional($this->intervento->iniziato_at)->format('d/m/Y H:i') ?: 'Non avviato' }}</div>
</div>
<div>
<div class="font-semibold uppercase tracking-wide text-slate-500">Timer stop</div>
<div class="mt-1">{{ optional($this->intervento->terminato_at)->format('d/m/Y H:i') ?: 'In corso' }}</div>
</div>
<div>
<div class="font-semibold uppercase tracking-wide text-slate-500">Durata rilevata</div>
<div class="mt-1">{{ $tempoMinuti ? ($tempoMinuti . ' minuti') : 'Da calcolare' }}</div>
</div>
</div>
@if($this->canAssignDipendente())
@ -179,10 +215,13 @@
<template x-for="item in localDocumentoPreviews" :key="item.name + item.size">
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
<div class="flex aspect-[4/3] items-center justify-center rounded-lg border bg-slate-50">
<template x-if="item.url">
<template x-if="item.isImage && item.url">
<img :src="item.url" :alt="item.name" class="h-full w-full rounded object-cover" />
</template>
<template x-if="!item.url">
<template x-if="item.isPdf && item.url">
<iframe :src="item.url + '#toolbar=0&navpanes=0&scrollbar=0&page=1&view=FitH'" class="h-full w-full rounded border-0 bg-white"></iframe>
</template>
<template x-if="!item.isImage && !item.isPdf">
<div class="text-center text-gray-500">
<div class="text-sm font-semibold">FILE</div>
<div class="mt-1 text-[11px]">Anteprima pronta</div>
@ -197,14 +236,46 @@
</div>
</div>
<div class="mt-4 grid gap-2 md:grid-cols-2">
@foreach($descrizioneFile as $index => $item)
<label class="block text-sm">
<span class="mb-1 block font-medium">Descrizione file {{ $index + 1 }}</span>
<input type="text" wire:model.defer="descrizioneFile.{{ $index }}" class="w-full rounded-lg border-gray-300" />
</label>
@endforeach
</div>
@if(count($this->rapportinoUploads) > 0)
<div class="mt-4 rounded-xl border bg-white p-4">
<div class="text-sm font-semibold">File selezionati per il rapportino</div>
<div class="mt-1 text-xs text-gray-500">Ogni foto o documento puo avere la sua descrizione, come nella gestione ticket.</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
@foreach($this->rapportinoUploads as $upload)
<div class="rounded-xl border bg-slate-50 p-3 text-xs shadow-sm">
<div class="aspect-[4/3] rounded-lg border bg-white p-2">
@if($upload['is_image'] && $upload['preview_url'])
<img src="{{ $upload['preview_url'] }}" alt="{{ $upload['name'] }}" class="h-full w-full rounded object-cover" />
@elseif($upload['is_pdf'])
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>
<div class="text-sm font-semibold">PDF</div>
<div class="mt-1 text-[11px]">Anteprima immediata disponibile sopra durante la selezione</div>
</div>
</div>
@else
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>
<div class="text-sm font-semibold">FILE</div>
<div class="mt-1 text-[11px]">{{ strtoupper(pathinfo((string) $upload['name'], PATHINFO_EXTENSION) ?: 'DOC') }}</div>
</div>
</div>
@endif
</div>
<div class="mt-2 font-medium">{{ $upload['name'] }}</div>
<div class="mt-1 text-gray-500">{{ number_format(((int) $upload['size']) / 1024, 1, ',', '.') }} KB</div>
<label class="mt-3 block text-sm">
<span class="mb-1 block font-medium">Descrizione allegato</span>
<input type="text" wire:model.defer="descrizioneFile.{{ $upload['index'] }}" class="w-full rounded-lg border-gray-300" placeholder="Es. foto finale, documento tecnico, rapporto PDF" />
</label>
</div>
@endforeach
</div>
</div>
@endif
<div class="mt-4 grid gap-3 md:grid-cols-2">
<label class="block text-sm">
@ -276,6 +347,8 @@
<div class="aspect-[4/3] rounded-lg border bg-gray-50 p-2">
@if($attachment->isImage())
<img src="{{ $this->getAttachmentPublicUrl($attachment) }}" alt="{{ $attachment->original_file_name }}" class="h-full w-full rounded object-cover" />
@elseif($attachment->isPdf())
<iframe src="{{ $this->getAttachmentPublicUrl($attachment) }}#toolbar=0&navpanes=0&scrollbar=0&page=1&view=FitH" class="h-full w-full rounded border-0 bg-white"></iframe>
@else
<div class="flex h-full items-center justify-center text-center text-gray-500">
<div>

View File

@ -13,6 +13,7 @@
</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ \App\Filament\Pages\Fornitore\LavorazioniOperative::getUrl(['fornitore' => $this->fornitoreId], panel: 'admin-filament') }}" 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">Lavorazioni</a>
<a href="{{ $this->getCollaboratoriUrl() }}" 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">Collaboratori</a>
<a href="{{ $this->getContabilitaUrl() }}" 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">Contabilita</a>
</div>