Preserve public ticket and water photo metadata

This commit is contained in:
michele 2026-04-12 18:41:02 +00:00
parent baaadd660b
commit 1301c4b144
6 changed files with 493 additions and 90 deletions

View File

@ -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);
}
}

View File

@ -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.');
}
}
}

View File

@ -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
{

View File

@ -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');
});
}
};

View File

@ -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>

View File

@ -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>