Fix supplier view and add public water reading flow

This commit is contained in:
michele 2026-04-11 16:13:01 +00:00
parent 016177bce0
commit 887540c5fe
8 changed files with 646 additions and 58 deletions

View File

@ -299,7 +299,20 @@ public function mount(): void
{
$user = Auth::user();
if ($user instanceof User) {
$requestedStabileId = request()->integer('stabile_id');
if ($requestedStabileId > 0) {
StabileContext::setActiveStabileId($user, $requestedStabileId);
}
$this->stabileId = StabileContext::resolveActiveStabileId($user);
if (! $this->stabileId) {
$fallbackStabileId = StabileContext::accessibleStabili($user)->first()?->id;
if ($fallbackStabileId) {
StabileContext::setActiveStabileId($user, (int) $fallbackStabileId);
$this->stabileId = (int) $fallbackStabileId;
}
}
}
$this->mountInteractsWithTable();
@ -355,7 +368,7 @@ protected function getTableQuery(): Builder
return LegacyCondominNominativo::query()->whereRaw('1 = 0');
}
if (! $this->hasLegacyNominativiForStabile($codStabile) || $this->hasLegacyDuplicateUnits($codStabile)) {
if (! $this->hasLegacyNominativiForStabile($codStabile)) {
return $this->buildDomainFallbackQuery((int) $activeStabileId);
}
@ -398,20 +411,6 @@ protected function hasLegacyNominativiForStabile(string $codStabile): bool
->exists();
}
protected function hasLegacyDuplicateUnits(string $codStabile): bool
{
return DB::connection('gescon_import')
->table('condomin')
->where('cod_stabile', $codStabile)
->whereNotNull('interno')
->where('interno', '<>', '')
->select('scala', 'interno')
->groupBy('scala', 'interno')
->havingRaw('COUNT(*) > 1')
->limit(1)
->exists();
}
protected function buildDomainFallbackQuery(int $stabileId): Builder
{
$today = now()->toDateString();

View File

@ -6,6 +6,7 @@
use App\Models\StabileServizio;
use App\Models\StabileServizioLettura;
use App\Models\StabileServizioTariffa;
use App\Models\UnitaImmobiliare;
use App\Models\User;
use App\Models\VoceSpesa;
use App\Support\AnnoGestioneContext;
@ -27,6 +28,7 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use UnitEnum;
class ServiziStabileArchivio extends Page implements HasTable
@ -69,6 +71,121 @@ public function setAcquaTab(string $tab): void
$this->acquaTab = in_array($tab, $allowed, true) ? $tab : 'dashboard';
}
public function prepareAcquaReadingCampaign(): void
{
$stabileId = $this->resolveActiveStabileId();
$servizio = $this->resolveActiveAcquaServizio();
if (! $stabileId || ! $servizio) {
Notification::make()->title('Servizio acqua attivo non disponibile per lo stabile selezionato.')->warning()->send();
return;
}
$units = UnitaImmobiliare::query()
->where('stabile_id', $stabileId)
->whereNull('deleted_at')
->orderBy('scala')
->orderBy('interno')
->orderBy('id')
->get(['id', 'codice_unita', 'scala', 'interno']);
if ($units->isEmpty()) {
Notification::make()->title('Nessuna unità immobiliare disponibile per avviare la campagna letture.')->warning()->send();
return;
}
$year = $this->resolveActiveAnnoGestione();
$created = 0;
$updated = 0;
foreach ($units as $unit) {
$completed = StabileServizioLettura::query()
->where('stabile_id', $stabileId)
->where('stabile_servizio_id', (int) $servizio->id)
->where('unita_immobiliare_id', (int) $unit->id)
->whereYear('created_at', $year)
->whereNotNull('lettura_fine')
->exists();
if ($completed) {
continue;
}
$previous = StabileServizioLettura::query()
->where('stabile_id', $stabileId)
->where('stabile_servizio_id', (int) $servizio->id)
->where('unita_immobiliare_id', (int) $unit->id)
->whereNotNull('lettura_fine')
->orderByDesc('periodo_al')
->orderByDesc('id')
->first();
$requestRow = StabileServizioLettura::query()
->where('stabile_id', $stabileId)
->where('stabile_servizio_id', (int) $servizio->id)
->where('unita_immobiliare_id', (int) $unit->id)
->whereYear('created_at', $year)
->whereNull('lettura_fine')
->orderByDesc('id')
->first();
$requestReference = 'PUBREQ:' . (int) $servizio->id . ':' . (int) $unit->id . ':' . $year;
$requestPayload = [
'stabile_id' => $stabileId,
'stabile_servizio_id' => (int) $servizio->id,
'unita_immobiliare_id' => (int) $unit->id,
'fornitore_id' => $servizio->fornitore_id,
'periodo_dal' => $previous?->periodo_al,
'periodo_al' => null,
'tipologia_lettura' => 'richiesta_autolettura',
'canale_acquisizione' => 'portale_pubblico',
'riferimento_acquisizione' => $requestReference,
'workflow_stato' => 'richiesta_inviata',
'richiesta_lettura_inviata_at' => now(),
'deadline_lettura_at' => now()->addDays(7),
'lettura_precedente_valore' => $previous?->lettura_fine,
'lettura_inizio' => $previous?->lettura_fine,
'raw' => [
'source' => 'campagna_letture_acqua',
'year' => $year,
'unit_label' => trim((string) ($unit->codice_unita ?? '') . ' ' . (string) ($unit->interno ?? '')),
],
];
if ($requestRow) {
$requestRow->fill($requestPayload);
$requestRow->save();
$updated++;
continue;
}
StabileServizioLettura::query()->create($requestPayload);
$created++;
}
Notification::make()
->title('Campagna letture acqua preparata')
->body("Richieste create: {$created} · richieste aggiornate: {$updated}")
->success()
->send();
}
public function markAcquaReadingReminder(int $readingId): void
{
$reading = StabileServizioLettura::query()->find($readingId);
if (! $reading) {
return;
}
$reading->fill([
'sollecito_inviato_at' => now(),
'workflow_stato' => 'sollecito_inviato',
]);
$reading->save();
Notification::make()->title('Sollecito registrato')->success()->send();
}
public function ensureAcquaAceaContract(): void
{
$stabileId = $this->resolveActiveStabileId();
@ -478,6 +595,34 @@ private function resolveActiveAnnoGestione(): int
return AnnoGestioneContext::resolveActiveAnno($user instanceof User ? $user : null);
}
private function resolveActiveAcquaServizio(): ?StabileServizio
{
$stabileId = $this->resolveActiveStabileId();
if (! $stabileId) {
return null;
}
return StabileServizio::query()
->where('stabile_id', $stabileId)
->where('tipo', 'acqua')
->orderByDesc('attivo')
->orderBy('id')
->first();
}
private function buildPublicAcquaReadingUrl(int $servizioId, int $unitaId, ?int $requestId = null): string
{
return URL::temporarySignedRoute(
'public.water-reading.show',
now()->addDays(30),
array_filter([
'servizio' => $servizioId,
'unita' => $unitaId,
'request' => $requestId,
], fn ($value) => $value !== null)
);
}
/** @return array<int, string> */
private function resolveLegacyCodiciStabile(): array
{
@ -1201,6 +1346,107 @@ public function getAcquaLettureCondominiProperty(): array
->all();
}
/** @return array{totale:int,completate:int,pendenti:int,sollecitate:int} */
public function getAcquaCampagnaStatsProperty(): array
{
$rows = $this->acquaCampagnaRows;
return [
'totale' => count($rows),
'completate' => count(array_filter($rows, fn (array $row): bool => $row['status'] === 'fatta')),
'pendenti' => count(array_filter($rows, fn (array $row): bool => in_array($row['status'], ['richiesta', 'non_richiesta'], true))),
'sollecitate' => count(array_filter($rows, fn (array $row): bool => $row['status'] === 'sollecito')),
];
}
/** @return array<int,array<string,mixed>> */
public function getAcquaCampagnaRowsProperty(): array
{
$stabileId = $this->resolveActiveStabileId();
$servizio = $this->resolveActiveAcquaServizio();
if (! $stabileId || ! $servizio) {
return [];
}
$year = $this->resolveActiveAnnoGestione();
$units = UnitaImmobiliare::query()
->where('stabile_id', $stabileId)
->whereNull('deleted_at')
->orderBy('scala')
->orderBy('interno')
->orderBy('id')
->get(['id', 'codice_unita', 'denominazione', 'scala', 'interno']);
$nominativi = DB::table('unita_immobiliare_nominativi')
->whereIn('unita_immobiliare_id', $units->pluck('id')->all())
->orderByDesc('updated_at')
->orderByDesc('id')
->get(['unita_immobiliare_id', 'nominativo']);
$nominativiByUnit = [];
foreach ($nominativi as $row) {
$unitId = (int) ($row->unita_immobiliare_id ?? 0);
if ($unitId > 0 && ! isset($nominativiByUnit[$unitId])) {
$nominativiByUnit[$unitId] = trim((string) ($row->nominativo ?? ''));
}
}
$readingRows = StabileServizioLettura::query()
->where('stabile_id', $stabileId)
->where('stabile_servizio_id', (int) $servizio->id)
->whereIn('unita_immobiliare_id', $units->pluck('id')->all())
->orderByDesc('created_at')
->orderByDesc('id')
->get();
$currentYearByUnit = [];
$latestRealByUnit = [];
foreach ($readingRows as $row) {
$unitId = (int) ($row->unita_immobiliare_id ?? 0);
if ($unitId <= 0) {
continue;
}
$rowYear = optional($row->created_at)->year ?: optional($row->periodo_al)->year;
if ((int) $rowYear === $year && ! isset($currentYearByUnit[$unitId])) {
$currentYearByUnit[$unitId] = $row;
}
if ($row->lettura_fine !== null && ! isset($latestRealByUnit[$unitId])) {
$latestRealByUnit[$unitId] = $row;
}
}
return $units->map(function (UnitaImmobiliare $unit) use ($currentYearByUnit, $latestRealByUnit, $nominativiByUnit, $servizio): array {
$current = $currentYearByUnit[(int) $unit->id] ?? null;
$latestReal = $latestRealByUnit[(int) $unit->id] ?? null;
$status = 'non_richiesta';
if ($current && $current->lettura_fine !== null) {
$status = 'fatta';
} elseif ($current && $current->sollecito_inviato_at) {
$status = 'sollecito';
} elseif ($current && $current->richiesta_lettura_inviata_at) {
$status = 'richiesta';
}
return [
'unit_id' => (int) $unit->id,
'unit_label' => trim((string) ($unit->codice_unita ?? '') . ' ' . (string) ($unit->interno ?? '')) ?: ('UI #' . (int) $unit->id),
'nominativo' => $nominativiByUnit[(int) $unit->id] ?? (trim((string) ($unit->denominazione ?? '')) ?: 'Nominativo da completare'),
'previous_reading' => $latestReal?->lettura_fine,
'request_id' => $current?->id,
'status' => $status,
'requested_at' => optional($current?->richiesta_lettura_inviata_at)->format('d/m/Y H:i'),
'deadline_at' => optional($current?->deadline_lettura_at)->format('d/m/Y'),
'completed_at' => optional($current?->created_at)->format('d/m/Y H:i'),
'latest_value' => $current?->lettura_fine,
'public_url' => $this->buildPublicAcquaReadingUrl((int) $servizio->id, (int) $unit->id, $current?->id ? (int) $current->id : null),
];
})->all();
}
/** @return array<int, array<string,mixed>> */
public function getAcquaTariffeRowsProperty(): array
{

View File

@ -0,0 +1,168 @@
<?php
namespace App\Http\Controllers\PublicAccess;
use App\Http\Controllers\Controller;
use App\Models\StabileServizio;
use App\Models\StabileServizioLettura;
use App\Models\UnitaImmobiliare;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
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');
$servizio = StabileServizio::query()
->where('id', $servizioId)
->where('tipo', 'acqua')
->firstOrFail();
$unita = UnitaImmobiliare::query()
->with('stabile:id,denominazione,codice_stabile')
->where('id', $unitaId)
->where('stabile_id', (int) $servizio->stabile_id)
->firstOrFail();
$pendingRequest = $requestId > 0
? StabileServizioLettura::query()
->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))
->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,
]));
return view('public.water-reading.show', [
'servizio' => $servizio,
'unita' => $unita,
'pendingRequest' => $pendingRequest,
'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');
$servizio = StabileServizio::query()
->where('id', $servizioId)
->where('tipo', 'acqua')
->firstOrFail();
$unita = UnitaImmobiliare::query()
->where('id', $unitaId)
->where('stabile_id', (int) $servizio->stabile_id)
->firstOrFail();
$validated = $request->validate([
'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'],
]);
$pendingRequest = $requestId > 0
? StabileServizioLettura::query()
->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))
->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;
if ($consumo !== null && $consumo < 0) {
return back()->withErrors([
'lettura_attuale' => 'La lettura attuale non puo essere inferiore a quella precedente.',
])->withInput();
}
$photoPaths = [];
foreach (['foto_1', 'foto_2'] as $photoField) {
if ($request->hasFile($photoField)) {
$photoPaths[] = $request->file($photoField)?->store('letture-acqua-public', 'public');
}
}
$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']),
'lettura_precedente_valore' => $letturaInizio,
'lettura_inizio' => $letturaInizio,
'lettura_fine' => $letturaFine,
'lettura_foto_path' => $photoPaths[0] ?? null,
'lettura_foto_metadata' => [
'paths' => $photoPaths,
'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_paths' => $photoPaths,
'request_id' => $pendingRequest?->id,
],
];
if ($pendingRequest) {
$pendingRequest->fill($payload);
$pendingRequest->save();
} else {
StabileServizioLettura::query()->create($payload);
}
return redirect()->back()->with('success', 'Lettura acqua inviata correttamente.');
}
}

