feat: migliora rapportino fornitore e lavorazioni
This commit is contained in:
parent
e44a3f039c
commit
05c10a6f8a
|
|
@ -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.
|
||||
|
|
|
|||
163
app/Filament/Pages/Fornitore/LavorazioniOperative.php
Normal file
163
app/Filament/Pages/Fornitore/LavorazioniOperative.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user