Compare commits

...

2 Commits

28 changed files with 2320 additions and 263 deletions

View File

@ -1,5 +1,4 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use Illuminate\Console\Command; use Illuminate\Console\Command;

View File

@ -27,7 +27,9 @@
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use UnitEnum; use UnitEnum;
class LettureServiziArchivio extends Page implements HasTable class LettureServiziArchivio extends Page implements HasTable
@ -37,9 +39,9 @@ class LettureServiziArchivio extends Page implements HasTable
public ?int $servizioFilter = null; public ?int $servizioFilter = null;
public string $archivioScope = 'active'; public string $archivioScope = 'active';
protected static ?string $navigationLabel = 'Letture servizi'; protected static ?string $navigationLabel = 'Letture servizi / contatori';
protected static ?string $title = 'Letture servizi'; protected static ?string $title = 'Letture servizi / contatori';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list'; protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-list';
@ -127,7 +129,7 @@ protected function getTableQuery(): Builder
->when($this->servizioFilter, fn(Builder $q) => $q->where('stabile_servizio_id', (int) $this->servizioFilter)) ->when($this->servizioFilter, fn(Builder $q) => $q->where('stabile_servizio_id', (int) $this->servizioFilter))
->with([ ->with([
'stabile:id,codice_stabile,denominazione', 'stabile:id,codice_stabile,denominazione',
'servizio:id,stabile_id,tipo,nome,contatore_matricola', 'servizio:id,stabile_id,tipo,nome,contatore_matricola,meta',
'fornitore:id,ragione_sociale', 'fornitore:id,ragione_sociale',
'voceSpesa:id,descrizione,tipo_gestione', 'voceSpesa:id,descrizione,tipo_gestione',
'unitaImmobiliare:id,codice_unita,denominazione,interno,scala,piano', 'unitaImmobiliare:id,codice_unita,denominazione,interno,scala,piano',
@ -168,6 +170,11 @@ public function table(Table $table): Table
'assicurazione' => 'Assicurazione', 'assicurazione' => 'Assicurazione',
'privacy' => 'Privacy', 'privacy' => 'Privacy',
'antenna' => 'Antenna', 'antenna' => 'Antenna',
'antincendio' => 'Antincendio',
'citofonia' => 'Citofonia',
'locale_comune' => 'Locale comune',
'spazio_comune' => 'Spazio / area comune',
'impianto' => 'Impianto comune',
'altro' => 'Altro', 'altro' => 'Altro',
]) ])
->query(function (Builder $query, array $data): Builder { ->query(function (Builder $query, array $data): Builder {
@ -241,24 +248,19 @@ public function table(Table $table): Table
TextColumn::make('servizio.contatore_matricola')->label('Matricola')->toggleable(isToggledHiddenByDefault: true), TextColumn::make('servizio.contatore_matricola')->label('Matricola')->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('unitaImmobiliare.denominazione') TextColumn::make('unitaImmobiliare.denominazione')
->label('Unità immobiliare') ->label('Attribuzione')
->formatStateUsing(function ($state, StabileServizioLettura $record): string { ->formatStateUsing(function ($state, StabileServizioLettura $record): string {
$ui = $record->unitaImmobiliare; return $this->resolveReadingAssignmentLabel($record);
if (! $ui) {
return '—';
}
$cod = trim((string) ($ui->codice_unita ?? ''));
$den = trim((string) ($ui->denominazione ?? ''));
if ($cod !== '' && $den !== '') {
return $cod . ' - ' . $den;
}
return $cod !== '' ? $cod : ($den !== '' ? $den : ('UI #' . $ui->id));
}) })
->wrap() ->wrap()
->toggleable(), ->toggleable(),
TextColumn::make('rilevatore_nome')
->label('Letturista / nominativo')
->formatStateUsing(fn($state): string => trim((string) $state) !== '' ? trim((string) $state) : '—')
->wrap()
->toggleable(),
TextColumn::make('consumo_valore') TextColumn::make('consumo_valore')
->label('Consumo') ->label('Consumo')
->formatStateUsing(function ($state, StabileServizioLettura $record): string { ->formatStateUsing(function ($state, StabileServizioLettura $record): string {
@ -301,6 +303,35 @@ public function table(Table $table): Table
->badge() ->badge()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('raw.consistency_check.summary')
->label('Check lettura')
->formatStateUsing(function ($state, StabileServizioLettura $record): string {
$summary = trim((string) data_get($record->raw, 'consistency_check.summary', ''));
return $summary !== '' ? $summary : '—';
})
->badge()
->color(function ($state, StabileServizioLettura $record): string {
return match ((string) data_get($record->raw, 'consistency_check.status', '')) {
'warning' => 'warning',
'danger' => 'danger',
default => 'gray',
};
})
->wrap()
->toggleable(),
TextColumn::make('lettura_foto_original_name')
->label('Foto')
->formatStateUsing(function ($state, StabileServizioLettura $record): string {
$photos = is_array(data_get($record->lettura_foto_metadata, 'photos')) ? data_get($record->lettura_foto_metadata, 'photos') : [];
if ($photos !== []) {
return count($photos) . ' foto';
}
return filled($record->lettura_foto_path) ? '1 foto' : '—';
})
->toggleable(),
TextColumn::make('protocollo_numero') TextColumn::make('protocollo_numero')
->label('Protocollo') ->label('Protocollo')
->formatStateUsing(function ($state, StabileServizioLettura $record): string { ->formatStateUsing(function ($state, StabileServizioLettura $record): string {
@ -528,6 +559,78 @@ public function table(Table $table): Table
}), }),
]) ])
->actions([ ->actions([
Action::make('apriFoto')
->label('Foto')
->icon('heroicon-o-photo')
->url(fn(StabileServizioLettura $record): ?string => $this->resolveReadingPrimaryPhotoUrl($record))
->openUrlInNewTab()
->visible(fn(StabileServizioLettura $record): bool => $this->resolveReadingPrimaryPhotoUrl($record) !== null),
Action::make('mappaFoto')
->label('Mappa')
->icon('heroicon-o-map')
->url(fn(StabileServizioLettura $record): ?string => $this->resolveReadingMapUrl($record))
->openUrlInNewTab()
->visible(fn(StabileServizioLettura $record): bool => $this->resolveReadingMapUrl($record) !== null),
Action::make('riattribuisci')
->label('Riattribuisci')
->icon('heroicon-o-user-circle')
->form([
Select::make('unita_immobiliare_id')
->label('Unità / nominativo')
->options(fn() => $this->getUnitaOptions())
->searchable()
->placeholder('Contatore generale / bene comune dello stabile'),
Select::make('rilevatore_tipo')
->label('Tipo rilevatore')
->options($this->getReaderTypeOptions()),
TextInput::make('rilevatore_nome')->label('Nominativo rilevatore')->maxLength(191),
Textarea::make('motivo')->label('Nota riassegnazione')->rows(3)->maxLength(1000),
])
->fillForm(fn(StabileServizioLettura $record): array => [
'unita_immobiliare_id' => $record->unita_immobiliare_id,
'rilevatore_tipo' => (string) ($record->rilevatore_tipo ?? ''),
'rilevatore_nome' => (string) ($record->rilevatore_nome ?? ''),
'motivo' => '',
])
->action(function (StabileServizioLettura $record, array $data): void {
$raw = is_array($record->raw ?? null) ? $record->raw : [];
$reassignments = is_array($raw['reassignments'] ?? null) ? $raw['reassignments'] : [];
$reassignments[] = [
'from_unita_immobiliare_id' => (int) ($record->getOriginal('unita_immobiliare_id') ?? 0) ?: null,
'to_unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null,
'from_reader_name' => (string) ($record->getOriginal('rilevatore_nome') ?? ''),
'to_reader_name' => trim((string) ($data['rilevatore_nome'] ?? '')),
'note' => trim((string) ($data['motivo'] ?? '')),
'changed_by' => Auth::id(),
'changed_at' => now()->toIso8601String(),
];
$raw['reassignments'] = $reassignments;
$raw['reader_assignment'] = [
'user_id' => null,
'name' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null,
'type' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null,
];
$record->fill([
'unita_immobiliare_id' => (int) ($data['unita_immobiliare_id'] ?? 0) ?: null,
'rilevatore_tipo' => trim((string) ($data['rilevatore_tipo'] ?? '')) ?: null,
'rilevatore_nome' => trim((string) ($data['rilevatore_nome'] ?? '')) ?: null,
'lettura_precedente_valore' => null,
'lettura_precedente_foto_path' => null,
'raw' => $raw,
]);
$record->save();
$this->hydrateReadingWithPreviousData($record);
Notification::make()
->title('Attribuzione aggiornata')
->body('La lettura è stata riallineata al nuovo nominativo/unità.')
->success()
->send();
}),
Action::make('edit') Action::make('edit')
->label('Modifica') ->label('Modifica')
->icon('heroicon-o-pencil-square') ->icon('heroicon-o-pencil-square')
@ -675,13 +778,17 @@ private function getServiziOptions(): array
->where('stabile_id', (int) $stabileId) ->where('stabile_id', (int) $stabileId)
->orderBy('tipo') ->orderBy('tipo')
->orderBy('nome') ->orderBy('nome')
->get(['id', 'tipo', 'nome', 'contatore_matricola']) ->get(['id', 'tipo', 'nome', 'contatore_matricola', 'meta'])
->mapWithKeys(function (StabileServizio $s): array { ->mapWithKeys(function (StabileServizio $s): array {
$tipo = strtoupper(trim((string) ($s->tipo ?? ''))); $tipo = strtoupper(trim((string) ($s->tipo ?? '')));
$nome = trim((string) ($s->nome ?? '')); $nome = trim((string) ($s->nome ?? ''));
$matr = trim((string) ($s->contatore_matricola ?? '')); $matr = trim((string) ($s->contatore_matricola ?? ''));
$asset = trim((string) data_get($s->meta, 'common_asset_label', ''));
$label = $nome !== '' ? $nome : ($tipo !== '' ? $tipo : ('Servizio #' . $s->id)); $label = $nome !== '' ? $nome : ($tipo !== '' ? $tipo : ('Servizio #' . $s->id));
if ($asset !== '' && ! str_contains(mb_strtolower($label), mb_strtolower($asset))) {
$label .= ' - ' . $asset;
}
if ($matr !== '') { if ($matr !== '') {
$label .= ' (' . $matr . ')'; $label .= ' (' . $matr . ')';
} }
@ -782,16 +889,36 @@ private function getUnitaOptions(): array
return []; return [];
} }
return UnitaImmobiliare::query() $units = UnitaImmobiliare::query()
->where('stabile_id', (int) $stabileId) ->where('stabile_id', (int) $stabileId)
->orderBy('codice_unita') ->orderBy('codice_unita')
->orderBy('interno') ->orderBy('interno')
->orderBy('id') ->orderBy('id')
->get(['id', 'codice_unita', 'denominazione', 'interno']) ->get(['id', 'codice_unita', 'denominazione', 'interno'])
->mapWithKeys(function (UnitaImmobiliare $u): array { ->all();
$latestNominativi = [];
if ($units !== [] && Schema::hasTable('unita_immobiliare_nominativi')) {
$rows = DB::table('unita_immobiliare_nominativi')
->whereIn('unita_immobiliare_id', array_map(fn(UnitaImmobiliare $unit): int => (int) $unit->id, $units))
->orderByDesc('updated_at')
->orderByDesc('id')
->get(['unita_immobiliare_id', 'nominativo']);
foreach ($rows as $row) {
$unitId = (int) ($row->unita_immobiliare_id ?? 0);
if ($unitId > 0 && ! isset($latestNominativi[$unitId])) {
$latestNominativi[$unitId] = trim((string) ($row->nominativo ?? ''));
}
}
}
$options = collect($units)
->mapWithKeys(function (UnitaImmobiliare $u) use ($latestNominativi): array {
$cod = trim((string) ($u->codice_unita ?? '')); $cod = trim((string) ($u->codice_unita ?? ''));
$den = trim((string) ($u->denominazione ?? '')); $den = trim((string) ($u->denominazione ?? ''));
$int = trim((string) ($u->interno ?? '')); $int = trim((string) ($u->interno ?? ''));
$nom = trim((string) ($latestNominativi[(int) $u->id] ?? ''));
$label = $cod !== '' ? $cod : ('UI #' . $u->id); $label = $cod !== '' ? $cod : ('UI #' . $u->id);
if ($den !== '') { if ($den !== '') {
@ -800,10 +927,33 @@ private function getUnitaOptions(): array
if ($int !== '') { if ($int !== '') {
$label .= ' (Int. ' . $int . ')'; $label .= ' (Int. ' . $int . ')';
} }
if ($nom !== '') {
$label .= ' · ' . $nom;
}
return [(int) $u->id => $label]; return [(int) $u->id => $label];
}) })
->all(); ->all();
uasort($options, static fn(string $left, string $right): int => strnatcasecmp($left, $right));
return $options;
}
/**
* @return array<string, string>
*/
private function getReaderTypeOptions(): array
{
return [
'condomino' => 'Condomino',
'inquilino' => 'Inquilino',
'letturista' => 'Letturista',
'collaboratore' => 'Collaboratore',
'amministrazione' => 'Amministrazione',
'operatore_filament' => 'Operatore Filament',
'link_pubblico' => 'Link pubblico',
];
} }
/** /**
@ -921,13 +1071,13 @@ private function markOverdueWaterReminders(): int
private function hydrateReadingWithPreviousData(StabileServizioLettura $record): void private function hydrateReadingWithPreviousData(StabileServizioLettura $record): void
{ {
if ($record->unita_immobiliare_id === null) {
return;
}
$previous = StabileServizioLettura::query() $previous = StabileServizioLettura::query()
->where('stabile_servizio_id', (int) $record->stabile_servizio_id) ->where('stabile_servizio_id', (int) $record->stabile_servizio_id)
->where('unita_immobiliare_id', (int) $record->unita_immobiliare_id) ->when(
$record->unita_immobiliare_id === null,
fn(Builder $query) => $query->whereNull('unita_immobiliare_id'),
fn(Builder $query) => $query->where('unita_immobiliare_id', (int) $record->unita_immobiliare_id)
)
->where('id', '!=', (int) $record->id) ->where('id', '!=', (int) $record->id)
->latest('periodo_al') ->latest('periodo_al')
->latest('id') ->latest('id')
@ -1045,4 +1195,76 @@ private function extractImageMetadata(string $path): array
return $metadata; return $metadata;
} }
private function resolveReadingAssignmentLabel(StabileServizioLettura $record): string
{
$ui = $record->unitaImmobiliare;
if ($ui instanceof UnitaImmobiliare) {
$cod = trim((string) ($ui->codice_unita ?? ''));
$den = trim((string) ($ui->denominazione ?? ''));
return trim(($cod !== '' ? $cod : ('UI #' . $ui->id)) . ($den !== '' ? (' - ' . $den) : ''));
}
$commonAsset = trim((string) data_get($record->servizio?->meta, 'common_asset_label', ''));
$servedArea = trim((string) data_get($record->servizio?->meta, 'served_area_label', ''));
$counterType = trim((string) data_get($record->servizio?->meta, 'counter_scope', ''));
$parts = array_filter([
$commonAsset,
$servedArea,
$counterType !== '' ? 'Contatore ' . $counterType : null,
]);
return $parts !== [] ? implode(' - ', $parts) : 'Contatore generale / bene comune';
}
private function resolveReadingPrimaryPhotoPath(StabileServizioLettura $record): ?string
{
if (filled($record->lettura_foto_path)) {
return (string) $record->lettura_foto_path;
}
$photos = is_array(data_get($record->lettura_foto_metadata, 'photos')) ? data_get($record->lettura_foto_metadata, 'photos') : [];
foreach ($photos as $photo) {
$path = trim((string) ($photo['path'] ?? ''));
if ($path !== '') {
return $path;
}
}
return null;
}
private function resolveReadingPrimaryPhotoUrl(StabileServizioLettura $record): ?string
{
$path = $this->resolveReadingPrimaryPhotoPath($record);
if ($path === null || ! Storage::disk('public')->exists($path)) {
return null;
}
return Storage::disk('public')->url($path);
}
private function resolveReadingMapUrl(StabileServizioLettura $record): ?string
{
$lat = data_get($record->lettura_foto_metadata, 'gps_decimal.lat');
$lng = data_get($record->lettura_foto_metadata, 'gps_decimal.lng');
if (is_numeric($lat) && is_numeric($lng)) {
return 'https://maps.google.com/?q=' . $lat . ',' . $lng;
}
$photos = is_array(data_get($record->lettura_foto_metadata, 'photos')) ? data_get($record->lettura_foto_metadata, 'photos') : [];
foreach ($photos as $photo) {
$gpsLat = data_get($photo, 'metadata.gps_decimal.lat');
$gpsLng = data_get($photo, 'metadata.gps_decimal.lng');
if (is_numeric($gpsLat) && is_numeric($gpsLng)) {
return 'https://maps.google.com/?q=' . $gpsLat . ',' . $gpsLng;
}
}
return null;
}
} }

View File

@ -14,6 +14,7 @@
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -35,9 +36,9 @@ class ServiziStabileArchivio extends Page implements HasTable
{ {
use InteractsWithTable; use InteractsWithTable;
protected static ?string $navigationLabel = 'Servizi / Utenze'; protected static ?string $navigationLabel = 'Servizi / Beni comuni';
protected static ?string $title = 'Servizi / Utenze'; protected static ?string $title = 'Servizi / Beni comuni';
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-rectangle-stack'; protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-rectangle-stack';
@ -268,18 +269,11 @@ protected function getTableQuery(): Builder
public function table(Table $table): Table public function table(Table $table): Table
{ {
$tipoOptions = [ $tipoOptions = $this->getServiceTypeOptions();
'acqua' => 'Acqua', $commonScopeOptions = $this->getCommonAssetScopeOptions();
'energia' => 'Energia', $allocationOptions = $this->getAllocationScopeOptions();
'gas' => 'Gas', $fiscalScopeOptions = $this->getFiscalScopeOptions();
'riscaldamento' => 'Riscaldamento', $counterScopeOptions = $this->getCounterScopeOptions();
'ascensore' => 'Ascensore',
'pulizie' => 'Pulizie',
'assicurazione' => 'Assicurazione',
'privacy' => 'Privacy',
'antenna' => 'Antenna',
'altro' => 'Altro',
];
$gestioneOptions = [ $gestioneOptions = [
'ordinaria' => 'Ordinaria', 'ordinaria' => 'Ordinaria',
@ -294,6 +288,30 @@ public function table(Table $table): Table
->label('Tipo servizio') ->label('Tipo servizio')
->options($tipoOptions), ->options($tipoOptions),
SelectFilter::make('common_asset_scope')
->label('Ambito bene comune')
->options($commonScopeOptions)
->query(function (Builder $query, array $data): Builder {
$value = trim((string) ($data['value'] ?? ''));
if ($value === '') {
return $query;
}
return $query->where('meta->common_asset_scope', $value);
}),
SelectFilter::make('counter_scope')
->label('Tipo contatore')
->options($counterScopeOptions)
->query(function (Builder $query, array $data): Builder {
$value = trim((string) ($data['value'] ?? ''));
if ($value === '') {
return $query;
}
return $query->where('meta->counter_scope', $value);
}),
SelectFilter::make('tipo_gestione') SelectFilter::make('tipo_gestione')
->label('Gestione (O/R/S)') ->label('Gestione (O/R/S)')
->options($gestioneOptions) ->options($gestioneOptions)
@ -309,11 +327,35 @@ public function table(Table $table): Table
->columns([ ->columns([
TextColumn::make('tipo') TextColumn::make('tipo')
->label('Tipo') ->label('Tipo')
->formatStateUsing(fn($state) => $tipoOptions[strtolower(trim((string) $state))] ?? (string) ($state ?? '—')) ->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($tipoOptions, $state))
->sortable(), ->sortable(),
TextColumn::make('nome')->label('Nome')->searchable()->wrap()->sortable(), TextColumn::make('nome')->label('Nome')->searchable()->wrap()->sortable(),
TextColumn::make('meta.common_asset_label')
->label('Bene / locale')
->formatStateUsing(fn($state): string => trim((string) $state) !== '' ? trim((string) $state) : '—')
->searchable()
->wrap()
->toggleable(),
TextColumn::make('meta.common_asset_scope')
->label('Ambito')
->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($commonScopeOptions, $state))
->toggleable(),
TextColumn::make('meta.allocation_scope')
->label('Addebito')
->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($allocationOptions, $state))
->wrap()
->toggleable(),
TextColumn::make('meta.fiscal_scope')
->label('Trattamento')
->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($fiscalScopeOptions, $state))
->wrap()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('fornitore.ragione_sociale')->label('Fornitore')->searchable()->wrap()->toggleable(), TextColumn::make('fornitore.ragione_sociale')->label('Fornitore')->searchable()->wrap()->toggleable(),
TextColumn::make('voceSpesa.descrizione')->label('Voce spesa')->searchable()->wrap()->toggleable(), TextColumn::make('voceSpesa.descrizione')->label('Voce spesa')->searchable()->wrap()->toggleable(),
@ -350,6 +392,21 @@ public function table(Table $table): Table
TextColumn::make('codice_contratto')->label('Contratto')->searchable()->toggleable(isToggledHiddenByDefault: true), TextColumn::make('codice_contratto')->label('Contratto')->searchable()->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('contatore_matricola')->label('Matricola')->searchable()->toggleable(), TextColumn::make('contatore_matricola')->label('Matricola')->searchable()->toggleable(),
TextColumn::make('meta.counter_scope')
->label('Contatore')
->formatStateUsing(fn($state) => $this->formatMetaOptionLabel($counterScopeOptions, $state))
->toggleable(),
IconColumn::make('meta.has_dedicated_meter')
->label('Ded.')
->boolean()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('meta.served_area_label')
->label('Area servita')
->wrap()
->toggleable(isToggledHiddenByDefault: true),
IconColumn::make('attivo')->label('Attivo')->boolean()->sortable(), IconColumn::make('attivo')->label('Attivo')->boolean()->sortable(),
TextColumn::make('updated_at')->label('Aggiornato')->dateTime('d/m/Y H:i')->toggleable(isToggledHiddenByDefault: true), TextColumn::make('updated_at')->label('Aggiornato')->dateTime('d/m/Y H:i')->toggleable(isToggledHiddenByDefault: true),
]) ])
@ -363,6 +420,16 @@ public function table(Table $table): Table
->options($tipoOptions) ->options($tipoOptions)
->required(), ->required(),
TextInput::make('nome')->label('Nome')->maxLength(255), TextInput::make('nome')->label('Nome')->maxLength(255),
TextInput::make('common_asset_label')->label('Bene comune / locale / servizio')->maxLength(255),
Select::make('common_asset_scope')
->label('Ambito bene comune')
->options($commonScopeOptions),
Select::make('allocation_scope')
->label('Ripartizione / addebito')
->options($allocationOptions),
Select::make('fiscal_scope')
->label('Trattamento economico / fiscale')
->options($fiscalScopeOptions),
Select::make('fornitore_id') Select::make('fornitore_id')
->label('Fornitore') ->label('Fornitore')
->searchable() ->searchable()
@ -375,6 +442,12 @@ public function table(Table $table): Table
TextInput::make('codice_cliente')->label('Codice cliente')->maxLength(255), TextInput::make('codice_cliente')->label('Codice cliente')->maxLength(255),
TextInput::make('codice_contratto')->label('Codice contratto')->maxLength(255), TextInput::make('codice_contratto')->label('Codice contratto')->maxLength(255),
TextInput::make('contatore_matricola')->label('Matricola contatore')->maxLength(255), TextInput::make('contatore_matricola')->label('Matricola contatore')->maxLength(255),
Toggle::make('has_dedicated_meter')->label('Contatore dedicato')->default(false),
Select::make('counter_scope')
->label('Tipo contatore')
->options($counterScopeOptions)
->default('assente'),
TextInput::make('served_area_label')->label('Area servita / utilizzo')->maxLength(255),
Select::make('canale_acquisizione') Select::make('canale_acquisizione')
->label('Canale acquisizione letture') ->label('Canale acquisizione letture')
->options([ ->options([
@ -389,6 +462,7 @@ public function table(Table $table): Table
TextInput::make('costo_lettura_unitario')->label('Costo lettura (unitario)')->numeric(), TextInput::make('costo_lettura_unitario')->label('Costo lettura (unitario)')->numeric(),
TextInput::make('costo_ripartizione')->label('Costo ripartizione')->numeric(), TextInput::make('costo_ripartizione')->label('Costo ripartizione')->numeric(),
Toggle::make('ripartizione_esterna')->label('Ripartizione affidata a ditta esterna')->default(false), Toggle::make('ripartizione_esterna')->label('Ripartizione affidata a ditta esterna')->default(false),
Textarea::make('operative_notes')->label('Note operative')->rows(3)->maxLength(2000),
Toggle::make('attivo')->label('Attivo')->default(true), Toggle::make('attivo')->label('Attivo')->default(true),
]) ])
->action(function (array $data): void { ->action(function (array $data): void {
@ -405,7 +479,7 @@ public function table(Table $table): Table
StabileServizio::query()->create([ StabileServizio::query()->create([
'stabile_id' => (int) $stabileId, 'stabile_id' => (int) $stabileId,
'tipo' => strtolower(trim((string) ($data['tipo'] ?? ''))), 'tipo' => strtolower(trim((string) ($data['tipo'] ?? ''))),
'nome' => trim((string) ($data['nome'] ?? '')), 'nome' => $this->resolveServiceDisplayName($data),
'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null, 'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null,
'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null, 'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null,
'codice_utenza' => trim((string) ($data['codice_utenza'] ?? '')) ?: null, 'codice_utenza' => trim((string) ($data['codice_utenza'] ?? '')) ?: null,
@ -413,14 +487,7 @@ public function table(Table $table): Table
'codice_contratto' => trim((string) ($data['codice_contratto'] ?? '')) ?: null, 'codice_contratto' => trim((string) ($data['codice_contratto'] ?? '')) ?: null,
'contatore_matricola' => trim((string) ($data['contatore_matricola'] ?? '')) ?: null, 'contatore_matricola' => trim((string) ($data['contatore_matricola'] ?? '')) ?: null,
'attivo' => (bool) ($data['attivo'] ?? true), 'attivo' => (bool) ($data['attivo'] ?? true),
'meta' => [ 'meta' => $this->buildServiceMetaPayload([], $data),
'canale_acquisizione' => trim((string) ($data['canale_acquisizione'] ?? '')) ?: null,
'email_raccolta_letture' => trim((string) ($data['email_raccolta_letture'] ?? '')) ?: null,
'whatsapp_raccolta_letture' => trim((string) ($data['whatsapp_raccolta_letture'] ?? '')) ?: null,
'costo_lettura_unitario' => is_numeric($data['costo_lettura_unitario'] ?? null) ? (float) $data['costo_lettura_unitario'] : null,
'costo_ripartizione' => is_numeric($data['costo_ripartizione'] ?? null) ? (float) $data['costo_ripartizione'] : null,
'ripartizione_esterna' => (bool) ($data['ripartizione_esterna'] ?? false),
],
]); ]);
}), }),
]) ])
@ -444,6 +511,16 @@ public function table(Table $table): Table
->options($tipoOptions) ->options($tipoOptions)
->required(), ->required(),
TextInput::make('nome')->label('Nome')->maxLength(255), TextInput::make('nome')->label('Nome')->maxLength(255),
TextInput::make('common_asset_label')->label('Bene comune / locale / servizio')->maxLength(255),
Select::make('common_asset_scope')
->label('Ambito bene comune')
->options($commonScopeOptions),
Select::make('allocation_scope')
->label('Ripartizione / addebito')
->options($allocationOptions),
Select::make('fiscal_scope')
->label('Trattamento economico / fiscale')
->options($fiscalScopeOptions),
Select::make('fornitore_id') Select::make('fornitore_id')
->label('Fornitore') ->label('Fornitore')
->searchable() ->searchable()
@ -456,6 +533,11 @@ public function table(Table $table): Table
TextInput::make('codice_cliente')->label('Codice cliente')->maxLength(255), TextInput::make('codice_cliente')->label('Codice cliente')->maxLength(255),
TextInput::make('codice_contratto')->label('Codice contratto')->maxLength(255), TextInput::make('codice_contratto')->label('Codice contratto')->maxLength(255),
TextInput::make('contatore_matricola')->label('Matricola contatore')->maxLength(255), TextInput::make('contatore_matricola')->label('Matricola contatore')->maxLength(255),
Toggle::make('has_dedicated_meter')->label('Contatore dedicato')->default(false),
Select::make('counter_scope')
->label('Tipo contatore')
->options($counterScopeOptions),
TextInput::make('served_area_label')->label('Area servita / utilizzo')->maxLength(255),
Select::make('canale_acquisizione') Select::make('canale_acquisizione')
->label('Canale acquisizione letture') ->label('Canale acquisizione letture')
->options([ ->options([
@ -470,37 +552,40 @@ public function table(Table $table): Table
TextInput::make('costo_lettura_unitario')->label('Costo lettura (unitario)')->numeric(), TextInput::make('costo_lettura_unitario')->label('Costo lettura (unitario)')->numeric(),
TextInput::make('costo_ripartizione')->label('Costo ripartizione')->numeric(), TextInput::make('costo_ripartizione')->label('Costo ripartizione')->numeric(),
Toggle::make('ripartizione_esterna')->label('Ripartizione affidata a ditta esterna')->default(false), Toggle::make('ripartizione_esterna')->label('Ripartizione affidata a ditta esterna')->default(false),
Textarea::make('operative_notes')->label('Note operative')->rows(3)->maxLength(2000),
Toggle::make('attivo')->label('Attivo')->default(true), Toggle::make('attivo')->label('Attivo')->default(true),
]) ])
->fillForm(fn(StabileServizio $record): array=> [ ->fillForm(fn(StabileServizio $record): array=> [
'tipo' => (string) ($record->tipo ?? ''), 'tipo' => (string) ($record->tipo ?? ''),
'nome' => (string) ($record->nome ?? ''), 'nome' => (string) ($record->nome ?? ''),
'common_asset_label' => (string) (($record->meta['common_asset_label'] ?? '') ?: ''),
'common_asset_scope' => (string) (($record->meta['common_asset_scope'] ?? '') ?: ''),
'allocation_scope' => (string) (($record->meta['allocation_scope'] ?? '') ?: ''),
'fiscal_scope' => (string) (($record->meta['fiscal_scope'] ?? '') ?: ''),
'fornitore_id' => $record->fornitore_id, 'fornitore_id' => $record->fornitore_id,
'voce_spesa_id' => $record->voce_spesa_id, 'voce_spesa_id' => $record->voce_spesa_id,
'codice_utenza' => (string) ($record->codice_utenza ?? ''), 'codice_utenza' => (string) ($record->codice_utenza ?? ''),
'codice_cliente' => (string) ($record->codice_cliente ?? ''), 'codice_cliente' => (string) ($record->codice_cliente ?? ''),
'codice_contratto' => (string) ($record->codice_contratto ?? ''), 'codice_contratto' => (string) ($record->codice_contratto ?? ''),
'contatore_matricola' => (string) ($record->contatore_matricola ?? ''), 'contatore_matricola' => (string) ($record->contatore_matricola ?? ''),
'has_dedicated_meter' => (bool) ($record->meta['has_dedicated_meter'] ?? false),
'counter_scope' => (string) (($record->meta['counter_scope'] ?? '') ?: ''),
'served_area_label' => (string) (($record->meta['served_area_label'] ?? '') ?: ''),
'canale_acquisizione' => (string) (($record->meta['canale_acquisizione'] ?? '') ?: ''), 'canale_acquisizione' => (string) (($record->meta['canale_acquisizione'] ?? '') ?: ''),
'email_raccolta_letture' => (string) (($record->meta['email_raccolta_letture'] ?? '') ?: ''), 'email_raccolta_letture' => (string) (($record->meta['email_raccolta_letture'] ?? '') ?: ''),
'whatsapp_raccolta_letture' => (string) (($record->meta['whatsapp_raccolta_letture'] ?? '') ?: ''), 'whatsapp_raccolta_letture' => (string) (($record->meta['whatsapp_raccolta_letture'] ?? '') ?: ''),
'costo_lettura_unitario' => $record->meta['costo_lettura_unitario'] ?? null, 'costo_lettura_unitario' => $record->meta['costo_lettura_unitario'] ?? null,
'costo_ripartizione' => $record->meta['costo_ripartizione'] ?? null, 'costo_ripartizione' => $record->meta['costo_ripartizione'] ?? null,
'ripartizione_esterna' => (bool) ($record->meta['ripartizione_esterna'] ?? false), 'ripartizione_esterna' => (bool) ($record->meta['ripartizione_esterna'] ?? false),
'operative_notes' => (string) (($record->meta['operative_notes'] ?? '') ?: ''),
'attivo' => (bool) ($record->attivo ?? true), 'attivo' => (bool) ($record->attivo ?? true),
]) ])
->action(function (StabileServizio $record, array $data): void { ->action(function (StabileServizio $record, array $data): void {
$meta = is_array($record->meta ?? null) ? $record->meta : []; $meta = $this->buildServiceMetaPayload(is_array($record->meta ?? null) ? $record->meta : [], $data);
$meta['canale_acquisizione'] = trim((string) ($data['canale_acquisizione'] ?? '')) ?: null;
$meta['email_raccolta_letture'] = trim((string) ($data['email_raccolta_letture'] ?? '')) ?: null;
$meta['whatsapp_raccolta_letture'] = trim((string) ($data['whatsapp_raccolta_letture'] ?? '')) ?: null;
$meta['costo_lettura_unitario'] = is_numeric($data['costo_lettura_unitario'] ?? null) ? (float) $data['costo_lettura_unitario'] : null;
$meta['costo_ripartizione'] = is_numeric($data['costo_ripartizione'] ?? null) ? (float) $data['costo_ripartizione'] : null;
$meta['ripartizione_esterna'] = (bool) ($data['ripartizione_esterna'] ?? false);
$record->fill([ $record->fill([
'tipo' => strtolower(trim((string) ($data['tipo'] ?? ''))), 'tipo' => strtolower(trim((string) ($data['tipo'] ?? ''))),
'nome' => trim((string) ($data['nome'] ?? '')), 'nome' => $this->resolveServiceDisplayName($data, (string) ($record->nome ?? '')),
'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null, 'fornitore_id' => (int) ($data['fornitore_id'] ?? 0) ?: null,
'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null, 'voce_spesa_id' => (int) ($data['voce_spesa_id'] ?? 0) ?: null,
'codice_utenza' => trim((string) ($data['codice_utenza'] ?? '')) ?: null, 'codice_utenza' => trim((string) ($data['codice_utenza'] ?? '')) ?: null,
@ -522,6 +607,142 @@ public function table(Table $table): Table
]); ]);
} }
/**
* @return array<string, string>
*/
private function getServiceTypeOptions(): array
{
return [
'acqua' => 'Acqua',
'energia' => 'Energia',
'gas' => 'Gas',
'riscaldamento' => 'Riscaldamento',
'ascensore' => 'Ascensore',
'pulizie' => 'Pulizie',
'assicurazione' => 'Assicurazione',
'privacy' => 'Privacy',
'antenna' => 'Antenna',
'antincendio' => 'Antincendio',
'citofonia' => 'Citofonia',
'locale_comune' => 'Locale comune',
'spazio_comune' => 'Spazio / area comune',
'impianto' => 'Impianto comune',
'altro' => 'Altro',
];
}
/**
* @return array<string, string>
*/
private function getCommonAssetScopeOptions(): array
{
return [
'locale' => 'Locale',
'spazio' => 'Spazio comune',
'impianto' => 'Impianto',
'area_esterna' => 'Area esterna / giardino',
'copertura' => 'Terrazzo / copertura',
'accesso' => 'Portone / accesso',
'servizio' => 'Servizio comune',
'sicurezza' => 'Sicurezza / antincendio',
'amministrativo'=> 'Privacy / assicurazione / studio',
'altro' => 'Altro',
];
}
/**
* @return array<string, string>
*/
private function getAllocationScopeOptions(): array
{
return [
'tutti_millesimi' => 'Tutti per millesimi',
'condomini_utilizzatori' => 'Condomini utilizzatori',
'inquilini_utilizzatori' => 'Inquilini utilizzatori',
'condomini_inquilini_utilizzatori'=> 'Condomini + inquilini utilizzatori',
'rimborso_spese' => 'Rimborso spese uso bene comune',
'gestione_studio' => 'Gestione studio / amministrazione',
'altro' => 'Altro criterio',
];
}
/**
* @return array<string, string>
*/
private function getFiscalScopeOptions(): array
{
return [
'spesa_condominiale' => 'Spesa condominiale',
'rimborso_non_fiscale' => 'Rimborso spese non fiscale',
'da_fatturare' => 'Da fatturare',
'polizza_o_servizio' => 'Polizza / servizio',
'non_applicabile' => 'Non applicabile',
];
}
/**
* @return array<string, string>
*/
private function getCounterScopeOptions(): array
{
return [
'generale' => 'Generale',
'particolare' => 'Particolare / dedicato',
'assente' => 'Senza contatore',
];
}
private function formatMetaOptionLabel(array $options, mixed $state): string
{
$value = strtolower(trim((string) $state));
return $options[$value] ?? ($value !== '' ? (string) $state : '—');
}
/**
* @param array<string, mixed> $currentMeta
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function buildServiceMetaPayload(array $currentMeta, array $data): array
{
$meta = $currentMeta;
$meta['common_asset_label'] = trim((string) ($data['common_asset_label'] ?? '')) ?: null;
$meta['common_asset_scope'] = trim((string) ($data['common_asset_scope'] ?? '')) ?: null;
$meta['allocation_scope'] = trim((string) ($data['allocation_scope'] ?? '')) ?: null;
$meta['fiscal_scope'] = trim((string) ($data['fiscal_scope'] ?? '')) ?: null;
$meta['has_dedicated_meter'] = (bool) ($data['has_dedicated_meter'] ?? false);
$meta['counter_scope'] = trim((string) ($data['counter_scope'] ?? '')) ?: (($meta['has_dedicated_meter'] ?? false) ? 'particolare' : 'assente');
$meta['served_area_label'] = trim((string) ($data['served_area_label'] ?? '')) ?: null;
$meta['canale_acquisizione'] = trim((string) ($data['canale_acquisizione'] ?? '')) ?: null;
$meta['email_raccolta_letture'] = trim((string) ($data['email_raccolta_letture'] ?? '')) ?: null;
$meta['whatsapp_raccolta_letture'] = trim((string) ($data['whatsapp_raccolta_letture'] ?? '')) ?: null;
$meta['costo_lettura_unitario'] = is_numeric($data['costo_lettura_unitario'] ?? null) ? (float) $data['costo_lettura_unitario'] : null;
$meta['costo_ripartizione'] = is_numeric($data['costo_ripartizione'] ?? null) ? (float) $data['costo_ripartizione'] : null;
$meta['ripartizione_esterna'] = (bool) ($data['ripartizione_esterna'] ?? false);
$meta['operative_notes'] = trim((string) ($data['operative_notes'] ?? '')) ?: null;
return $meta;
}
/**
* @param array<string, mixed> $data
*/
private function resolveServiceDisplayName(array $data, string $fallback = ''): string
{
$name = trim((string) ($data['nome'] ?? ''));
if ($name !== '') {
return $name;
}
$commonLabel = trim((string) ($data['common_asset_label'] ?? ''));
if ($commonLabel !== '') {
return $commonLabel;
}
return trim($fallback);
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */

View File

@ -1227,7 +1227,7 @@ public function getStableGoogleConnectUrl(): string
return route('oauth.google.redirect', [ return route('oauth.google.redirect', [
'account_key' => 'stabile-' . ($this->stabile?->id ?? 0) . '-pec', 'account_key' => 'stabile-' . ($this->stabile?->id ?? 0) . '-pec',
'label' => $label, 'label' => $label,
'return_to' => request()->getRequestUri(), 'return_to' => $this->getStabilePostaUrl(),
]); ]);
} }

View File

@ -261,7 +261,7 @@ public function getStableGoogleConnectUrl(): string
return route('oauth.google.redirect', [ return route('oauth.google.redirect', [
'account_key' => 'stabile-' . ($this->stabile?->id ?? 0) . '-pec', 'account_key' => 'stabile-' . ($this->stabile?->id ?? 0) . '-pec',
'label' => $label, 'label' => $label,
'return_to' => request()->getRequestUri(), 'return_to' => $this->getReturnUrl(),
]); ]);
} }
@ -270,7 +270,12 @@ public function getTestGoogleConnectUrl(): string
return route('oauth.google.redirect', [ return route('oauth.google.redirect', [
'account_key' => 'gmail-test', 'account_key' => 'gmail-test',
'label' => 'Account Google test', 'label' => 'Account Google test',
'return_to' => request()->getRequestUri(), 'return_to' => $this->getReturnUrl(),
]); ]);
} }
public function getReturnUrl(): string
{
return static::getUrl(['stabile_id' => (int) ($this->stabile?->id ?? 0)], panel: 'admin-filament');
}
} }

View File

@ -54,6 +54,25 @@ class FatturaElettronicaScheda extends Page
/** @var array<string,mixed> */ /** @var array<string,mixed> */
public array $acquaPdfPreview = []; public array $acquaPdfPreview = [];
public function getDocumentoProtocolloConAnnoProperty(): ?string
{
if (! $this->documento instanceof Documento) {
return null;
}
$protocollo = trim((string) ($this->documento->numero_protocollo ?? ''));
if ($protocollo === '') {
return null;
}
$year = $this->documento->data_documento?->format('Y')
?: $this->documento->created_at?->format('Y')
?: $this->fattura->data_fattura?->format('Y')
?: now()->format('Y');
return $year . ' · ' . $protocollo;
}
public function getFornitoreAgganciatoLabel(): ?string public function getFornitoreAgganciatoLabel(): ?string
{ {
$f = $this->fattura->fornitore; $f = $this->fattura->fornitore;

View File

@ -294,6 +294,7 @@ public function registraLetturaAcqua(): void
$letturaFine = round((float) $validated['letturaAttuale'], 3); $letturaFine = round((float) $validated['letturaAttuale'], 3);
$consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null; $consumo = $letturaInizio !== null ? round($letturaFine - $letturaInizio, 3) : null;
$readerName = trim((string) ($validated['rilevatoreNome'] ?? '')); $readerName = trim((string) ($validated['rilevatoreNome'] ?? ''));
$consistency = $this->buildReadingConsistencyCheck((int) $servizio->id, (int) $unita->id, $letturaFine, $letturaInizio);
$readingDate = filled($validated['dataLettura'] ?? null) $readingDate = filled($validated['dataLettura'] ?? null)
? Carbon::parse((string) $validated['dataLettura']) ? Carbon::parse((string) $validated['dataLettura'])
: now(); : now();
@ -390,6 +391,7 @@ public function registraLetturaAcqua(): void
'photo_assets' => $photoAssets, 'photo_assets' => $photoAssets,
'drive_relative_directory' => $storageDirectory, 'drive_relative_directory' => $storageDirectory,
'reader_label' => $this->resolveUnitReaderLabel($unita), 'reader_label' => $this->resolveUnitReaderLabel($unita),
'consistency_check' => $consistency,
], ],
'created_by' => (int) $user->id, 'created_by' => (int) $user->id,
]); ]);
@ -407,6 +409,14 @@ public function registraLetturaAcqua(): void
->body('Lettura salvata per unità ' . ((string) ($unita->codice_unita ?: ('#' . $unita->id))) . ' con riferimento #' . $row->id . '.') ->body('Lettura salvata per unità ' . ((string) ($unita->codice_unita ?: ('#' . $unita->id))) . ' con riferimento #' . $row->id . '.')
->success() ->success()
->send(); ->send();
if (($consistency['status'] ?? 'ok') !== 'ok') {
Notification::make()
->title('Controllo coerenza lettura')
->body((string) ($consistency['summary'] ?? 'La lettura va verificata rispetto allo storico.'))
->warning()
->send();
}
} }
private function loadStabileOptions(): void private function loadStabileOptions(): void
@ -455,6 +465,8 @@ private function loadServiceOptions(): void
return; return;
} }
$this->ensureWaterServiceBucket();
$this->servizioOptions = StabileServizio::query() $this->servizioOptions = StabileServizio::query()
->where('stabile_id', (int) $this->stabileId) ->where('stabile_id', (int) $this->stabileId)
->where('tipo', 'acqua') ->where('tipo', 'acqua')
@ -497,8 +509,6 @@ private function loadUnitOptions(): void
->where(function ($query): void { ->where(function ($query): void {
$query->whereNull('attiva')->orWhere('attiva', true); $query->whereNull('attiva')->orWhere('attiva', true);
}) })
->orderBy('codice_unita')
->orderBy('id')
->get(['id', 'codice_unita', 'denominazione', 'scala', 'piano', 'interno']) ->get(['id', 'codice_unita', 'denominazione', 'scala', 'piano', 'interno'])
->map(function (UnitaImmobiliare $unita): array { ->map(function (UnitaImmobiliare $unita): array {
$bits = [trim((string) ($unita->codice_unita ?: ('UI #' . $unita->id)))]; $bits = [trim((string) ($unita->codice_unita ?: ('UI #' . $unita->id)))];
@ -517,9 +527,10 @@ private function loadUnitOptions(): void
} }
$readerLabel = $this->resolveUnitReaderLabel($unita); $readerLabel = $this->resolveUnitReaderLabel($unita);
$searchNames = $this->resolveUnitSearchNames($unita);
$searchText = mb_strtolower(implode(' ', array_filter([ $searchText = mb_strtolower(implode(' ', array_filter([
implode(' ', array_filter($bits)), implode(' ', array_filter($bits)),
$readerLabel, ...$searchNames,
]))); ])));
return [ return [
@ -528,10 +539,15 @@ private function loadUnitOptions(): void
'reader_label' => $readerLabel, 'reader_label' => $readerLabel,
'folder_prefix' => $this->buildUnitFolderPrefix($unita), 'folder_prefix' => $this->buildUnitFolderPrefix($unita),
'search_text' => $searchText, 'search_text' => $searchText,
'sort_key' => $this->buildUnitNaturalSortKey($unita, $readerLabel),
]; ];
}) })
->all(); ->all();
usort($this->unitaOptions, static function (array $left, array $right): int {
return strnatcasecmp((string) ($left['sort_key'] ?? ''), (string) ($right['sort_key'] ?? ''));
});
$unitIds = array_column($this->unitaOptions, 'id'); $unitIds = array_column($this->unitaOptions, 'id');
if (! in_array((int) $this->unitaId, $unitIds, true)) { if (! in_array((int) $this->unitaId, $unitIds, true)) {
$this->unitaId = $unitIds[0] ?? null; $this->unitaId = $unitIds[0] ?? null;
@ -696,6 +712,38 @@ private function syncSuggestedReaderName(): void
} }
} }
private function ensureWaterServiceBucket(): void
{
if (! $this->stabileId) {
return;
}
$hasActiveWaterService = StabileServizio::query()
->where('stabile_id', (int) $this->stabileId)
->where('tipo', 'acqua')
->where('attivo', true)
->exists();
if ($hasActiveWaterService) {
return;
}
StabileServizio::query()->firstOrCreate(
[
'stabile_id' => (int) $this->stabileId,
'tipo' => 'acqua',
'nome' => 'Archivio letture acqua',
],
[
'attivo' => true,
'meta' => [
'auto_created_by' => 'ticket-acqua',
'purpose' => 'reading-bucket',
],
]
);
}
private function ensureDefaultSelections(): void private function ensureDefaultSelections(): void
{ {
if (! $this->stabileId && $this->stabileOptions !== []) { if (! $this->stabileId && $this->stabileOptions !== []) {
@ -726,6 +774,94 @@ private function resolveCurrentUnitOption(): ?array
return null; return null;
} }
/**
* @return array{status:string,summary:string,delta:?float,average:?float,rule:?string}
*/
private function buildReadingConsistencyCheck(int $servizioId, int $unitaId, float $letturaFine, ?float $letturaInizio): array
{
if ($letturaInizio === null) {
return [
'status' => 'ok',
'summary' => 'Prima lettura utile disponibile per questa unità.',
'delta' => null,
'average' => null,
'rule' => 'first_reading',
];
}
$delta = round($letturaFine - $letturaInizio, 3);
if ($delta < 0) {
return [
'status' => 'danger',
'summary' => 'La lettura inserita è inferiore alla precedente.',
'delta' => $delta,
'average' => null,
'rule' => 'lower_than_previous',
];
}
if ($delta === 0.0) {
return [
'status' => 'warning',
'summary' => 'La lettura coincide con la precedente: verificare se il contatore è fermo o se serve un controllo.',
'delta' => $delta,
'average' => 0.0,
'rule' => 'same_as_previous',
];
}
$history = StabileServizioLettura::query()
->where('stabile_servizio_id', $servizioId)
->where('unita_immobiliare_id', $unitaId)
->whereNotNull('consumo_valore')
->where('consumo_valore', '>', 0)
->orderByDesc('periodo_al')
->orderByDesc('id')
->limit(5)
->pluck('consumo_valore')
->map(fn($value): float => (float) $value)
->all();
if ($history === []) {
return [
'status' => 'ok',
'summary' => 'Lettura registrata: manca ancora uno storico sufficiente per confronti automatici.',
'delta' => $delta,
'average' => null,
'rule' => 'no_history',
];
}
$average = round(array_sum($history) / count($history), 3);
if ($average > 0 && $delta > max(30.0, $average * 3)) {
return [
'status' => 'warning',
'summary' => 'La lettura produce un consumo molto più alto della media storica (' . number_format($delta, 3, ',', '.') . ' mc contro media ' . number_format($average, 3, ',', '.') . ' mc).',
'delta' => $delta,
'average' => $average,
'rule' => 'much_higher_than_average',
];
}
if ($average >= 1 && $delta < max(0.5, $average * 0.2)) {
return [
'status' => 'warning',
'summary' => 'La lettura produce un consumo molto basso rispetto alla media storica (' . number_format($delta, 3, ',', '.') . ' mc contro media ' . number_format($average, 3, ',', '.') . ' mc).',
'delta' => $delta,
'average' => $average,
'rule' => 'much_lower_than_average',
];
}
return [
'status' => 'ok',
'summary' => 'Lettura coerente con lo storico recente.',
'delta' => $delta,
'average' => $average,
'rule' => 'within_expected_range',
];
}
private function resolveUnitReaderLabel(UnitaImmobiliare $unita): string private function resolveUnitReaderLabel(UnitaImmobiliare $unita): string
{ {
$role = $unita->rubricaRuoliAttivi $role = $unita->rubricaRuoliAttivi
@ -752,6 +888,54 @@ private function resolveUnitReaderLabel(UnitaImmobiliare $unita): string
return trim((string) ($unita->denominazione ?? '')); return trim((string) ($unita->denominazione ?? ''));
} }
/**
* @return array<int,string>
*/
private function resolveUnitSearchNames(UnitaImmobiliare $unita): array
{
$names = [];
foreach (($unita->rubricaRuoliAttivi ?? collect()) as $role) {
$contact = $role->contatto;
if (! $contact) {
continue;
}
$label = trim((string) ($contact->nome_completo ?: $contact->ragione_sociale));
if ($label !== '') {
$names[] = $label;
}
}
foreach (($unita->nominativiStorici ?? collect()) as $historic) {
$label = trim((string) ($historic->nominativo ?? ''));
if ($label !== '') {
$names[] = $label;
}
}
$fallback = trim((string) ($unita->denominazione ?? ''));
if ($fallback !== '') {
$names[] = $fallback;
}
return array_values(array_unique(array_filter($names)));
}
private function buildUnitNaturalSortKey(UnitaImmobiliare $unita, string $readerLabel): string
{
$parts = array_filter([
trim((string) ($unita->scala ?? '')),
trim((string) ($unita->interno ?? '')),
trim((string) ($unita->codice_unita ?? '')),
trim((string) ($unita->piano ?? '')),
trim($readerLabel),
(string) $unita->id,
]);
return implode(' ', $parts);
}
private function buildUnitFolderPrefix(UnitaImmobiliare $unita): string private function buildUnitFolderPrefix(UnitaImmobiliare $unita): string
{ {
$parts = array_filter([ $parts = array_filter([

File diff suppressed because it is too large Load Diff

View File

@ -17,9 +17,9 @@
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Servizi / Utenze - ACQUA</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Servizi / Beni comuni - ACQUA</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Cruscotto operativo acqua con controllo fatture, versamenti/letture condomini e tariffe storicizzate. I dati mostrati seguono lo stabile attivo e l'anno gestione del topbar. Cruscotto operativo per utenze, beni comuni, locali condivisi e contatori acqua dello stabile. Qui convivono contratti, servizi ad uso comune, rimborsi spesa, contatori generali e contatori particolari con storico e tariffe.
</p> </p>
<div class="mt-2 inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:border-blue-700/50 dark:bg-blue-900/20 dark:text-blue-200"> <div class="mt-2 inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:border-blue-700/50 dark:bg-blue-900/20 dark:text-blue-200">
Anno gestione attivo: {{ $annoGestioneAttivo }} Anno gestione attivo: {{ $annoGestioneAttivo }}

View File

@ -39,7 +39,7 @@
<form method="POST" action="{{ route('oauth.google.disconnect') }}"> <form method="POST" action="{{ route('oauth.google.disconnect') }}">
@csrf @csrf
<input type="hidden" name="account_key" value="{{ $account['key'] }}" /> <input type="hidden" name="account_key" value="{{ $account['key'] }}" />
<input type="hidden" name="return_to" value="{{ request()->getRequestUri() }}" /> <input type="hidden" name="return_to" value="{{ $this->getReturnUrl() }}" />
<button type="submit" class="rounded-md bg-rose-100 px-2 py-1 text-[11px] font-medium text-rose-700 hover:bg-rose-200">Disconnetti</button> <button type="submit" class="rounded-md bg-rose-100 px-2 py-1 text-[11px] font-medium text-rose-700 hover:bg-rose-200">Disconnetti</button>
</form> </form>
</div> </div>

View File

@ -172,7 +172,7 @@
<div class="text-xs text-gray-500 dark:text-gray-400">PDF allegato (se presente) oppure prospetto generato</div> <div class="text-xs text-gray-500 dark:text-gray-400">PDF allegato (se presente) oppure prospetto generato</div>
@if($this->documento) @if($this->documento)
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Protocollo: <span class="font-medium">{{ $this->documento->numero_protocollo }}</span> Protocollo: <span class="font-medium">{{ $this->documentoProtocolloConAnno ?: $this->documento->numero_protocollo }}</span>
· ·
<button <button
type="button" type="button"

View File

@ -0,0 +1,378 @@
<x-filament-panels::page>
<div class="space-y-4">
@if ($errors->any())
<div class="rounded-xl border border-rose-300 bg-rose-50 p-3 text-sm text-rose-800">
<div class="font-semibold">Controlla i dati prima di salvare</div>
<ul class="mt-2 list-disc pl-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-900">Lettura contatori comuni acqua</h2>
<div class="mt-1 text-sm text-slate-600">Pagina operativa per contatore generale e contatori particolari dei beni comuni dello stabile, con foto geolocalizzate, storico per contratto e scadenze da calendario per amministrazione o collaboratori.</div>
</div>
<div class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-800">Bozza {{ $generalWaterDraftProtocol }}</div>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[1.05fr_0.95fr]">
<div class="space-y-4">
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Stabile</span>
<select wire:model.live="stabileId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona stabile</option>
@foreach($stabileOptions as $option)
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Contratto / servizio acqua</span>
<select wire:model.live="servizioId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona contratto</option>
@foreach($servizioOptions as $option)
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
<div class="mt-1 text-[11px] text-slate-500">Se lo stabile ha piu contratti o contatori dedicati a giardino, locale caldaia o altri beni comuni, aggancia la lettura al servizio corretto.</div>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Letturista</span>
<select wire:model.live="rilevatoreUserId" class="w-full rounded-lg border-gray-300">
<option value="">Seleziona letturista</option>
@foreach($readerOptions as $option)
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Nominativo che invia la lettura</span>
<input type="text" wire:model.defer="rilevatoreNome" class="w-full rounded-lg border-gray-300" placeholder="Nome e cognome" />
</label>
</div>
</div>
@php($service = $this->selectedServiceSnapshot)
@if($service)
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 shadow-sm">
<div class="text-sm font-semibold text-emerald-900">Riferimenti contratto selezionato</div>
<div class="mt-3 grid gap-3 md:grid-cols-2 text-sm text-emerald-900">
<div><span class="font-medium">Servizio:</span> {{ $service['nome'] ?: 'n/d' }}</div>
<div><span class="font-medium">Bene comune:</span> {{ $service['common_asset'] ?: 'n/d' }}</div>
<div><span class="font-medium">Fornitore:</span> {{ $service['fornitore'] ?: 'n/d' }}</div>
<div><span class="font-medium">Tipo contatore:</span> {{ $service['counter_scope'] ?: 'n/d' }}</div>
<div><span class="font-medium">Codice utenza:</span> {{ $service['codice_utenza'] ?: 'n/d' }}</div>
<div><span class="font-medium">Codice cliente:</span> {{ $service['codice_cliente'] ?: 'n/d' }}</div>
<div><span class="font-medium">Codice contratto:</span> {{ $service['codice_contratto'] ?: 'n/d' }}</div>
<div><span class="font-medium">Matricola contatore:</span> {{ $service['matricola'] ?: 'n/d' }}</div>
<div><span class="font-medium">Area servita:</span> {{ $service['served_area'] ?: 'n/d' }}</div>
</div>
<div class="mt-3 rounded-xl border border-emerald-300 bg-white p-3 text-xs text-slate-700">
<div class="font-semibold text-slate-900">Formato rapido Acea</div>
<div class="mt-1">SMS a 3399941808: <span class="font-mono">{{ $service['acea_sms_preview'] !== '' ? $service['acea_sms_preview'] : 'codiceutenza#codicecliente#autolettura' }}</span></div>
<div class="mt-1">Numero verde: <span class="font-medium">800 130 331</span></div>
<div class="mt-1">Area clienti: <span class="font-medium">www.aceaato2.it</span></div>
<div class="mt-1">Cartella archivio anno: <span class="font-medium">{{ $service['year_folder'] }}</span></div>
</div>
</div>
@endif
<div class="rounded-2xl border border-sky-200 bg-sky-50 p-4 shadow-sm">
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium text-slate-900">Lettura effettiva</span>
<input type="number" step="0.001" min="0" wire:model.defer="letturaAttuale" class="w-full rounded-lg border-sky-300 bg-white text-2xl font-semibold text-slate-900" placeholder="Es. 1284.350" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium text-slate-900">Data lettura</span>
<input type="date" wire:model.defer="dataLettura" class="w-full rounded-lg border-sky-300 bg-white text-slate-900" />
</label>
</div>
<div class="mt-2 text-xs text-slate-600">La lettura viene salvata nello storico del contratto selezionato, separata dalle singole unita ma con lo stesso standard foto e metadati. Se il valore e fuori linea rispetto allo storico recente compare subito un avviso.</div>
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="text-sm font-semibold text-slate-900">Finestra Acea e calendario operativo</div>
<div class="mt-3 grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Inizio finestra autolettura</span>
<input type="date" wire:model.defer="aceaWindowStart" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Fine finestra autolettura</span>
<input type="date" wire:model.defer="aceaWindowEnd" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Data passaggio incaricato Acea</span>
<input type="date" wire:model.defer="aceaVisitDate" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Canale previsto invio lettura</span>
<select wire:model.defer="aceaAutoSendChannel" class="w-full rounded-lg border-gray-300">
<option value="manuale">Manuale studio</option>
<option value="sms">SMS Acea</option>
<option value="myacea">MyAcea Acqua</option>
<option value="telefono">Numero verde</option>
<option value="api_futura">Invio automatico futuro</option>
</select>
</label>
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block font-medium">Fascia da</span>
<input type="time" wire:model.defer="aceaVisitTimeFrom" class="w-full rounded-lg border-gray-300" />
</label>
<label class="block text-sm">
<span class="mb-1 block font-medium">Fascia a</span>
<input type="time" wire:model.defer="aceaVisitTimeTo" class="w-full rounded-lg border-gray-300" />
</label>
</div>
</div>
<div class="mt-2 text-xs text-slate-500">Quando salvi la lettura, creo anche le scadenze operative per la finestra Acea e per il passaggio dell'incaricato se hai compilato le date.</div>
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<label class="block text-sm">
<span class="mb-1 block font-medium">Note operative</span>
<textarea rows="4" wire:model.defer="noteGenerali" class="w-full rounded-lg border-gray-300" placeholder="Es. accesso locale contatori, due contratti nello stabile, lettura inviata via MyAcea oppure da confermare via SMS..."></textarea>
</label>
</div>
</div>
<div class="space-y-4">
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="text-sm font-semibold text-slate-900">Ultima lettura generale nota</div>
@if($lastGeneralReading)
<div class="mt-3 space-y-1 text-sm text-slate-700">
<div><span class="font-medium">Valore:</span> {{ number_format((float) $lastGeneralReading['value'], 3, ',', '.') }}</div>
<div><span class="font-medium">Data:</span> {{ $lastGeneralReading['date'] ?: 'n/d' }}</div>
<div><span class="font-medium">Letturista:</span> {{ $lastGeneralReading['reader'] ?: 'n/d' }}</div>
<div><span class="font-medium">Protocollo:</span> {{ $lastGeneralReading['protocol'] ?: 'n/d' }}</div>
</div>
@else
<div class="mt-3 text-sm text-slate-500">Nessuna lettura generale ancora registrata per questo contratto.</div>
@endif
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<label class="block text-sm"
x-data="{
localPreviews: [],
localClientMetadata: [],
init() {
window.__ticketAcquaGeneralePreviewState = window.__ticketAcquaGeneralePreviewState || [];
window.__ticketAcquaGeneraleClientMetadataState = window.__ticketAcquaGeneraleClientMetadataState || [];
this.localPreviews = [...window.__ticketAcquaGeneralePreviewState];
this.localClientMetadata = [...window.__ticketAcquaGeneraleClientMetadataState];
this.syncClientMetadataToWire();
},
persist() {
window.__ticketAcquaGeneralePreviewState = [...this.localPreviews];
},
persistClientMetadata() {
window.__ticketAcquaGeneraleClientMetadataState = [...this.localClientMetadata];
this.syncClientMetadataToWire();
},
revoke(items) {
items.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
});
},
syncClientMetadataToWire() {
$wire.set('generalWaterClientMetadata', this.localClientMetadata.map((item) => ({
source: item.source,
name: item.name,
size: item.size,
captured_at: item.captured_at || null,
device: item.device || null,
gps_decimal: item.gps_decimal || null,
gps_accuracy_meters: item.gps_accuracy_meters || null,
})), false);
},
getDeviceLabel() {
if (navigator.userAgentData?.platform) {
return navigator.userAgentData.platform;
}
return navigator.platform || navigator.userAgent || '';
},
async readGeolocation() {
if (!navigator.geolocation) {
return null;
}
return await new Promise((resolve) => {
navigator.geolocation.getCurrentPosition(
(position) => resolve({
gps_decimal: {
lat: position.coords.latitude,
lng: position.coords.longitude,
},
gps_accuracy_meters: position.coords.accuracy || null,
}),
() => resolve(null),
{
enableHighAccuracy: true,
maximumAge: 30000,
timeout: 8000,
},
);
});
},
async buildClientMetadata(file) {
const base = {
source: 'camera',
name: file?.name || '',
size: file?.size || 0,
captured_at: new Date(file?.lastModified || Date.now()).toISOString(),
device: this.getDeviceLabel(),
};
const geo = await this.readGeolocation();
return geo ? { ...base, ...geo } : base;
},
mergeClientMetadata(items) {
const existing = new Map(this.localClientMetadata.map((item) => [`${item.name}|${item.size}`, item]));
items.forEach((item) => {
if (!item?.name) {
return;
}
existing.set(`${item.name}|${item.size}`, item);
});
this.localClientMetadata = Array.from(existing.values());
this.persistClientMetadata();
},
async addFiles(event) {
const batchId = Date.now();
const files = Array.from(event?.target?.files || []);
const mapped = files.map((file, index) => ({
key: `${batchId}-${index}-${file.name}-${file.size}`,
name: file.name,
size: file.size || 0,
url: (file.type || '').startsWith('image/') ? URL.createObjectURL(file) : null,
}));
const existing = new Map(this.localPreviews.map((item) => [item.key, item]));
mapped.forEach((item) => existing.set(item.key, item));
this.localPreviews = Array.from(existing.values());
this.mergeClientMetadata(await Promise.all(files.map((file) => this.buildClientMetadata(file))));
this.persist();
if (event?.target) {
event.target.value = null;
}
},
previewFor(name, size = 0) {
const exact = this.localPreviews.find((item) => item.name === name && Number(item.size || 0) === Number(size || 0));
return exact?.url || null;
},
clearAll() {
this.revoke(this.localPreviews);
this.localPreviews = [];
this.localClientMetadata = [];
this.persist();
this.persistClientMetadata();
},
}"
x-on:ticket-acqua-generale-draft-reset.window="clearAll()"
>
<span class="mb-2 block font-medium">Foto contatore comune</span>
<label class="inline-flex cursor-pointer items-center rounded-md bg-emerald-600 px-3 py-2 text-xs font-medium text-white hover:bg-emerald-500">
Scatta o aggiungi foto
<input type="file" wire:model="pendingGeneralWaterPhotos" multiple accept="image/*" capture="environment" class="hidden" x-on:change="addFiles($event)" />
</label>
<div class="mt-1 text-xs text-gray-500">Puoi accodare fino a 4 immagini del contatore generale o particolare e della targhetta matricola.</div>
<div wire:loading wire:target="pendingGeneralWaterPhotos" class="mt-2 text-xs font-medium text-amber-700">Caricamento foto in corso.</div>
@if(count($this->selectedGeneralWaterUploads) > 0)
<div class="mt-4 grid gap-3 sm:grid-cols-2">
@foreach($this->selectedGeneralWaterUploads as $upload)
<div class="rounded-xl border bg-slate-50 p-3 text-xs shadow-sm">
<div class="mb-2 aspect-[4/3] overflow-hidden rounded-lg border bg-white" x-show="Boolean(@js($upload['preview_url']) || previewFor(@js($upload['original_name']), @js($upload['size'] ?? 0)))">
<img x-bind:src="@js($upload['preview_url']) || previewFor(@js($upload['original_name']), @js($upload['size'] ?? 0))" alt="{{ $upload['name'] }}" class="h-full w-full object-cover" />
</div>
<div class="rounded-lg border bg-white px-3 py-2 text-[11px] text-slate-700">
<div class="font-medium">{{ $upload['name'] }}</div>
<div class="mt-1 text-emerald-700">Protocollo bozza: {{ $upload['protocol'] }}</div>
<div class="mt-1 text-slate-500">Origine: {{ $upload['original_name'] }}</div>
</div>
@php
$metadata = is_array($upload['metadata'] ?? null) ? $upload['metadata'] : [];
$gpsLat = data_get($metadata, 'gps_decimal.lat');
$gpsLng = data_get($metadata, 'gps_decimal.lng');
$hasGps = is_numeric($gpsLat) && is_numeric($gpsLng);
$device = trim((string) data_get($metadata, 'device', ''));
@endphp
@if($hasGps || $device !== '')
<div class="mt-2 rounded-lg border border-slate-200 bg-white p-2 text-[11px] text-slate-700">
@if($hasGps)
<div><span class="font-medium">Coordinate:</span> {{ number_format((float) $gpsLat, 5, '.', '') }}, {{ number_format((float) $gpsLng, 5, '.', '') }}</div>
@endif
@if($device !== '')
<div><span class="font-medium">Dispositivo:</span> {{ $device }}</div>
@endif
</div>
@endif
<label class="mt-2 block">
<span class="mb-1 block text-[11px] font-medium">Nota foto</span>
<input type="text" wire:model.defer="generalWaterPhotoDescriptions.{{ $upload['index'] }}" class="w-full rounded-lg border-gray-300" placeholder="Es. display principale, targhetta matricola, quadro contatori" />
</label>
<button type="button" wire:click="removeGeneralWaterPhoto({{ (int) $upload['index'] }})" class="mt-2 inline-flex items-center rounded-md bg-rose-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-rose-500">Rimuovi</button>
</div>
@endforeach
</div>
@else
<div class="mt-3 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-center text-sm text-slate-500">Nessuna foto in coda. Aggiungi almeno la foto display e, se possibile, matricola/contratto esposto vicino al contatore.</div>
@endif
</label>
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<div class="text-sm font-semibold text-slate-900">Storico rapido contratto</div>
@if(count($this->recentGeneralReadings) > 0)
<div class="mt-3 space-y-2">
@foreach($this->recentGeneralReadings as $row)
<div class="rounded-xl border bg-slate-50 px-3 py-2 text-xs text-slate-700">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="font-medium">{{ $row['date'] ?: 'n/d' }} · {{ $row['protocollo'] ?: 'senza protocollo' }}</div>
<div>{{ $row['workflow'] ?: 'n/d' }}</div>
</div>
<div class="mt-1">Valore: <span class="font-medium">{{ $row['value'] !== null ? number_format((float) $row['value'], 3, ',', '.') . ' mc' : 'n/d' }}</span></div>
@if($row['consumo'] !== null)
<div class="mt-1">Consumo: {{ number_format((float) $row['consumo'], 3, ',', '.') }} mc</div>
@endif
@if($row['reader'] !== '')
<div class="mt-1">Letturista: {{ $row['reader'] }}</div>
@endif
</div>
@endforeach
</div>
@else
<div class="mt-3 text-sm text-slate-500">Storico ancora vuoto per questo contratto.</div>
@endif
</div>
<div class="rounded-2xl border bg-white p-4 shadow-sm">
<x-filament::button wire:click="registraLetturaGenerale" wire:loading.attr="disabled" wire:target="registraLetturaGenerale,pendingGeneralWaterPhotos">
<span wire:loading.remove wire:target="registraLetturaGenerale">Salva lettura generale</span>
<span wire:loading wire:target="registraLetturaGenerale">Salvataggio...</span>
</x-filament::button>
</div>
</div>
</div>
</div>
</x-filament-panels::page>

View File

@ -43,6 +43,7 @@
<option value="{{ $option['id'] }}">{{ $option['label'] }}</option> <option value="{{ $option['id'] }}">{{ $option['label'] }}</option>
@endforeach @endforeach
</select> </select>
<div class="mt-1 text-[11px] text-slate-500">Se lo stabile non ha ancora un servizio acqua attivo, preparo automaticamente un archivio letture dedicato.</div>
</label> </label>
<label class="block text-sm md:col-span-2"> <label class="block text-sm md:col-span-2">

View File

@ -378,6 +378,7 @@
})); }));
this.appendPreviews(target, mapped); this.appendPreviews(target, mapped);
this.mergeClientMetadata(await Promise.all(files.map((file) => this.buildClientMetadata(file, kind)))); this.mergeClientMetadata(await Promise.all(files.map((file) => this.buildClientMetadata(file, kind))));
this.draftSequence += files.length;
this.persist(); this.persist();
if (event?.target) { if (event?.target) {
event.target.value = null; event.target.value = null;
@ -401,14 +402,34 @@
this.persist(); this.persist();
this.persistClientMetadata(); this.persistClientMetadata();
}, },
previewFor(draftName, originalName, size = 0) { previewFor(draftName, originalName, size = 0, source = null) {
const pool = [...this.localCameraPreviews, ...this.localAttachmentPreviews]; const pool = [...this.localCameraPreviews, ...this.localAttachmentPreviews];
const exact = pool.find((item) => item.name === originalName && Number(item.size || 0) === Number(size || 0)); const exact = pool.find((item) => {
if (source === 'camera' && !String(item.key || '').startsWith('camera-')) {
return false;
}
if (source === 'attachment' && !String(item.key || '').startsWith('attachment-')) {
return false;
}
return item.name === originalName && Number(item.size || 0) === Number(size || 0);
});
if (exact?.url) { if (exact?.url) {
return exact.url; return exact.url;
} }
const match = pool.find((item) => item.draftName === draftName || item.name === originalName); const match = pool.find((item) => {
if (source === 'camera' && !String(item.key || '').startsWith('camera-')) {
return false;
}
if (source === 'attachment' && !String(item.key || '').startsWith('attachment-')) {
return false;
}
return item.draftName === draftName || item.name === originalName;
});
return match?.url || null; return match?.url || null;
}, },
@ -465,8 +486,8 @@
@foreach($this->selectedUploads as $upload) @foreach($this->selectedUploads as $upload)
<div class="rounded-xl border bg-white p-3 text-xs shadow-sm"> <div class="rounded-xl border bg-white p-3 text-xs shadow-sm">
@if($upload['is_image']) @if($upload['is_image'])
<div class="mb-2 aspect-[4/3] overflow-hidden rounded-lg border bg-slate-50" x-show="Boolean(@js($upload['preview_url']) || previewFor(@js($upload['name']), @js($upload['original_name']), @js($upload['size'] ?? 0)))"> <div class="mb-2 aspect-[4/3] overflow-hidden rounded-lg border bg-slate-50" x-show="Boolean(@js($upload['preview_url']) || previewFor(@js($upload['name']), @js($upload['original_name']), @js($upload['size'] ?? 0), @js($upload['metadata']['capture_source'] ?? null)))">
<img x-bind:src="@js($upload['preview_url']) || previewFor(@js($upload['name']), @js($upload['original_name']), @js($upload['size'] ?? 0))" alt="{{ $upload['name'] }}" class="h-full w-full object-cover" /> <img x-bind:src="@js($upload['preview_url']) || previewFor(@js($upload['name']), @js($upload['original_name']), @js($upload['size'] ?? 0), @js($upload['metadata']['capture_source'] ?? null))" alt="{{ $upload['name'] }}" class="h-full w-full object-cover" />
</div> </div>
@endif @endif
<div class="flex flex-wrap items-center gap-2 rounded-lg border bg-gray-50 px-3 py-2"> <div class="flex flex-wrap items-center gap-2 rounded-lg border bg-gray-50 px-3 py-2">