View File

@ -11,6 +11,8 @@
$legacyOpsRows = $this->acquaLegacyOperazioniRows;
$acquaContratto = $this->acquaContrattoStabile;
$acquaRipartoNominativi = $this->acquaRipartoNominativiLegacy;
$campagnaStats = $this->acquaCampagnaStats;
$campagnaRows = $this->acquaCampagnaRows;
@endphp
<div class="space-y-6">
@ -236,6 +238,90 @@ class="pb-3 px-1 border-b-2 font-medium text-sm {{ $acquaTab === 'servizi' ? 'bo
@if($acquaTab === 'letture')
<div class="space-y-4">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-700/50 dark:bg-emerald-900/10">
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-emerald-900 dark:text-emerald-200">Campagna letture acqua per unità</div>
<div class="mt-1 text-xs text-emerald-800 dark:text-emerald-300">Primo slice operativo: genera richieste per le unità dello stabile, dividi fatte e non fatte, e usa un link pubblico firmato per inviare lettura attuale più massimo due foto.</div>
</div>
<x-filament::button size="sm" color="success" wire:click="prepareAcquaReadingCampaign">
Prepara / aggiorna campagna letture
</x-filament::button>
</div>
<div class="grid gap-3 md:grid-cols-4">
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Unità</div>
<div class="mt-2 text-xl font-semibold">{{ $campagnaStats['totale'] }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Fatte</div>
<div class="mt-2 text-xl font-semibold text-emerald-700">{{ $campagnaStats['completate'] }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Pendenti</div>
<div class="mt-2 text-xl font-semibold text-amber-700">{{ $campagnaStats['pendenti'] }}</div>
</div>
<div class="rounded-lg border bg-white p-3 text-sm dark:bg-gray-900">
<div class="text-xs uppercase tracking-wide text-gray-500">Sollecitate</div>
<div class="mt-2 text-xl font-semibold text-sky-700">{{ $campagnaStats['sollecitate'] }}</div>
</div>
</div>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-xs">
<thead>
<tr class="border-b border-emerald-200 dark:border-emerald-700/50">
<th class="px-2 py-2 text-left">Unità</th>
<th class="px-2 py-2 text-left">Nominativo</th>
<th class="px-2 py-2 text-right">Lettura precedente</th>
<th class="px-2 py-2 text-left">Stato</th>
<th class="px-2 py-2 text-left">Richiesta</th>
<th class="px-2 py-2 text-left">Azioni</th>
</tr>
</thead>
<tbody>
@forelse($campagnaRows as $row)
<tr class="border-b border-emerald-100 dark:border-emerald-800/30">
<td class="px-2 py-2">{{ $row['unit_label'] }}</td>
<td class="px-2 py-2">{{ $row['nominativo'] }}</td>
<td class="px-2 py-2 text-right">{{ $row['previous_reading'] !== null ? number_format((float) $row['previous_reading'], 3, ',', '.') : '—' }}</td>
<td class="px-2 py-2">
@if($row['status'] === 'fatta')
<span class="rounded bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-700">Fatta</span>
@elseif($row['status'] === 'sollecito')
<span class="rounded bg-sky-100 px-1.5 py-0.5 font-semibold text-sky-700">Sollecito</span>
@elseif($row['status'] === 'richiesta')
<span class="rounded bg-amber-100 px-1.5 py-0.5 font-semibold text-amber-700">Richiesta inviata</span>
@else
<span class="rounded bg-slate-100 px-1.5 py-0.5 font-semibold text-slate-700">Da inviare</span>
@endif
</td>
<td class="px-2 py-2">
{{ $row['requested_at'] ?: '—' }}
@if($row['deadline_at'])
<div class="text-[10px] text-gray-500">Scadenza {{ $row['deadline_at'] }}</div>
@endif
</td>
<td class="px-2 py-2">
<div class="flex flex-wrap gap-2">
<a href="{{ $row['public_url'] }}" target="_blank" class="inline-flex items-center rounded-md bg-white px-2 py-1 text-[11px] font-medium text-emerald-800 ring-1 ring-inset ring-emerald-300 hover:bg-emerald-100">Apri link</a>
@if($row['request_id'] && $row['status'] !== 'fatta')
<button type="button" wire:click="markAcquaReadingReminder({{ (int) $row['request_id'] }})" class="inline-flex items-center rounded-md bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-200">Sollecito</button>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-2 py-4 text-center text-emerald-700 dark:text-emerald-300">Nessuna unità disponibile per la campagna letture.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700/50 dark:bg-slate-900/20">
<div class="mb-2 text-sm font-semibold text-slate-900 dark:text-slate-100">Base nominativi riparto ACQUA (legacy) per inserimento letture manuali</div>
<div class="mb-2 text-xs text-slate-600 dark:text-slate-300">

View File

@ -72,6 +72,7 @@
<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>
<a href="{{ $this->getRubricaUrl() }}" 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">Rubrica clienti</a>
<a href="{{ \App\Filament\Pages\Fornitore\ImpostazioniArchivio::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">Impostazioni</a>
<a href="{{ $this->getProdottiUrl() }}" 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">Prodotti</a>
</div>
</div>
@ -596,51 +597,50 @@
</div>
</div>
</div>
</div>
@if($attachmentPreview)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" x-data="{ zoom: 1, pdfPage: 1, iframeKey: 0, refreshPdf() { this.iframeKey++; }, zoomIn() { this.zoom = Math.min(this.zoom + 0.2, 4); }, zoomOut() { this.zoom = Math.max(this.zoom - 0.2, 0.5); }, resetZoom() { this.zoom = 1; }, nextPage() { this.pdfPage = this.pdfPage + 1; this.refreshPdf(); }, prevPage() { this.pdfPage = Math.max(this.pdfPage - 1, 1); this.refreshPdf(); }, printPdf() { const frame = this.$refs.pdfFrame; if (frame?.contentWindow) { frame.contentWindow.focus(); frame.contentWindow.print(); } else { window.open(@js($attachmentPreview['url']), '_blank'); } } }">
<div class="h-[92vh] w-full max-w-[96vw] rounded-xl bg-white shadow-2xl">
<div class="flex items-center justify-between border-b px-4 py-3">
<div>
<div class="text-sm font-semibold">{{ $attachmentPreview['name'] ?? 'Allegato' }}</div>
@if(!empty($attachmentPreview['description']))
<div class="text-xs text-gray-500">{{ $attachmentPreview['description'] }}</div>
@endif
@if($attachmentPreview)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" x-data="{ zoom: 1, pdfPage: 1, iframeKey: 0, refreshPdf() { this.iframeKey++; }, zoomIn() { this.zoom = Math.min(this.zoom + 0.2, 4); }, zoomOut() { this.zoom = Math.max(this.zoom - 0.2, 0.5); }, resetZoom() { this.zoom = 1; }, nextPage() { this.pdfPage = this.pdfPage + 1; this.refreshPdf(); }, prevPage() { this.pdfPage = Math.max(this.pdfPage - 1, 1); this.refreshPdf(); }, printPdf() { const frame = this.$refs.pdfFrame; if (frame?.contentWindow) { frame.contentWindow.focus(); frame.contentWindow.print(); } else { window.open(@js($attachmentPreview['url']), '_blank'); } } }">
<div class="h-[92vh] w-full max-w-[96vw] rounded-xl bg-white shadow-2xl">
<div class="flex items-center justify-between border-b px-4 py-3">
<div>
<div class="text-sm font-semibold">{{ $attachmentPreview['name'] ?? 'Allegato' }}</div>
@if(!empty($attachmentPreview['description']))
<div class="text-xs text-gray-500">{{ $attachmentPreview['description'] }}</div>
@endif
</div>
<div class="flex items-center gap-2">
@if($attachmentPreview['is_image'] ?? false)
<button type="button" x-on:click="zoomOut()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">-</button>
<button type="button" x-on:click="resetZoom()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">100%</button>
<button type="button" x-on:click="zoomIn()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">+</button>
@elseif($attachmentPreview['is_pdf'] ?? false)
<button type="button" x-on:click="prevPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina precedente</button>
<span class="text-xs text-slate-500">Pagina <span x-text="pdfPage"></span></span>
<button type="button" x-on:click="nextPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina successiva</button>
<button type="button" x-on:click="printPdf()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Stampa PDF</button>
@endif
<a href="{{ $attachmentPreview['url'] }}" target="_blank" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Apri in nuova scheda</a>
<button type="button" wire:click="closeAttachmentPreview" class="rounded-md bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">Chiudi</button>
</div>
</div>
<div class="flex items-center gap-2">
<div class="h-[calc(92vh-64px)] overflow-auto p-4">
@if($attachmentPreview['is_image'] ?? false)
<button type="button" x-on:click="zoomOut()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">-</button>
<button type="button" x-on:click="resetZoom()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">100%</button>
<button type="button" x-on:click="zoomIn()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">+</button>
@elseif($attachmentPreview['is_pdf'] ?? false)
<button type="button" x-on:click="prevPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina precedente</button>
<span class="text-xs text-slate-500">Pagina <span x-text="pdfPage"></span></span>
<button type="button" x-on:click="nextPage()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Pagina successiva</button>
<button type="button" x-on:click="printPdf()" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Stampa PDF</button>
@endif
<a href="{{ $attachmentPreview['url'] }}" target="_blank" class="rounded-md bg-slate-100 px-2 py-1 text-xs hover:bg-slate-200">Apri in nuova scheda</a>
<button type="button" wire:click="closeAttachmentPreview" class="rounded-md bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">Chiudi</button>
</div>
</div>
<div class="h-[calc(92vh-64px)] overflow-auto p-4">
@if($attachmentPreview['is_image'] ?? false)
<div class="h-full overflow-auto rounded-lg bg-slate-50 p-4">
<div class="flex min-h-full min-w-full items-start justify-center">
<img src="{{ $attachmentPreview['url'] }}" alt="Anteprima allegato" class="max-w-none rounded border bg-white shadow" x-bind:style="`width: ${zoom * 100}%; height: auto;`" />
<div class="h-full overflow-auto rounded-lg bg-slate-50 p-4">
<div class="flex min-h-full min-w-full items-start justify-center">
<img src="{{ $attachmentPreview['url'] }}" alt="Anteprima allegato" class="max-w-none rounded border bg-white shadow" x-bind:style="`width: ${zoom * 100}%; height: auto;`" />
</div>
</div>
</div>
@elseif($attachmentPreview['is_pdf'] ?? false)
<iframe x-ref="pdfFrame" x-bind:key="iframeKey" x-bind:src="@js($attachmentPreview['url']) + '#toolbar=1&navpanes=0&scrollbar=1&view=FitH&zoom=page-width&page=' + pdfPage" class="h-[84vh] w-full rounded border"></iframe>
@else
<div class="rounded-md border bg-gray-50 p-4 text-sm text-gray-700">
Anteprima non disponibile per questo formato. Scarica o apri il file:
<a href="{{ $attachmentPreview['url'] }}" class="ml-1 text-primary-600 hover:underline">Apri file</a>
</div>
@endif
@elseif($attachmentPreview['is_pdf'] ?? false)
<iframe x-ref="pdfFrame" x-bind:key="iframeKey" x-bind:src="@js($attachmentPreview['url']) + '#toolbar=1&navpanes=0&scrollbar=1&view=FitH&zoom=page-width&page=' + pdfPage" class="h-[84vh] w-full rounded border"></iframe>
@else
<div class="rounded-md border bg-gray-50 p-4 text-sm text-gray-700">
Anteprima non disponibile per questo formato. Scarica o apri il file:
<a href="{{ $attachmentPreview['url'] }}" class="ml-1 text-primary-600 hover:underline">Apri file</a>
</div>
@endif
</div>
</div>
</div>
</div>
@endif
@endif
</div>
</x-filament-panels::page>

View File

@ -123,7 +123,7 @@ class="inline-flex h-4 w-4 items-center justify-center rounded-full text-slate-5
<div class="mt-1 text-xs text-indigo-700">Tag: {{ $match->tags }}</div>
@endif
@if($match->note)
<div class="mt-1 text-xs text-gray-500">{{ \\Illuminate\\Support\\Str::limit((string) $match->note, 120) }}</div>
<div class="mt-1 text-xs text-gray-500">{{ \Illuminate\Support\Str::limit((string) $match->note, 120) }}</div>
@endif
</button>
@endforeach

View File

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Lettura acqua - {{ $unita->stabile->denominazione ?? 'NetGescon' }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="min-h-screen bg-slate-100 text-slate-900">
<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>
</div>
@if (session('success'))
<div class="rounded-lg border border-emerald-300 bg-emerald-50 p-3 text-sm text-emerald-700">{{ session('success') }}</div>
@endif
@if ($errors->any())
<div class="rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-700">
<ul class="list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="rounded-lg bg-white p-4 shadow">
<div class="grid gap-3 md:grid-cols-2 text-sm">
<div><span class="font-medium">Stabile:</span> {{ $unita->stabile->codice_stabile ?? '' }} - {{ $unita->stabile->denominazione ?? '-' }}</div>
<div><span class="font-medium">Unità:</span> {{ $unita->codice_unita ?: ('UI #' . (int) $unita->id) }}</div>
<div><span class="font-medium">Servizio:</span> {{ $servizio->nome ?: 'Acqua' }}</div>
<div><span class="font-medium">Matricola:</span> {{ $servizio->contatore_matricola ?: '—' }}</div>
<div><span class="font-medium">Lettura precedente:</span> {{ $previous?->lettura_fine !== null ? number_format((float) $previous->lettura_fine, 3, ',', '.') : '—' }}</div>
<div><span class="font-medium">Ultima data nota:</span> {{ optional($previous?->periodo_al)->format('d/m/Y') ?: '—' }}</div>
</div>
</div>
<form method="POST" action="{{ $submitUrl }}" enctype="multipart/form-data" class="space-y-4 rounded-lg bg-white p-4 shadow">
@csrf
<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>
<input type="text" name="rilevatore_nome" value="{{ old('rilevatore_nome') }}" class="w-full rounded-md border-slate-300" required>
</label>
<label class="block text-sm">
<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>
<label class="block text-sm">
<span class="mb-1 block font-medium">Note</span>
<textarea name="note" rows="3" class="w-full rounded-md border-slate-300">{{ old('note') }}</textarea>
</label>
<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>
</body>
</html>

View File

@ -45,6 +45,11 @@
return redirect('/admin-filament');
});
Route::middleware('signed')->prefix('public/letture-acqua')->name('public.water-reading.')->group(function () {
Route::get('/', [\App\Http\Controllers\PublicAccess\WaterReadingRequestController::class, 'show'])->name('show');
Route::post('/', [\App\Http\Controllers\PublicAccess\WaterReadingRequestController::class, 'store'])->name('store');
});
// --- Authenticated Routes ---
Route::middleware(['auth', 'verified'])->group(function () {