Preserve public ticket and water photo metadata
This commit is contained in:
parent
baaadd660b
commit
1301c4b144
|
|
@ -4,16 +4,26 @@
|
|||
use App\Http\Controllers\Condomino\Concerns\ResolvesCondominoAccess;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CategoriaTicket;
|
||||
use App\Models\Documento;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketAttachment;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Models\User;
|
||||
use App\Services\Documenti\ImageTextExtractionService;
|
||||
use App\Services\Documenti\PdfTextExtractionService;
|
||||
use App\Services\Support\TicketAttachmentUploadService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
use ResolvesCondominoAccess;
|
||||
|
||||
/** @var array<int, string>|null */
|
||||
private ?array $documentiColumnsCache = null;
|
||||
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
|
@ -57,6 +67,8 @@ public function store(Request $request)
|
|||
'luogo_intervento' => 'nullable|string|max:255',
|
||||
'priorita' => 'required|in:Bassa,Media,Alta,Urgente',
|
||||
'allegati.*' => 'nullable|file|max:20480', // 20MB per file
|
||||
'attachment_descriptions' => 'nullable|array',
|
||||
'attachment_descriptions.*' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$unitaImmobiliare = $this->resolveUserUnita($user)
|
||||
|
|
@ -84,22 +96,42 @@ public function store(Request $request)
|
|||
|
||||
// Gestione allegati
|
||||
if ($request->hasFile('allegati')) {
|
||||
foreach ($request->file('allegati') as $file) {
|
||||
$supportsAttachmentMetadata = Schema::hasColumn('ticket_attachments', 'metadata');
|
||||
$attachmentDescriptions = (array) $request->input('attachment_descriptions', []);
|
||||
|
||||
foreach ($request->file('allegati') as $index => $file) {
|
||||
if (! $file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->store('ticket-attachments', 'public');
|
||||
$displayName = $this->buildStoredAttachmentDisplayName($ticket, (int) $index, (string) $file->getClientOriginalName());
|
||||
$stored = app(TicketAttachmentUploadService::class)->store($file, 'ticket-attachments/' . $ticket->id, [
|
||||
'stored_basename' => pathinfo($displayName, PATHINFO_FILENAME),
|
||||
'display_name' => $displayName,
|
||||
]);
|
||||
|
||||
$ticket->attachments()->create([
|
||||
$attachmentPayload = [
|
||||
'ticket_update_id' => null,
|
||||
'user_id' => $user->id,
|
||||
'file_path' => $path,
|
||||
'original_file_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => (string) $file->getMimeType(),
|
||||
'size' => (int) $file->getSize(),
|
||||
'description' => 'Upload da area condomino',
|
||||
]);
|
||||
'file_path' => $stored['path'],
|
||||
'original_file_name' => $stored['original_name'],
|
||||
'mime_type' => $stored['mime'],
|
||||
'size' => $stored['size'],
|
||||
'description' => $this->resolveAttachmentDescription(
|
||||
(string) ($attachmentDescriptions[$index] ?? ''),
|
||||
$stored,
|
||||
),
|
||||
];
|
||||
|
||||
if ($supportsAttachmentMetadata) {
|
||||
$attachmentPayload['metadata'] = is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [];
|
||||
}
|
||||
|
||||
$attachment = $ticket->attachments()->create($attachmentPayload);
|
||||
|
||||
if ($attachment instanceof TicketAttachment) {
|
||||
$this->archiveTicketAttachmentDocument($ticket, $attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,4 +160,161 @@ public function show(Ticket $ticket)
|
|||
return view('condomino.tickets.show', compact('ticket'));
|
||||
}
|
||||
|
||||
private function buildStoredAttachmentDisplayName(Ticket $ticket, int $index, string $originalName): string
|
||||
{
|
||||
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
|
||||
return sprintf(
|
||||
'ticket-%06d-all-%02d%s',
|
||||
(int) $ticket->id,
|
||||
$index + 1,
|
||||
$extension !== '' ? '.' . $extension : ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stored
|
||||
*/
|
||||
private function resolveAttachmentDescription(string $specificDescription, array $stored): string
|
||||
{
|
||||
$specificDescription = trim($specificDescription);
|
||||
if ($specificDescription !== '') {
|
||||
return $specificDescription;
|
||||
}
|
||||
|
||||
$metadata = is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [];
|
||||
$parts = ['Upload da area condomino'];
|
||||
|
||||
$lat = $metadata['gps_decimal']['lat'] ?? null;
|
||||
$lng = $metadata['gps_decimal']['lng'] ?? null;
|
||||
if (is_numeric($lat) && is_numeric($lng)) {
|
||||
$parts[] = 'GPS: ' . number_format((float) $lat, 5, '.', '') . ',' . number_format((float) $lng, 5, '.', '');
|
||||
}
|
||||
|
||||
$mapsUrl = trim((string) ($metadata['google_maps_url'] ?? ''));
|
||||
if ($mapsUrl !== '') {
|
||||
$parts[] = 'Maps: ' . $mapsUrl;
|
||||
}
|
||||
|
||||
$exifDate = trim((string) ($metadata['exif_datetime'] ?? ''));
|
||||
if ($exifDate !== '') {
|
||||
$parts[] = 'EXIF: ' . $exifDate;
|
||||
}
|
||||
|
||||
$device = trim((string) ($metadata['device'] ?? ''));
|
||||
if ($device !== '') {
|
||||
$parts[] = 'Device: ' . $device;
|
||||
}
|
||||
|
||||
$displayName = trim((string) ($stored['original_name'] ?? ''));
|
||||
if ($displayName !== '') {
|
||||
$parts[] = 'File: ' . $displayName;
|
||||
}
|
||||
|
||||
$sourceName = trim((string) ($stored['source_original_name'] ?? ''));
|
||||
if ($sourceName !== '' && $sourceName !== $displayName) {
|
||||
$parts[] = 'Orig: ' . $sourceName;
|
||||
}
|
||||
|
||||
return mb_substr(implode(' | ', array_filter($parts)), 0, 255);
|
||||
}
|
||||
|
||||
private function archiveTicketAttachmentDocument(Ticket $ticket, TicketAttachment $attachment): void
|
||||
{
|
||||
if (! Schema::hasTable('documenti')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$publicPath = (string) ($attachment->file_path ?? '');
|
||||
if ($publicPath === '' || ! Storage::disk('public')->exists($publicPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$localPath = Storage::disk('public')->path($publicPath);
|
||||
$contents = (string) Storage::disk('public')->get($publicPath);
|
||||
$extension = strtolower((string) pathinfo($localPath, PATHINFO_EXTENSION));
|
||||
$archiveReference = 'ticket-' . (int) $ticket->id . '-attachment-' . (int) $attachment->id;
|
||||
$attachmentMeta = is_array($attachment->metadata ?? null) ? $attachment->metadata : [];
|
||||
|
||||
$payload = [
|
||||
'documentable_type' => Ticket::class,
|
||||
'documentable_id' => (int) $ticket->id,
|
||||
'stabile_id' => (int) $ticket->stabile_id,
|
||||
'utente_id' => Auth::id(),
|
||||
'nome' => (string) ($attachment->original_file_name ?: ('Allegato ticket #' . (int) $attachment->id)),
|
||||
'nome_file' => (string) ($attachment->original_file_name ?: basename($localPath)),
|
||||
'path_file' => $localPath,
|
||||
'percorso_file' => $localPath,
|
||||
'tipo_documento' => 'ticket_allegato',
|
||||
'tipologia' => $attachment->isImage() ? 'foto' : ($attachment->isPdf() ? 'comunicazione' : 'altro'),
|
||||
'mime_type' => $attachment->resolvedMimeType(),
|
||||
'descrizione' => (string) ($attachment->description ?: 'Allegato ticket #' . (int) $ticket->id),
|
||||
'note' => 'Ticket #' . (int) $ticket->id . ' · ' . (string) ($ticket->titolo ?: 'senza titolo'),
|
||||
'dimensione_file' => (int) ($attachment->size ?? strlen($contents)),
|
||||
'estensione' => $extension !== '' ? $extension : null,
|
||||
'hash_file' => hash('sha256', $contents),
|
||||
'xml_data' => [
|
||||
'ticket_id' => (int) $ticket->id,
|
||||
'ticket_attachment_id' => (int) $attachment->id,
|
||||
'source_public_path' => $publicPath,
|
||||
'archive_reference' => $archiveReference,
|
||||
'archive_scope' => 'ticket',
|
||||
'visibility_scope' => 'interno',
|
||||
'attachment_metadata' => $attachmentMeta,
|
||||
],
|
||||
'metadati_ocr' => $attachment->isImage()
|
||||
? ['status' => 'pending_image_ocr', 'source' => 'ticket_attachment']
|
||||
: ['source' => 'ticket_attachment'],
|
||||
'data_upload' => now(),
|
||||
'approvato' => true,
|
||||
'archiviato' => true,
|
||||
'urgente' => false,
|
||||
'is_demo' => false,
|
||||
'archive_reference' => $archiveReference,
|
||||
'visibility_scope' => 'interno',
|
||||
'visibility_groups' => ['supporto', 'amministrazione', 'stabile:' . (int) $ticket->stabile_id],
|
||||
'visibility_roles' => ['super-admin', 'admin', 'amministratore', 'collaboratore'],
|
||||
];
|
||||
|
||||
$documento = Documento::query()->updateOrCreate(
|
||||
[
|
||||
'documentable_type' => Ticket::class,
|
||||
'documentable_id' => (int) $ticket->id,
|
||||
'path_file' => $localPath,
|
||||
],
|
||||
$this->filterDocumentoPayload($payload)
|
||||
);
|
||||
|
||||
if ($attachment->isPdf()) {
|
||||
app(PdfTextExtractionService::class)->extractAndStore($documento, false);
|
||||
} elseif ($attachment->isImage()) {
|
||||
app(ImageTextExtractionService::class)->extractAndStore($documento, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function documentiColumns(): array
|
||||
{
|
||||
if ($this->documentiColumnsCache === null) {
|
||||
$this->documentiColumnsCache = Schema::hasTable('documenti')
|
||||
? Schema::getColumnListing('documenti')
|
||||
: [];
|
||||
}
|
||||
|
||||
return $this->documentiColumnsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function filterDocumentoPayload(array $payload): array
|
||||
{
|
||||
$allowed = array_flip($this->documentiColumns());
|
||||
|
||||
return array_intersect_key($payload, $allowed);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\PublicAccess;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\StabileServizio;
|
||||
use App\Models\StabileServizioLettura;
|
||||
use App\Models\UnitaImmobiliare;
|
||||
use App\Services\Support\TicketAttachmentUploadService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -17,8 +17,8 @@ class WaterReadingRequestController extends Controller
|
|||
public function show(Request $request): View
|
||||
{
|
||||
$servizioId = (int) $request->integer('servizio');
|
||||
$unitaId = (int) $request->integer('unita');
|
||||
$requestId = (int) $request->integer('request');
|
||||
$unitaId = (int) $request->integer('unita');
|
||||
$requestId = (int) $request->integer('request');
|
||||
|
||||
$servizio = StabileServizio::query()
|
||||
->where('id', $servizioId)
|
||||
|
|
@ -33,41 +33,41 @@ public function show(Request $request): View
|
|||
|
||||
$pendingRequest = $requestId > 0
|
||||
? StabileServizioLettura::query()
|
||||
->where('id', $requestId)
|
||||
->where('stabile_servizio_id', $servizioId)
|
||||
->where('unita_immobiliare_id', $unitaId)
|
||||
->first()
|
||||
->where('id', $requestId)
|
||||
->where('stabile_servizio_id', $servizioId)
|
||||
->where('unita_immobiliare_id', $unitaId)
|
||||
->first()
|
||||
: null;
|
||||
|
||||
$previous = StabileServizioLettura::query()
|
||||
->where('stabile_servizio_id', $servizioId)
|
||||
->where('unita_immobiliare_id', $unitaId)
|
||||
->whereNotNull('lettura_fine')
|
||||
->when($pendingRequest, fn ($query) => $query->where('id', '!=', (int) $pendingRequest->id))
|
||||
->when($pendingRequest, fn($query) => $query->where('id', '!=', (int) $pendingRequest->id))
|
||||
->orderByDesc('periodo_al')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$submitUrl = URL::temporarySignedRoute('public.water-reading.store', now()->addDays(30), array_filter([
|
||||
'servizio' => $servizioId,
|
||||
'unita' => $unitaId,
|
||||
'request' => $pendingRequest?->id,
|
||||
'unita' => $unitaId,
|
||||
'request' => $pendingRequest?->id,
|
||||
]));
|
||||
|
||||
return view('public.water-reading.show', [
|
||||
'servizio' => $servizio,
|
||||
'unita' => $unita,
|
||||
'servizio' => $servizio,
|
||||
'unita' => $unita,
|
||||
'pendingRequest' => $pendingRequest,
|
||||
'previous' => $previous,
|
||||
'submitUrl' => $submitUrl,
|
||||
'previous' => $previous,
|
||||
'submitUrl' => $submitUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$servizioId = (int) $request->integer('servizio');
|
||||
$unitaId = (int) $request->integer('unita');
|
||||
$requestId = (int) $request->integer('request');
|
||||
$unitaId = (int) $request->integer('unita');
|
||||
$requestId = (int) $request->integer('request');
|
||||
|
||||
$servizio = StabileServizio::query()
|
||||
->where('id', $servizioId)
|
||||
|
|
@ -80,35 +80,35 @@ public function store(Request $request): RedirectResponse
|
|||
->firstOrFail();
|
||||
|
||||
$validated = $request->validate([
|
||||
'lettura_attuale' => ['required', 'numeric', 'min:0'],
|
||||
'data_lettura' => ['nullable', 'date'],
|
||||
'rilevatore_nome' => ['required', 'string', 'max:255'],
|
||||
'lettura_attuale' => ['required', 'numeric', 'min:0'],
|
||||
'data_lettura' => ['nullable', 'date'],
|
||||
'rilevatore_nome' => ['required', 'string', 'max:255'],
|
||||
'rilevatore_contatto' => ['nullable', 'string', 'max:255'],
|
||||
'note' => ['nullable', 'string', 'max:2000'],
|
||||
'foto_1' => ['nullable', 'image', 'max:8192'],
|
||||
'foto_2' => ['nullable', 'image', 'max:8192'],
|
||||
'note' => ['nullable', 'string', 'max:2000'],
|
||||
'foto_1' => ['nullable', 'image', 'max:8192'],
|
||||
'foto_2' => ['nullable', 'image', 'max:8192'],
|
||||
]);
|
||||
|
||||
$pendingRequest = $requestId > 0
|
||||
? StabileServizioLettura::query()
|
||||
->where('id', $requestId)
|
||||
->where('stabile_servizio_id', $servizioId)
|
||||
->where('unita_immobiliare_id', $unitaId)
|
||||
->first()
|
||||
->where('id', $requestId)
|
||||
->where('stabile_servizio_id', $servizioId)
|
||||
->where('unita_immobiliare_id', $unitaId)
|
||||
->first()
|
||||
: null;
|
||||
|
||||
$previous = StabileServizioLettura::query()
|
||||
->where('stabile_servizio_id', $servizioId)
|
||||
->where('unita_immobiliare_id', $unitaId)
|
||||
->whereNotNull('lettura_fine')
|
||||
->when($pendingRequest, fn ($query) => $query->where('id', '!=', (int) $pendingRequest->id))
|
||||
->when($pendingRequest, fn($query) => $query->where('id', '!=', (int) $pendingRequest->id))
|
||||
->orderByDesc('periodo_al')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
$letturaInizio = $previous?->lettura_fine !== null ? (float) $previous->lettura_fine : null;
|
||||
$letturaFine = (float) $validated['lettura_attuale'];
|
||||
$consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null;
|
||||
$letturaFine = (float) $validated['lettura_attuale'];
|
||||
$consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null;
|
||||
|
||||
if ($consumo !== null && $consumo < 0) {
|
||||
return back()->withErrors([
|
||||
|
|
@ -116,43 +116,72 @@ public function store(Request $request): RedirectResponse
|
|||
])->withInput();
|
||||
}
|
||||
|
||||
$photoPaths = [];
|
||||
$photoAssets = [];
|
||||
foreach (['foto_1', 'foto_2'] as $photoField) {
|
||||
if ($request->hasFile($photoField)) {
|
||||
$photoPaths[] = $request->file($photoField)?->store('letture-acqua-public', 'public');
|
||||
$file = $request->file($photoField);
|
||||
if (! $file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stored = app(TicketAttachmentUploadService::class)->store(
|
||||
$file,
|
||||
'letture-acqua-public/' . $servizio->id . '/' . $unita->id,
|
||||
[
|
||||
'stored_basename' => 'water-reading-' . $servizio->id . '-' . $unita->id . '-' . $photoField,
|
||||
]
|
||||
);
|
||||
|
||||
$photoAssets[] = [
|
||||
'field' => $photoField,
|
||||
'path' => $stored['path'],
|
||||
'original_name' => $stored['original_name'] ?? null,
|
||||
'source_original_name' => $stored['source_original_name'] ?? null,
|
||||
'mime' => $stored['mime'] ?? null,
|
||||
'size' => $stored['size'] ?? null,
|
||||
'metadata' => is_array($stored['metadata'] ?? null) ? $stored['metadata'] : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$photoPaths = array_values(array_filter(array_map(
|
||||
static fn(array $photo): ?string => is_string($photo['path'] ?? null) ? $photo['path'] : null,
|
||||
$photoAssets
|
||||
)));
|
||||
|
||||
$payload = [
|
||||
'stabile_id' => (int) $servizio->stabile_id,
|
||||
'stabile_servizio_id' => (int) $servizio->id,
|
||||
'unita_immobiliare_id' => (int) $unita->id,
|
||||
'fornitore_id' => $servizio->fornitore_id,
|
||||
'periodo_dal' => $previous?->periodo_al,
|
||||
'periodo_al' => isset($validated['data_lettura']) ? Carbon::parse((string) $validated['data_lettura'])->toDateString() : now()->toDateString(),
|
||||
'tipologia_lettura' => 'autolettura_link_pubblico',
|
||||
'canale_acquisizione' => 'portale_pubblico',
|
||||
'workflow_stato' => 'ricevuta',
|
||||
'riferimento_acquisizione' => $pendingRequest?->riferimento_acquisizione ?: ('PUBREAD:' . (int) $servizio->id . ':' . (int) $unita->id . ':' . now()->format('YmdHis')),
|
||||
'rilevatore_tipo' => 'link_pubblico',
|
||||
'rilevatore_nome' => trim((string) $validated['rilevatore_nome']),
|
||||
'stabile_id' => (int) $servizio->stabile_id,
|
||||
'stabile_servizio_id' => (int) $servizio->id,
|
||||
'unita_immobiliare_id' => (int) $unita->id,
|
||||
'fornitore_id' => $servizio->fornitore_id,
|
||||
'periodo_dal' => $previous?->periodo_al,
|
||||
'periodo_al' => isset($validated['data_lettura']) ? Carbon::parse((string) $validated['data_lettura'])->toDateString() : now()->toDateString(),
|
||||
'tipologia_lettura' => 'autolettura_link_pubblico',
|
||||
'canale_acquisizione' => 'portale_pubblico',
|
||||
'workflow_stato' => 'ricevuta',
|
||||
'riferimento_acquisizione' => $pendingRequest?->riferimento_acquisizione ?: ('PUBREAD:' . (int) $servizio->id . ':' . (int) $unita->id . ':' . now()->format('YmdHis')),
|
||||
'rilevatore_tipo' => 'link_pubblico',
|
||||
'rilevatore_nome' => trim((string) $validated['rilevatore_nome']),
|
||||
'lettura_precedente_valore' => $letturaInizio,
|
||||
'lettura_inizio' => $letturaInizio,
|
||||
'lettura_fine' => $letturaFine,
|
||||
'lettura_foto_path' => $photoPaths[0] ?? null,
|
||||
'lettura_foto_metadata' => [
|
||||
'paths' => $photoPaths,
|
||||
'lettura_inizio' => $letturaInizio,
|
||||
'lettura_fine' => $letturaFine,
|
||||
'lettura_foto_path' => $photoPaths[0] ?? null,
|
||||
'lettura_foto_original_name'=> $photoAssets[0]['original_name'] ?? null,
|
||||
'lettura_foto_metadata' => [
|
||||
'photos' => $photoAssets,
|
||||
'paths' => $photoPaths,
|
||||
'contact' => trim((string) ($validated['rilevatore_contatto'] ?? '')),
|
||||
'note' => trim((string) ($validated['note'] ?? '')),
|
||||
'note' => trim((string) ($validated['note'] ?? '')),
|
||||
],
|
||||
'consumo_valore' => $consumo,
|
||||
'consumo_unita' => 'mc',
|
||||
'raw' => [
|
||||
'source' => 'public_water_reading_link',
|
||||
'contact' => trim((string) ($validated['rilevatore_contatto'] ?? '')),
|
||||
'note' => trim((string) ($validated['note'] ?? '')),
|
||||
'consumo_valore' => $consumo,
|
||||
'consumo_unita' => 'mc',
|
||||
'raw' => [
|
||||
'source' => 'public_water_reading_link',
|
||||
'contact' => trim((string) ($validated['rilevatore_contatto'] ?? '')),
|
||||
'note' => trim((string) ($validated['note'] ?? '')),
|
||||
'photo_assets'=> $photoAssets,
|
||||
'photo_paths' => $photoPaths,
|
||||
'request_id' => $pendingRequest?->id,
|
||||
'request_id' => $pendingRequest?->id,
|
||||
],
|
||||
];
|
||||
|
||||
|
|
@ -165,4 +194,4 @@ public function store(Request $request): RedirectResponse
|
|||
|
||||
return redirect()->back()->with('success', 'Lettura acqua inviata correttamente.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ class TicketAttachment extends Model
|
|||
use HasFactory;
|
||||
|
||||
protected $table = 'ticket_attachments';
|
||||
protected $fillable = ['ticket_id', 'ticket_update_id', 'user_id', 'file_path', 'original_file_name', 'mime_type', 'size', 'description'];
|
||||
protected $fillable = ['ticket_id', 'ticket_update_id', 'user_id', 'file_path', 'original_file_name', 'mime_type', 'size', 'description', 'metadata'];
|
||||
|
||||
protected $casts = [
|
||||
'size' => 'integer',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?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_attachments') || Schema::hasColumn('ticket_attachments', 'metadata')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('ticket_attachments', function (Blueprint $table): void {
|
||||
$table->json('metadata')->nullable()->after('description');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('ticket_attachments') || ! Schema::hasColumn('ticket_attachments', 'metadata')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('ticket_attachments', function (Blueprint $table): void {
|
||||
$table->dropColumn('metadata');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -74,16 +74,33 @@
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="allegati_camera" class="block text-sm font-medium">Foto da fotocamera</label>
|
||||
<input id="allegati_camera" name="allegati[]" type="file" multiple accept="image/*" capture="environment" class="mt-1 block w-full rounded-md border-slate-300">
|
||||
<p class="mt-1 text-xs text-slate-500">Scatta foto direttamente da smartphone.</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Allegati con anteprima e descrizione</div>
|
||||
<p class="text-xs text-slate-500">Ogni file compare qui sotto in un box dedicato, così puoi descriverlo subito prima dell'invio.</p>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">Max 20MB per file</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="allegati_file" class="block text-sm font-medium">File da telefono / PC</label>
|
||||
<input id="allegati_file" name="allegati[]" type="file" multiple accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip" class="mt-1 block w-full rounded-md border-slate-300">
|
||||
<p class="mt-1 text-xs text-slate-500">Puoi caricare immagini, PDF e documenti; max 20MB per file.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="allegati_camera" class="block text-sm font-medium">Foto da fotocamera</label>
|
||||
<input id="allegati_camera" name="allegati[]" type="file" multiple accept="image/*" capture="environment" class="mt-1 block w-full rounded-md border-slate-300">
|
||||
<p class="mt-1 text-xs text-slate-500">Scatta foto direttamente da smartphone.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="allegati_file" class="block text-sm font-medium">File da telefono / PC</label>
|
||||
<input id="allegati_file" name="allegati[]" type="file" multiple accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt,.zip" class="mt-1 block w-full rounded-md border-slate-300">
|
||||
<p class="mt-1 text-xs text-slate-500">Immagini, PDF e documenti. I metadati foto vengono letti in fase di salvataggio.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ticket-attachment-preview-empty" class="mt-4 rounded-lg border border-dashed border-slate-300 bg-white px-4 py-5 text-center text-sm text-slate-500">
|
||||
Nessun allegato selezionato.
|
||||
</div>
|
||||
<div id="ticket-attachment-preview-list" class="mt-4 grid gap-3 sm:grid-cols-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
|
|
@ -92,5 +109,87 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const cameraInput = document.getElementById('allegati_camera');
|
||||
const fileInput = document.getElementById('allegati_file');
|
||||
const previewList = document.getElementById('ticket-attachment-preview-list');
|
||||
const emptyState = document.getElementById('ticket-attachment-preview-empty');
|
||||
|
||||
if (!cameraInput || !fileInput || !previewList || !emptyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formatBytes = (value) => {
|
||||
const bytes = Number(value || 0);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 KB';
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const currentFiles = () => ([
|
||||
...Array.from(cameraInput.files || []),
|
||||
...Array.from(fileInput.files || []),
|
||||
]);
|
||||
|
||||
const renderPreviews = () => {
|
||||
const files = currentFiles();
|
||||
previewList.innerHTML = '';
|
||||
emptyState.classList.toggle('hidden', files.length > 0);
|
||||
|
||||
files.forEach((file, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm';
|
||||
|
||||
const media = document.createElement(file.type.startsWith('image/') ? 'img' : 'div');
|
||||
if (file.type.startsWith('image/')) {
|
||||
media.className = 'h-40 w-full bg-slate-100 object-cover';
|
||||
media.alt = file.name;
|
||||
media.src = URL.createObjectURL(file);
|
||||
} else {
|
||||
media.className = 'flex h-40 w-full items-center justify-center bg-slate-100 text-sm font-medium text-slate-500';
|
||||
media.textContent = file.name.split('.').pop()?.toUpperCase() || 'FILE';
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'space-y-3 p-3';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'text-sm font-medium text-slate-900';
|
||||
title.textContent = file.name;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'text-xs text-slate-500';
|
||||
meta.textContent = `${file.type || 'file generico'} · ${formatBytes(file.size)}`;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'block text-xs font-medium text-slate-700';
|
||||
label.textContent = 'Descrizione allegato';
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.name = 'attachment_descriptions[]';
|
||||
textarea.rows = 3;
|
||||
textarea.className = 'mt-1 block w-full rounded-md border-slate-300 text-sm';
|
||||
textarea.placeholder = 'Es. perdita vicino al contatore, crepa sul soffitto, dettaglio del guasto';
|
||||
|
||||
label.appendChild(textarea);
|
||||
body.append(title, meta, label);
|
||||
card.append(media, body);
|
||||
previewList.appendChild(card);
|
||||
});
|
||||
};
|
||||
|
||||
cameraInput.addEventListener('change', renderPreviews);
|
||||
fileInput.addEventListener('change', renderPreviews);
|
||||
renderPreviews();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<div class="mx-auto max-w-3xl space-y-4 p-4 md:p-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Invio lettura acqua</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">Compila la lettura attuale del contatore per l'unità indicata. Puoi allegare massimo due foto.</p>
|
||||
<p class="mt-1 text-sm text-slate-600">Compila la lettura attuale del contatore per l'unità indicata. La lettura resta il campo principale; le foto servono a fissare subito il dato del display.</p>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
|
|
@ -42,6 +42,14 @@
|
|||
<form method="POST" action="{{ $submitUrl }}" enctype="multipart/form-data" class="space-y-4 rounded-lg bg-white p-4 shadow">
|
||||
@csrf
|
||||
|
||||
<div class="rounded-xl border border-blue-100 bg-blue-50 p-4">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-sm font-semibold text-slate-900">Lettura attuale del contatore</span>
|
||||
<input type="number" step="0.001" min="0" name="lettura_attuale" value="{{ old('lettura_attuale') }}" class="w-full rounded-md border-slate-300 text-lg font-semibold" required>
|
||||
</label>
|
||||
<div class="mt-2 text-xs text-slate-600">Inserisci solo il numero letto sul contatore. Se alleghi foto, il sistema conserva anche i metadati dello scatto.</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium">Chi sta inviando la lettura</span>
|
||||
|
|
@ -51,25 +59,28 @@
|
|||
<span class="mb-1 block font-medium">Contatto</span>
|
||||
<input type="text" name="rilevatore_contatto" value="{{ old('rilevatore_contatto') }}" class="w-full rounded-md border-slate-300" placeholder="Telefono o email">
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium">Lettura attuale</span>
|
||||
<input type="number" step="0.001" min="0" name="lettura_attuale" value="{{ old('lettura_attuale') }}" class="w-full rounded-md border-slate-300" required>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium">Data lettura</span>
|
||||
<input type="date" name="data_lettura" value="{{ old('data_lettura', now()->toDateString()) }}" class="w-full rounded-md border-slate-300">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium">Foto contatore 1</span>
|
||||
<input type="file" name="foto_1" accept="image/*" capture="environment" class="w-full rounded-md border-slate-300">
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium">Foto contatore 2</span>
|
||||
<input type="file" name="foto_2" accept="image/*" capture="environment" class="w-full rounded-md border-slate-300">
|
||||
</label>
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="mb-3 text-sm font-medium">Foto del contatore</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium">Foto contatore 1</span>
|
||||
<input id="water-photo-1" type="file" name="foto_1" accept="image/*" capture="environment" class="w-full rounded-md border-slate-300">
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block font-medium">Foto contatore 2</span>
|
||||
<input id="water-photo-2" type="file" name="foto_2" accept="image/*" capture="environment" class="w-full rounded-md border-slate-300">
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div id="water-photo-preview-1" class="flex min-h-40 items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white px-4 py-5 text-center text-sm text-slate-500">Nessuna foto 1 selezionata</div>
|
||||
<div id="water-photo-preview-2" class="flex min-h-40 items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white px-4 py-5 text-center text-sm text-slate-500">Nessuna foto 2 selezionata</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block text-sm">
|
||||
|
|
@ -80,5 +91,45 @@
|
|||
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">Invia lettura</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const bindPreview = (inputId, previewId, emptyLabel) => {
|
||||
const input = document.getElementById(inputId);
|
||||
const preview = document.getElementById(previewId);
|
||||
|
||||
if (!input || !preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const [file] = Array.from(input.files || []);
|
||||
preview.innerHTML = '';
|
||||
|
||||
if (!file) {
|
||||
preview.textContent = emptyLabel;
|
||||
preview.className = 'flex min-h-40 items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white px-4 py-5 text-center text-sm text-slate-500';
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
const image = document.createElement('img');
|
||||
image.src = URL.createObjectURL(file);
|
||||
image.alt = file.name;
|
||||
image.className = 'h-40 w-full rounded-xl object-cover';
|
||||
preview.className = 'rounded-xl border border-slate-200 bg-white p-2';
|
||||
preview.appendChild(image);
|
||||
return;
|
||||
}
|
||||
|
||||
preview.textContent = file.name;
|
||||
preview.className = 'flex min-h-40 items-center justify-center rounded-xl border border-slate-200 bg-white px-4 py-5 text-center text-sm text-slate-500';
|
||||
});
|
||||
};
|
||||
|
||||
bindPreview('water-photo-1', 'water-photo-preview-1', 'Nessuna foto 1 selezionata');
|
||||
bindPreview('water-photo-2', 'water-photo-preview-2', 'Nessuna foto 2 selezionata');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user