diff --git a/CHANGELOG.md b/CHANGELOG.md index de6e63c..f72eade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/Filament/Pages/Fornitore/LavorazioniOperative.php b/app/Filament/Pages/Fornitore/LavorazioniOperative.php new file mode 100644 index 0000000..7ce5adf --- /dev/null +++ b/app/Filament/Pages/Fornitore/LavorazioniOperative.php @@ -0,0 +1,163 @@ +> */ + public array $rows = []; + + /** @var array */ + 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; + } +} \ No newline at end of file diff --git a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php index 3407d64..370a0eb 100644 --- a/app/Filament/Pages/Fornitore/TicketInterventoScheda.php +++ b/app/Filament/Pages/Fornitore/TicketInterventoScheda.php @@ -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> + */ + 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([ diff --git a/app/Services/Support/TicketAttachmentUploadService.php b/app/Services/Support/TicketAttachmentUploadService.php index 6bf7c28..43057df 100644 --- a/app/Services/Support/TicketAttachmentUploadService.php +++ b/app/Services/Support/TicketAttachmentUploadService.php @@ -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( diff --git a/docs/support/FORNITORE-OPERATIVITA-ROADMAP-2026-04.md b/docs/support/FORNITORE-OPERATIVITA-ROADMAP-2026-04.md index 94fe7e2..93cdafc 100644 --- a/docs/support/FORNITORE-OPERATIVITA-ROADMAP-2026-04.md +++ b/docs/support/FORNITORE-OPERATIVITA-ROADMAP-2026-04.md @@ -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 diff --git a/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php b/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php new file mode 100644 index 0000000..124171b --- /dev/null +++ b/resources/views/filament/pages/fornitore/lavorazioni-operative.blade.php @@ -0,0 +1,110 @@ + +
+
+
+
+
Lavorazioni operative fornitore
+
+ @if($this->fornitoreLabel) + Elenco unico operativo per {{ $this->fornitoreLabel }}. + @else + Seleziona un fornitore dalla gestione ticket per aprire questa vista. + @endif +
+
+ +
+
+ + @if($this->missingAdminContext) +
+ Questa vista per amministratori richiede un fornitore selezionato. +
+ @else +
+ 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. +
+ +
+ + + +
+ + @if($scope === 'interne') +
+ 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. +
+ @else +
+
+ + + + + + + + + + + + + + + @forelse($rows as $row) + + + + + + + + + + + @empty + + + + @endforelse + +
OrigineTicketContattoProblemaOperatoreStatoAggiornato
+ {{ $row['origine'] }} + +
#{{ $row['ticket_id'] }} - {{ $row['titolo'] }}
+
{{ $row['stabile'] }} ยท ingresso {{ $row['ingresso'] }}
+
+
{{ $row['contatto'] }}
+
{{ $row['telefono'] !== '' ? $row['telefono'] : 'Telefono non disponibile' }}
+
+
{{ $row['problema'] }}
+
Apparato: {{ $row['apparato'] }}
+
{{ $row['operatore'] }} + {{ $row['stato'] }} + {{ $row['updated_at'] }} + Apri scheda +
Nessuna lavorazione disponibile per questo filtro.
+
+
+ @endif + @endif +
+
\ No newline at end of file diff --git a/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php b/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php index a35757f..7ca450e 100644 --- a/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php +++ b/resources/views/filament/pages/fornitore/ticket-intervento-scheda.blade.php @@ -1,12 +1,27 @@ - @php($ticket = $this->intervento->ticket) + @php + $ticket = $this->intervento->ticket; + @endphp @@ -78,9 +94,29 @@
Operativo e rapportino
- @if($this->intervento->stato === 'assegnato') - Prendi in carico - @endif +
+ @if($this->intervento->stato === 'assegnato' || ! $this->intervento->iniziato_at || $this->intervento->terminato_at) + Start intervento + @endif + @if($this->intervento->iniziato_at && ! $this->intervento->terminato_at) + Stop intervento + @endif +
+
+ +
+
+
Timer avvio
+
{{ optional($this->intervento->iniziato_at)->format('d/m/Y H:i') ?: 'Non avviato' }}
+
+
+
Timer stop
+
{{ optional($this->intervento->terminato_at)->format('d/m/Y H:i') ?: 'In corso' }}
+
+
+
Durata rilevata
+
{{ $tempoMinuti ? ($tempoMinuti . ' minuti') : 'Da calcolare' }}
+
@if($this->canAssignDipendente()) @@ -179,10 +215,13 @@