feat: add previous configuration handling for user selections in quotes

- Implemented session storage management for the last configuration choices made by the user.
- Added functionality to prompt users when changing models to retain their previous selections.
- Introduced methods to apply previous choices to the newly loaded model, including finishes, handles, glass types, and options.
- Enhanced user experience by ensuring selections are automatically applied where applicable.
This commit is contained in:
2026-10-19 19:49:44 +02:00
parent 863e6ca920
commit 06617a985a
197 changed files with 4892 additions and 400 deletions
@@ -0,0 +1,142 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\FinituraResource\Pages;
use App\Filament\Support\CampiConfiguratore;
use App\Models\Finitura;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\Rules\Unique;
class FinituraResource extends Resource
{
protected static ?string $model = Finitura::class;
protected static ?string $navigationIcon = 'heroicon-o-swatch';
protected static ?string $slug = 'finiture';
protected static ?string $navigationGroup = 'Configuratore';
protected static ?string $navigationLabel = 'Finiture e maniglie';
protected static ?string $modelLabel = 'finitura';
protected static ?string $pluralModelLabel = 'finiture';
protected static ?int $navigationSort = 20;
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make()
->columns(2)
->schema([
CampiConfiguratore::sistema(),
Forms\Components\Select::make('tipo')
->label('Tipo')
->options(Finitura::TIPI)
->default('profilo')
->required()
->live(),
Forms\Components\TextInput::make('codice')
->label('Codice')
->helperText('Identificativo inviato dal configuratore (es. 2, M1).')
->required()
->maxLength(50)
->unique(
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule, Get $get) => $rule
->where('sistema_id', $get('sistema_id'))
->where('tipo', $get('tipo')),
),
Forms\Components\TextInput::make('descrizione')->label('Descrizione')->required()->maxLength(255),
Forms\Components\TextInput::make('codice_mazzetta')->label('Codice mazzetta')->maxLength(50),
CampiConfiguratore::immagine('anteprima', 'Anteprima nel menu', 'finiture/anteprime'),
Forms\Components\Select::make('lato')
->label('Lato')
->helperText('Su quale lato del serramento si può scegliere. Si può cambiare per singolo modello dalla scheda del modello.')
->options(Finitura::LATI)
->default('entrambi')
->required()
->visible(fn (Get $get) => $get('tipo') === 'profilo'),
Forms\Components\Toggle::make('predefinita')
->label('Predefinita')
->helperText('Selezionata all\'apertura. Per le finiture del profilo: la stessa finitura predefinita su interno ed esterno è il "bianco" senza supplemento.')
->inline(false),
Forms\Components\Toggle::make('attivo')
->label('Attiva nel sistema')
->helperText('Disattiva: non compare nei modelli, salvo quelli che la attivano nella propria scheda.')
->default(true)
->inline(false),
]),
CampiConfiguratore::aspetto3d('finiture'),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('ordine')
->reorderable('ordine')
->defaultGroup('tipo')
->groups([
Tables\Grouping\Group::make('tipo')
->label('Tipo')
->getTitleFromRecordUsing(fn (Finitura $record) => Finitura::TIPI[$record->tipo] ?? $record->tipo),
])
->columns([
Tables\Columns\ImageColumn::make('anteprima')->label('Anteprima')->disk('public')->height(40),
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable(),
Tables\Columns\TextColumn::make('descrizione')->label('Descrizione')->searchable()->wrap(),
Tables\Columns\SelectColumn::make('lato')
->label('Lato')
->options(Finitura::LATI)
->selectablePlaceholder(false)
->disabled(fn (Finitura $record) => $record->tipo !== 'profilo'),
Tables\Columns\ColorColumn::make('colore')->label('Colore'),
Tables\Columns\IconColumn::make('texture')
->label('Texture')
->icon(fn (?string $state) => $state ? 'heroicon-o-photo' : null),
Tables\Columns\IconColumn::make('predefinita')->label('Predefinita')->boolean(),
Tables\Columns\ToggleColumn::make('attivo')->label('Attiva'),
])
->filters([
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
Tables\Filters\SelectFilter::make('tipo')->label('Tipo')->options(Finitura::TIPI),
Tables\Filters\SelectFilter::make('lato')->label('Lato')->options(Finitura::LATI),
])
->actions([
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()->iconButton(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('lato')
->label('Imposta lato')
->icon('heroicon-o-arrows-right-left')
->form([
Forms\Components\Select::make('lato')->label('Lato')->options(Finitura::LATI)->required(),
])
->action(fn (Collection $records, array $data) => $records
->where('tipo', 'profilo')
->each->update(['lato' => $data['lato']]))
->deselectRecordsAfterCompletion(),
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageFiniture::route('/'),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\FinituraResource\Pages;
use App\Filament\Resources\FinituraResource;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageFiniture extends ManageRecords
{
protected static string $resource = FinituraResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,105 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\GruppoOpzioniResource\Pages;
use App\Models\GruppoOpzioni;
use App\Models\Modello;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
/** Gruppi di opzioni del configuratore: un nuovo gruppo si crea qui, le sue voci in "Opzioni e supplementi". */
class GruppoOpzioniResource extends Resource
{
protected static ?string $model = GruppoOpzioni::class;
protected static ?string $navigationIcon = 'heroicon-o-queue-list';
protected static ?string $slug = 'gruppi-opzioni';
protected static ?string $navigationGroup = 'Configuratore';
protected static ?string $navigationLabel = 'Gruppi di opzioni';
protected static ?string $modelLabel = 'gruppo di opzioni';
protected static ?string $pluralModelLabel = 'gruppi di opzioni';
protected static ?int $navigationSort = 24;
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make()
->columns(2)
->schema([
Forms\Components\TextInput::make('codice')
->label('Codice')
->helperText('Chiave del gruppo nelle opzioni e nel preventivo (es. soglia_ribassata). Non modificarlo se il gruppo ha già opzioni.')
->required()
->alphaDash()
->maxLength(50)
->unique(ignoreRecord: true)
->live(onBlur: true)
->afterStateUpdated(fn (Forms\Set $set, Forms\Get $get, ?string $state) => blank($get('modulo')) ? $set('modulo', $state) : null),
Forms\Components\TextInput::make('etichetta')
->label('Titolo nel configuratore')
->required()
->maxLength(255),
Forms\Components\Select::make('ui')
->label('Controllo')
->options(GruppoOpzioni::UI)
->default('select')
->required(),
Forms\Components\Select::make('sezione')
->label('Sezione del menu')
->options(GruppoOpzioni::SEZIONI)
->default('accessori')
->required(),
Forms\Components\TextInput::make('modulo')
->label('Modulo')
->helperText('Il gruppo compare nei modelli che hanno questo modulo tra i "Moduli" (scheda del modello).')
->required()
->alphaDash()
->maxLength(50)
->notIn(array_keys(Modello::MODULI))
->validationMessages(['not_in' => 'È un modulo base: scegline uno dedicato al gruppo.']),
Forms\Components\TextInput::make('ordine')->label('Ordine')->numeric()->minValue(0)->default(0),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('ordine')
->reorderable('ordine')
->columns([
Tables\Columns\TextColumn::make('etichetta')->label('Titolo')->description(fn (GruppoOpzioni $record) => $record->codice)->searchable(['etichetta', 'codice']),
Tables\Columns\TextColumn::make('ui')->label('Controllo')->formatStateUsing(fn (string $state) => GruppoOpzioni::UI[$state] ?? $state)->wrap(),
Tables\Columns\TextColumn::make('sezione')->label('Sezione')->badge()->formatStateUsing(fn (string $state) => ucfirst($state)),
Tables\Columns\TextColumn::make('modulo')->label('Modulo')->color('gray'),
Tables\Columns\TextColumn::make('opzioni_count')->label('Opzioni')->counts('opzioni'),
Tables\Columns\TextColumn::make('modelli')
->label('Modelli')
->state(fn (GruppoOpzioni $record) => Modello::whereJsonContains('moduli', $record->modulo)->count()),
])
->actions([
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()
->iconButton()
->disabled(fn (GruppoOpzioni $record) => $record->codice === 'tipo_finitura' || $record->opzioni()->exists())
->tooltip(fn (GruppoOpzioni $record) => $record->opzioni()->exists() ? 'Elimina prima le opzioni del gruppo' : null),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageGruppiOpzioni::route('/'),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\GruppoOpzioniResource\Pages;
use App\Filament\Resources\GruppoOpzioniResource;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageGruppiOpzioni extends ManageRecords
{
protected static string $resource = GruppoOpzioniResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -3,7 +3,9 @@
namespace App\Filament\Resources;
use App\Filament\Resources\ModelloResource\Pages;
use App\Filament\Resources\ModelloResource\RelationManagers\FinitureRelationManager;
use App\Filament\Resources\ModelloResource\RelationManagers\ListinoRelationManager;
use App\Filament\Resources\ModelloResource\RelationManagers\OpzioniRelationManager;
use App\Models\Modello;
use Filament\Forms;
use Filament\Forms\Form;
@@ -11,6 +13,7 @@ use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rules\Unique;
class ModelloResource extends Resource
@@ -61,6 +64,11 @@ class ModelloResource extends Resource
Forms\Components\TextInput::make('descrizione')
->label('Descrizione')
->maxLength(255),
Forms\Components\TextInput::make('titolo')
->label('Titolo nel configuratore')
->helperText('Intestazione del menu e nome nel preventivo; se vuoto si usa la descrizione.')
->maxLength(255)
->columnSpanFull(),
Forms\Components\FileUpload::make('icona')
->label('Icona')
->image()
@@ -104,9 +112,20 @@ class ModelloResource extends Resource
->helperText('Se vuota si usa la cartella dati.'),
Forms\Components\CheckboxList::make('moduli')
->label('Moduli')
->options(Modello::MODULI)
->helperText('Sezioni del menu del configuratore e voci che entrano nel prezzo.')
->options(fn () => Modello::moduliDisponibili())
->columns(3)
->columnSpanFull(),
Forms\Components\Select::make('regola_vetro_id')
->label('Regola vetro')
->helperText('Geometria delle lastre per la variazione di prezzo del vetro. Vuota: nessuna variazione.')
->relationship(
'regolaVetro',
'descrizione',
modifyQueryUsing: fn (Builder $query, Get $get) => $query->where('sistema_id', $get('sistema_id'))->orderBy('codice'),
)
->getOptionLabelFromRecordUsing(fn ($record) => "{$record->codice} — {$record->descrizione}")
->preload(),
]),
]);
}
@@ -114,13 +133,24 @@ class ModelloResource extends Resource
public static function table(Table $table): Table
{
return $table
->defaultSort('ordine')
// Ordine del catalogo (quello del configuratore), con i disattivi in fondo.
->defaultSort(fn (Builder $query) => $query->orderByDesc('attivo')->orderBy('ordine'))
->reorderable('ordine')
->reorderRecordsTriggerAction(fn (Tables\Actions\Action $action, bool $isReordering) => $action
->button()
->icon($isReordering ? 'heroicon-m-check' : 'heroicon-m-arrows-up-down')
->label($isReordering ? 'Fine riordino' : 'Riordina'))
->defaultGroup('sistema.nome')
->columns([
Tables\Columns\ImageColumn::make('icona')->label('Icona')->disk('public')->height(48),
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable()->copyable(),
Tables\Columns\TextColumn::make('nome')->label('Nome')->searchable(),
// Nome a capo su due righe e codice sotto, nella stessa cella: evita lo
// scroll orizzontale (i codici, senza spazi, non possono andare a capo da soli).
Tables\Columns\TextColumn::make('nome')
->label('Nome / codice')
->description(fn (Modello $record) => $record->codice)
->searchable(['nome', 'codice'])
->wrap()
->extraAttributes(['style' => 'min-width: 11rem; max-width: 14rem; overflow-wrap: anywhere']),
Tables\Columns\TextColumn::make('descrizione')->label('Descrizione')->searchable()->wrap()->placeholder('—'),
Tables\Columns\TextColumn::make('categoria')
->label('Categoria')
@@ -131,7 +161,11 @@ class ModelloResource extends Resource
->label('Listino')
->counts('listino')
->formatStateUsing(fn (int $state) => $state ? "{$state} prezzi" : 'Nessuno')
->color(fn (int $state) => $state ? null : 'gray')
// Senza listino il modello non compare nel configuratore.
->badge(fn (int $state) => ! $state)
->color(fn (int $state) => $state ? null : 'danger')
->icon(fn (int $state) => $state ? null : 'heroicon-m-exclamation-triangle')
->tooltip(fn (int $state) => $state ? null : 'Senza listino: non compare nel configuratore')
->description(fn (Modello $record) => $record->listino_aggiornato_at?->format('d/m/Y'))
->sortable(),
Tables\Columns\ToggleColumn::make('attivo')
@@ -142,10 +176,19 @@ class ModelloResource extends Resource
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
Tables\Filters\SelectFilter::make('categoria')->label('Categoria')->options(Modello::CATEGORIE),
Tables\Filters\TernaryFilter::make('attivo')->label('Attivo'),
Tables\Filters\TernaryFilter::make('listino')
->label('Listino')
->trueLabel('Con listino')
->falseLabel('Senza listino (non nel configuratore)')
->queries(
true: fn (Builder $query) => $query->whereHas('listino'),
false: fn (Builder $query) => $query->whereDoesntHave('listino'),
),
])
// Solo icone (con tooltip): le etichette allargherebbero la tabella.
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()->iconButton(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
@@ -158,6 +201,8 @@ class ModelloResource extends Resource
{
return [
ListinoRelationManager::class,
FinitureRelationManager::class,
OpzioniRelationManager::class,
];
}
@@ -0,0 +1,142 @@
<?php
namespace App\Filament\Resources\ModelloResource\RelationManagers;
use App\Models\Finitura;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
/**
* Finiture del profilo del sistema e, per ciascuna, la scelta del modello: come il
* sistema (nessuna riga in finiture_modelli) oppure lato proprio o disattivata.
*/
class FinitureRelationManager extends RelationManager
{
protected static string $relationship = 'finitureDelSistema';
protected static ?string $title = 'Finiture';
protected static ?string $modelLabel = 'finitura';
protected static ?string $pluralModelLabel = 'finiture';
/** Scelte del modello per finitura_id, lette una volta per richiesta. */
protected ?\Illuminate\Support\Collection $scelte = null;
public function table(Table $table): Table
{
return $table
->description('Per ogni finitura del profilo scegli se nel modello vale l\'impostazione del sistema (lato e attivazione in "Finiture e maniglie") o una propria: un lato diverso, oppure disattivata. La scelta del modello può anche attivare una finitura disattiva nel sistema.')
->defaultSort('ordine')
->defaultPaginationPageOption(50)
->columns([
Tables\Columns\ImageColumn::make('anteprima')->label('Anteprima')->disk('public')->height(36),
Tables\Columns\TextColumn::make('descrizione')
->label('Finitura')
->description(fn (Finitura $record) => $record->codice)
->searchable(['descrizione', 'codice']),
Tables\Columns\TextColumn::make('sistema')
->label('Nel sistema')
->state(fn (Finitura $record) => $record->attivo ? Finitura::LATI[$record->lato] : 'Disattiva')
->color('gray'),
Tables\Columns\SelectColumn::make('scelta_modello')
->label('In questo modello')
->options(Finitura::LATI_MODELLO)
->placeholder('Come il sistema')
->getStateUsing(fn (Finitura $record) => $this->scelte()->get($record->id))
->updateStateUsing(function (Finitura $record, ?string $state) {
$this->salvaScelta([$record->id], $state);
return $state;
}),
Tables\Columns\TextColumn::make('effettiva')
->label('Risultato')
->state(function (Finitura $record) {
$lato = $this->scelte()->get($record->id) ?? ($record->attivo ? $record->lato : 'nessuno');
return $lato === 'nessuno' ? 'Non disponibile' : Finitura::LATI[$lato];
})
->badge()
->color(fn (string $state) => $state === 'Non disponibile' ? 'danger' : 'success'),
])
->filters([
Tables\Filters\TernaryFilter::make('personalizzata')
->label('Scelta del modello')
->placeholder('Tutte')
->trueLabel('Personalizzate')
->falseLabel('Come il sistema')
->queries(
true: fn ($query) => $query->whereIn('id', $this->scelte()->keys()),
false: fn ($query) => $query->whereNotIn('id', $this->scelte()->keys()),
),
])
->headerActions([
Tables\Actions\Action::make('ripristina')
->label('Tutte come il sistema')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->requiresConfirmation()
->modalDescription('Elimina le scelte del modello: per tutte le finiture torna a valere l\'impostazione del sistema.')
->visible(fn () => $this->scelte()->isNotEmpty())
->action(function () {
DB::table('finiture_modelli')->where('modello_id', $this->getOwnerRecord()->getKey())->delete();
$this->scelte = null;
Notification::make()->title('Finiture ripristinate come nel sistema')->success()->send();
}),
])
->bulkActions([
Tables\Actions\BulkAction::make('imposta')
->label('Imposta nel modello')
->icon('heroicon-o-adjustments-horizontal')
->form([
Forms\Components\Select::make('lato')
->label('In questo modello')
->options(['sistema' => 'Come il sistema'] + Finitura::LATI_MODELLO)
->required(),
])
->action(fn (Collection $records, array $data) => $this->salvaScelta(
$records->modelKeys(),
$data['lato'] === 'sistema' ? null : $data['lato'],
))
->deselectRecordsAfterCompletion(),
]);
}
public function isReadOnly(): bool
{
return false;
}
private function scelte(): \Illuminate\Support\Collection
{
return $this->scelte ??= DB::table('finiture_modelli')
->where('modello_id', $this->getOwnerRecord()->getKey())
->pluck('lato', 'finitura_id');
}
/** null = come il sistema (rimuove la scelta del modello). */
private function salvaScelta(array $finiture, ?string $lato): void
{
$modello = $this->getOwnerRecord()->getKey();
$righe = DB::table('finiture_modelli')->where('modello_id', $modello)->whereIn('finitura_id', $finiture);
if ($lato === null || $lato === '') {
$righe->delete();
} else {
abort_unless(array_key_exists($lato, Finitura::LATI_MODELLO), 422);
DB::table('finiture_modelli')->upsert(
array_map(fn ($id) => ['modello_id' => $modello, 'finitura_id' => $id, 'lato' => $lato, 'created_at' => now(), 'updated_at' => now()], $finiture),
['modello_id', 'finitura_id'],
['lato', 'updated_at'],
);
}
$this->scelte = null;
}
}
@@ -0,0 +1,130 @@
<?php
namespace App\Filament\Resources\ModelloResource\RelationManagers;
use App\Filament\Resources\OpzioneResource;
use App\Models\GruppoOpzioni;
use App\Models\Opzione;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Opzioni personalizzate del modello: se il modello ha almeno una riga in un gruppo,
* per quel modello il gruppo del sistema è sostituito per intero.
*/
class OpzioniRelationManager extends RelationManager
{
protected static string $relationship = 'opzioni';
protected static ?string $title = 'Opzioni personalizzate';
protected static ?string $modelLabel = 'opzione';
protected static ?string $pluralModelLabel = 'opzioni';
public function form(Form $form): Form
{
return $form->schema(OpzioneResource::campi(conSistema: false, modello: fn () => $this->getOwnerRecord()));
}
public function table(Table $table): Table
{
return OpzioneResource::tabella($table)
->description('I gruppi presenti qui sostituiscono, per questo modello, quelli definiti in "Opzioni e supplementi" del sistema (es. icone di apertura proprie o un supplemento diverso). Eliminando tutte le righe di un gruppo torna a valere quello del sistema.')
->headerActions([
Tables\Actions\Action::make('personalizza')
->label('Personalizza gruppo')
->icon('heroicon-o-document-duplicate')
->color('gray')
->modalDescription('Copia nel modello le opzioni del gruppo definite sul sistema, da modificare qui.')
->form([
Forms\Components\Select::make('gruppo')
->label('Gruppo')
->options(fn () => $this->gruppiPersonalizzabili())
->required(),
])
->action(function (array $data) {
$copiate = $this->copiaGruppo($data['gruppo']);
Notification::make()->title("Copiate {$copiate} opzioni dal sistema")->success()->send();
}),
Tables\Actions\CreateAction::make()
->mutateFormDataUsing(fn (array $data) => $this->conIconaDelSistema($data) + ['sistema_id' => $this->getOwnerRecord()->sistema_id]),
]);
}
public function isReadOnly(): bool
{
return false;
}
/** Gruppi del sistema non ancora personalizzati per il modello. */
private function gruppiPersonalizzabili(): array
{
$modello = $this->getOwnerRecord();
$personalizzati = $modello->opzioni()->distinct()->pluck('gruppo')->all();
return Opzione::query()
->where('sistema_id', $modello->sistema_id)
->whereNull('modello_id')
->whereNotIn('gruppo', $personalizzati)
->distinct()
->pluck('gruppo')
->mapWithKeys(fn (string $g) => [$g => GruppoOpzioni::where('codice', $g)->value('etichetta') ?? $g])
->all();
}
private function copiaGruppo(string $gruppo): int
{
$modello = $this->getOwnerRecord();
$righe = Opzione::query()
->where('sistema_id', $modello->sistema_id)
->whereNull('modello_id')
->where('gruppo', $gruppo)
->get();
DB::transaction(function () use ($righe, $modello) {
foreach ($righe as $riga) {
$copia = $riga->replicate(['icona']);
$copia->modello_id = $modello->getKey();
$copia->icona = $this->copiaIcona($riga);
$copia->save();
}
});
return $righe->count();
}
/** Nuova opzione con un valore del sistema e senza icona: prende una copia di quella del sistema. */
private function conIconaDelSistema(array $data): array
{
if (blank($data['icona'] ?? null)) {
$origine = OpzioneResource::catalogo($this->getOwnerRecord(), $data['gruppo'] ?? null)->firstWhere('valore', $data['valore'] ?? null);
$data['icona'] = $origine ? $this->copiaIcona($origine) : null;
}
return $data;
}
/** Icona duplicata: ogni record ha i suoi file (vedi HaImmaginiCatalogo). */
private function copiaIcona(Opzione $origine): ?string
{
$disco = Storage::disk('public');
if (! $origine->icona || ! $disco->exists($origine->icona)) {
return null;
}
$percorso = 'configuratore/opzioni/'.Str::random(20).'.'.pathinfo($origine->icona, PATHINFO_EXTENSION);
$disco->copy($origine->icona, $percorso);
return $percorso;
}
}
@@ -0,0 +1,267 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\OpzioneResource\Pages;
use App\Filament\Support\CampiConfiguratore;
use App\Models\GruppoOpzioni;
use App\Models\Modello;
use App\Models\Opzione;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rules\Unique;
/** Opzioni e supplementi del sistema (le personalizzazioni per modello sono nella scheda del modello). */
class OpzioneResource extends Resource
{
/** Voce "Altro" del valore nelle opzioni personalizzate: valore scritto a mano. */
public const ALTRO = '__altro';
protected static ?string $model = Opzione::class;
protected static ?string $navigationIcon = 'heroicon-o-adjustments-horizontal';
protected static ?string $slug = 'opzioni';
protected static ?string $navigationGroup = 'Configuratore';
protected static ?string $navigationLabel = 'Opzioni e supplementi';
protected static ?string $modelLabel = 'opzione';
protected static ?string $pluralModelLabel = 'opzioni';
protected static ?int $navigationSort = 23;
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->whereNull('modello_id');
}
public static function form(Form $form): Form
{
return $form->schema(self::campi(conSistema: true));
}
/**
* Campi dell'opzione; senza sistema (relation manager del modello) l'unicità è sul modello
* e il valore si sceglie tra quelli del gruppo sul sistema, o "Altro" per scriverlo a mano.
*
* @param \Closure(): Modello|null $modello
*/
public static function campi(bool $conSistema, ?\Closure $modello = null): array
{
$unicita = fn (Unique $rule, Get $get) => self::unicita($rule, $get, $modello);
$finitura = fn (Get $get) => $get('gruppo') === 'tipo_finitura';
return [
Forms\Components\Section::make()
->columns(2)
->schema(array_values(array_filter([
$conSistema ? CampiConfiguratore::sistema() : null,
Forms\Components\Select::make('gruppo')
->label('Gruppo')
->options(fn () => GruppoOpzioni::etichette())
->required()
->live()
->afterStateUpdated(function (Set $set) {
$set('valore', null);
$set('valore_altro', null);
}),
$modello ? null : Forms\Components\Select::make('valore')
->label('Valore')
->options(Opzione::TIPI_FINITURA)
->helperText('Il tipo finitura è calcolato dalle finiture interna ed esterna scelte.')
->visible($finitura)
->required()
->unique(ignoreRecord: true, modifyRuleUsing: $unicita),
$modello ? null : Forms\Components\TextInput::make('valore')
->label('Valore')
->helperText('Identificativo inviato dal configuratore (es. dx, telaio_l).')
->hidden($finitura)
->required()
->alphaDash()
->maxLength(100)
->unique(ignoreRecord: true, modifyRuleUsing: $unicita),
$modello ? Forms\Components\Select::make('valore')
->label('Valore')
->helperText(fn (Get $get) => $finitura($get)
? 'Il tipo finitura è calcolato dalle finiture interna ed esterna scelte.'
: 'Valori del gruppo definiti sul sistema: sceglierne uno ne precompila i dati. "Altro" per un valore nuovo.')
// Un solo campo per tutti i gruppi: con due campi "valore" Filament aggiorna solo il primo.
->options(fn (Get $get) => $finitura($get)
? Opzione::TIPI_FINITURA
: self::catalogo($modello(), $get('gruppo'))
->mapWithKeys(fn (Opzione $o) => [$o->valore => "{$o->etichetta} ({$o->valore})"])
->put(self::ALTRO, 'Altro…')
->all())
->required()
->live()
->unique(ignoreRecord: true, modifyRuleUsing: $unicita)
// Valore non presente sul sistema: in modifica compare come "Altro".
->afterStateHydrated(function (Forms\Components\Select $component, ?string $state, Get $get, Set $set) use ($modello) {
if (filled($state) && $get('gruppo') !== 'tipo_finitura'
&& ! self::catalogo($modello(), $get('gruppo'))->contains('valore', $state)) {
$component->state(self::ALTRO);
$set('valore_altro', $state);
}
})
->afterStateUpdated(fn (?string $state, Get $get, Set $set) => self::precompila($modello(), $get('gruppo'), $state, $set))
->dehydrateStateUsing(fn (?string $state, Get $get) => $state === self::ALTRO ? $get('valore_altro') : $state) : null,
$modello ? Forms\Components\TextInput::make('valore_altro')
->label('Nuovo valore')
->helperText('Identificativo inviato dal configuratore (es. dx, telaio_l).')
->visible(fn (Get $get) => $get('valore') === self::ALTRO)
->required()
->alphaDash()
->maxLength(100)
->notIn([self::ALTRO])
->unique(table: Opzione::class, column: 'valore', ignoreRecord: true, modifyRuleUsing: $unicita)
->dehydrated(false) : null,
Forms\Components\TextInput::make('etichetta')->label('Etichetta')->required()->maxLength(255),
Forms\Components\TextInput::make('alternativa')
->label('Alternativa a')
->helperText('Le opzioni del gruppo con lo stesso valore si escludono a vicenda (es. "coprifili": piatti o scatolati).')
->maxLength(50)
->visible(fn (Get $get) => GruppoOpzioni::where('codice', $get('gruppo'))->value('ui') === 'multipla'),
Forms\Components\Toggle::make('attivo')->label('Attiva')->default(true)->inline(false),
]))),
Forms\Components\Section::make('Supplemento')
->columns(2)
->schema([
Forms\Components\Select::make('supplemento_tipo')
->label('Tipo')
->options(Opzione::SUPPLEMENTI)
->placeholder('Nessun supplemento')
->live(),
Forms\Components\TextInput::make('supplemento')
->label('Valore')
->numeric()
->step(0.01)
->default(0)
->required()
->prefix(fn (Get $get) => in_array($get('supplemento_tipo'), ['fisso', 'metro_lineare'], true) ? '€' : null)
->suffix(fn (Get $get) => match ($get('supplemento_tipo')) {
'percentuale' => '%',
'metro_lineare' => '/ml',
default => null,
})
->visible(fn (Get $get) => filled($get('supplemento_tipo'))),
Forms\Components\Fieldset::make('Metri lineari')
->columns(3)
->visible(fn (Get $get) => $get('supplemento_tipo') === 'metro_lineare')
->schema([
Forms\Components\Placeholder::make('formula_ml')
->hiddenLabel()
->content('ml = (L × moltiplicatore L + H × moltiplicatore H + aggiunta) ÷ 1000, con L e H del serramento in mm. Es. "H + 100 mm, 2 pezzi": moltiplicatore H 2, aggiunta 200.')
->columnSpanFull(),
Forms\Components\TextInput::make('ml_per_larghezza')->label('Moltiplicatore L')->numeric()->step(0.001)->default(0)->required(),
Forms\Components\TextInput::make('ml_per_altezza')->label('Moltiplicatore H')->numeric()->step(0.001)->default(0)->required(),
Forms\Components\TextInput::make('ml_aggiunta_mm')->label('Aggiunta')->numeric()->step(1)->suffix('mm')->default(0)->required(),
]),
]),
Forms\Components\Section::make('Configuratore')
->columns(2)
->collapsible()
->hidden(fn (Get $get) => $get('gruppo') === 'tipo_finitura')
->schema([
CampiConfiguratore::immagine('icona', 'Icona', 'opzioni'),
Forms\Components\KeyValue::make('parametri')
->label('Parametri')
->helperText('Letti dal configuratore: scale_x (1 / -1) per l\'apertura, func e categoria per le select.')
->keyLabel('Nome')
->valueLabel('Valore'),
]),
];
}
public static function table(Table $table): Table
{
return self::tabella($table)
->filters([
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
Tables\Filters\SelectFilter::make('gruppo')->label('Gruppo')->options(fn () => GruppoOpzioni::etichette()),
]);
}
/** Tabella condivisa con le opzioni personalizzate del modello. */
public static function tabella(Table $table): Table
{
return $table
->defaultSort('ordine')
->reorderable('ordine')
->defaultGroup('gruppo')
->groups([
Tables\Grouping\Group::make('gruppo')
->label('Gruppo')
->getTitleFromRecordUsing(fn (Opzione $record) => $record->definizione?->etichetta ?? $record->gruppo),
])
->columns([
Tables\Columns\ImageColumn::make('icona')->label('Icona')->disk('public')->height(40),
Tables\Columns\TextColumn::make('etichetta')->label('Etichetta')->searchable(),
Tables\Columns\TextColumn::make('valore')->label('Valore')->searchable()->color('gray'),
Tables\Columns\TextColumn::make('alternativa')->label('Alternativa')->badge()->color('gray')->placeholder('—')->toggleable(),
Tables\Columns\TextColumn::make('supplemento')
->label('Supplemento')
->state(fn (Opzione $record) => $record->descriviSupplemento()),
Tables\Columns\ToggleColumn::make('attivo')->label('Attiva'),
])
->actions([
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()->iconButton(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageOpzioni::route('/'),
];
}
private static function unicita(Unique $rule, Get $get, ?\Closure $modello): Unique
{
$rule->where('gruppo', $get('gruppo'));
return $modello
? $rule->where('modello_id', $modello()->getKey())
: $rule->where('sistema_id', $get('sistema_id'))->whereNull('modello_id');
}
/** Opzioni del gruppo definite sul sistema del modello. */
public static function catalogo(Modello $modello, ?string $gruppo): Collection
{
return Opzione::query()
->where('sistema_id', $modello->sistema_id)
->whereNull('modello_id')
->where('gruppo', $gruppo)
->orderBy('ordine')
->get();
}
/** Copia nel form i dati dell'opzione del sistema scelta come valore. */
private static function precompila(Modello $modello, ?string $gruppo, ?string $valore, Set $set): void
{
$set('valore_altro', null);
$origine = self::catalogo($modello, $gruppo)->firstWhere('valore', $valore);
if (! $origine) {
return;
}
foreach (['etichetta', 'alternativa', 'supplemento_tipo', 'supplemento', 'ml_per_larghezza', 'ml_per_altezza', 'ml_aggiunta_mm', 'parametri'] as $campo) {
$set($campo, $origine->{$campo});
}
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\OpzioneResource\Pages;
use App\Filament\Resources\OpzioneResource;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageOpzioni extends ManageRecords
{
protected static string $resource = OpzioneResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,155 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RegolaVetroResource\Pages;
use App\Filament\Support\CampiConfiguratore;
use App\Models\RegolaVetro;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
use Illuminate\Validation\Rules\Unique;
class RegolaVetroResource extends Resource
{
protected static ?string $model = RegolaVetro::class;
protected static ?string $navigationIcon = 'heroicon-o-calculator';
protected static ?string $slug = 'regole-vetro';
protected static ?string $navigationGroup = 'Configuratore';
protected static ?string $navigationLabel = 'Regole vetro';
protected static ?string $modelLabel = 'regola vetro';
protected static ?string $pluralModelLabel = 'regole vetro';
protected static ?int $navigationSort = 22;
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make()
->columns(2)
->schema([
CampiConfiguratore::sistema(),
Forms\Components\TextInput::make('codice')
->label('Codice')
->helperText('Es. F2, TR_A_F.')
->required()
->maxLength(50)
->unique(
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('sistema_id', $get('sistema_id')),
),
Forms\Components\TextInput::make('descrizione')->label('Tipologia')->required()->maxLength(255)->columnSpanFull(),
]),
Forms\Components\Section::make('Lastre')
->description('Per ogni riga: larghezza vetro LV = (L − detrazione) ÷ divisore, altezza vetro HV = H − detrazione, ripetuta per la quantità. Es. L/2 − 181 si scrive detrazione 362, divisore 2.')
->schema([
Forms\Components\Repeater::make('lastre')
->hiddenLabel()
->columns(5)
->minItems(1)
->defaultItems(1)
->reorderable(false)
->live(onBlur: true)
->schema([
Forms\Components\TextInput::make('descrizione')->label('Descrizione')->placeholder('es. Vetro anta')->maxLength(100),
Forms\Components\TextInput::make('quantita')->label('Quantità')->integer()->minValue(1)->default(1)->required(),
Forms\Components\TextInput::make('divisore')->label('Divisore L')->integer()->minValue(1)->default(1)->required(),
Forms\Components\TextInput::make('detrazione_larghezza')->label('Detrazione L (mm)')->numeric()->default(0)->required(),
Forms\Components\TextInput::make('detrazione_altezza')->label('Detrazione H (mm)')->numeric()->default(0)->required(),
]),
]),
Forms\Components\Section::make('Prova')
->description('Verifica la regola su una misura: non viene salvata.')
->columns(3)
->collapsible()
->schema([
Forms\Components\TextInput::make('prova_larghezza')->label('Larghezza L (mm)')->integer()->default(1200)->dehydrated(false)->live(onBlur: true),
Forms\Components\TextInput::make('prova_altezza')->label('Altezza H (mm)')->integer()->default(1400)->dehydrated(false)->live(onBlur: true),
Forms\Components\Placeholder::make('prova_risultato')
->label('Lastre risultanti')
->content(fn (Get $get) => self::anteprima($get('lastre') ?? [], (float) $get('prova_larghezza'), (float) $get('prova_altezza'))),
]),
Forms\Components\Textarea::make('note')->label('Note')->rows(2)->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('codice')
->columns([
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable()->sortable(),
Tables\Columns\TextColumn::make('descrizione')->label('Tipologia')->searchable()->wrap(),
Tables\Columns\TextColumn::make('lastre')
->label('Formula')
->state(fn (RegolaVetro $record) => self::formula($record->lastre ?? []))
->html(),
Tables\Columns\TextColumn::make('modelli_count')->label('Modelli')->counts('modelli'),
])
->filters([
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
])
->actions([
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()
->iconButton()
->modalDescription('I modelli che usano questa regola non avranno più variazione di prezzo per il vetro.'),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageRegoleVetro::route('/'),
];
}
/** Formula leggibile, una riga per lastra (es. "2 × (L − 312) ÷ 2 × (H − 189)"). */
private static function formula(array $lastre): HtmlString
{
$righe = array_map(function (array $l) {
$larghezza = 'L'.self::meno($l['detrazione_larghezza'] ?? 0);
if ((int) ($l['divisore'] ?? 1) > 1) {
$larghezza = "({$larghezza}) ÷ {$l['divisore']}";
}
$descrizione = filled($l['descrizione'] ?? null) ? e($l['descrizione']).': ' : '';
return $descrizione.((int) ($l['quantita'] ?? 1)).' × '.$larghezza.' × (H'.self::meno($l['detrazione_altezza'] ?? 0).')';
}, $lastre);
return new HtmlString(implode('<br>', $righe));
}
private static function meno(mixed $detrazione): string
{
return (float) $detrazione ? ' − '.(float) $detrazione : '';
}
private static function anteprima(array $lastre, float $larghezza, float $altezza): HtmlString
{
if ($larghezza <= 0 || $altezza <= 0 || ! $lastre) {
return new HtmlString('—');
}
$calcolate = (new RegolaVetro(['lastre' => array_values($lastre)]))->calcolaLastre($larghezza, $altezza);
return new HtmlString(implode('<br>', array_map(fn (array $l) => sprintf(
'%s%d × %s × %s mm = %s m² cad.',
filled($l['descrizione']) ? e($l['descrizione']).': ' : '',
$l['quantita'],
number_format($l['larghezza'], 1, ',', '.'),
number_format($l['altezza'], 1, ',', '.'),
number_format(max(0, $l['larghezza']) * max(0, $l['altezza']) / 1_000_000, 3, ',', '.'),
), $calcolate)));
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\RegolaVetroResource\Pages;
use App\Filament\Resources\RegolaVetroResource;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageRegoleVetro extends ManageRecords
{
protected static string $resource = RegolaVetroResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\VetroResource\Pages;
use App\Filament\Support\CampiConfiguratore;
use App\Models\Vetro;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Validation\Rules\Unique;
class VetroResource extends Resource
{
protected static ?string $model = Vetro::class;
protected static ?string $navigationIcon = 'heroicon-o-square-2-stack';
protected static ?string $slug = 'vetri';
protected static ?string $navigationGroup = 'Configuratore';
protected static ?string $navigationLabel = 'Vetri e riempimenti';
protected static ?string $modelLabel = 'vetro';
protected static ?string $pluralModelLabel = 'vetri';
protected static ?int $navigationSort = 21;
public static function form(Form $form): Form
{
return $form->schema([
Forms\Components\Section::make()
->columns(2)
->schema([
CampiConfiguratore::sistema(),
Forms\Components\TextInput::make('codice')
->label('Codice')
->helperText('Identificativo inviato dal configuratore (es. V3).')
->required()
->maxLength(50)
->unique(
ignoreRecord: true,
modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('sistema_id', $get('sistema_id')),
),
Forms\Components\TextInput::make('descrizione')->label('Descrizione')->required()->maxLength(255)->columnSpanFull(),
Forms\Components\Toggle::make('predefinito')->label('Predefinito')->helperText('Selezionato all\'apertura del configuratore.')->inline(false),
Forms\Components\Toggle::make('attivo')->label('Attivo')->default(true)->inline(false),
]),
Forms\Components\Section::make('Prezzo')
->description('Variazione = somma per lastra di max(area, minimo fatturabile) x maggiorazione. Le lastre sono calcolate dalla regola vetro del modello.')
->columns(2)
->schema([
Forms\Components\TextInput::make('maggiorazione_mq')
->label('Maggiorazione')
->helperText('Negativa per una decurtazione (es. infisso senza vetro).')
->numeric()
->step(0.01)
->prefix('€')
->suffix('/m²')
->default(0)
->required(),
Forms\Components\TextInput::make('minimo_mq')
->label('Minimo fatturabile per lastra')
->numeric()
->minValue(0)
->step(0.001)
->suffix('m²')
->default(0)
->required(),
]),
Forms\Components\Section::make('Immagini')
->columns(2)
->schema([
CampiConfiguratore::immagine('anteprima', 'Icona nel menu', 'vetri'),
CampiConfiguratore::immagine('popup', 'Immagine informativa (popup)', 'vetri'),
]),
CampiConfiguratore::aspetto3d('vetri', opacita: true),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('ordine')
->reorderable('ordine')
->columns([
Tables\Columns\ImageColumn::make('anteprima')->label('Icona')->disk('public')->height(40),
Tables\Columns\TextColumn::make('codice')->label('Codice')->searchable(),
Tables\Columns\TextColumn::make('descrizione')->label('Descrizione')->searchable()->wrap(),
Tables\Columns\TextColumn::make('maggiorazione_mq')
->label('Maggiorazione')
->formatStateUsing(fn ($state) => '€ '.number_format((float) $state, 2, ',', '.').'/m²')
->color(fn ($state) => $state < 0 ? 'danger' : null)
->sortable(),
Tables\Columns\TextColumn::make('minimo_mq')
->label('Minimo')
->formatStateUsing(fn ($state) => number_format((float) $state, 2, ',', '.').' m²'),
Tables\Columns\IconColumn::make('predefinito')->label('Predefinito')->boolean(),
Tables\Columns\ToggleColumn::make('attivo')->label('Attivo'),
])
->filters([
Tables\Filters\SelectFilter::make('sistema')->label('Sistema')->relationship('sistema', 'nome'),
])
->actions([
Tables\Actions\EditAction::make()->iconButton(),
Tables\Actions\DeleteAction::make()->iconButton(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ManageVetri::route('/'),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\VetroResource\Pages;
use App\Filament\Resources\VetroResource;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageVetri extends ManageRecords
{
protected static string $resource = VetroResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Filament\Support;
use App\Models\Sistema;
use Filament\Forms;
/** Campi condivisi dalle risorse del catalogo del configuratore. */
class CampiConfiguratore
{
public static function sistema(): Forms\Components\Select
{
return Forms\Components\Select::make('sistema_id')
->label('Sistema')
->relationship('sistema', 'nome')
->default(fn () => Sistema::orderBy('ordine')->value('id'))
->required()
->preload()
->live();
}
public static function immagine(string $campo, string $etichetta, string $cartella): Forms\Components\FileUpload
{
return Forms\Components\FileUpload::make($campo)
->label($etichetta)
->image()
->disk('public')
->directory("configuratore/{$cartella}")
->maxSize(10240);
}
/** Proprietà del materiale Three.js applicate alle mesh della categoria. */
public static function aspetto3d(string $cartella, bool $opacita = false): Forms\Components\Section
{
return Forms\Components\Section::make('Aspetto 3D')
->description('Proprietà del materiale applicate al modello 3D. I campi vuoti lasciano il valore del modello.')
->columns(3)
->collapsible()
->schema(array_values(array_filter([
Forms\Components\ColorPicker::make('colore')->label('Colore'),
$cartella === 'finiture'
? Forms\Components\ColorPicker::make('colore_corpo_base')->label('Colore corpo base')
: null,
Forms\Components\TextInput::make('roughness')->label('Roughness')->numeric()->minValue(0)->maxValue(1)->step(0.01),
Forms\Components\TextInput::make('metalness')->label('Metalness')->numeric()->minValue(0)->maxValue(1)->step(0.01),
$opacita
? Forms\Components\TextInput::make('opacita')->label('Opacità')->numeric()->minValue(0)->maxValue(1)->step(0.01)
: null,
Forms\Components\TextInput::make('bump_scala')->label('Intensità rilievo (bump)')->numeric()->step(0.1),
self::immagine('texture', 'Texture', "{$cartella}/texture"),
self::immagine('texture_bump', 'Texture rilievo (bump)', "{$cartella}/texture"),
])));
}
}
@@ -9,6 +9,7 @@ use App\Models\Cliente;
use App\Models\Preventivo;
use App\Models\PreventivoItem;
use App\Support\ConfigurazioneFormatter;
use App\Support\Configuratore\CalcolatorePrezzo;
use App\Support\PreventivoPdf;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -21,39 +22,57 @@ use Illuminate\Support\Str;
class CarrelloController extends Controller
{
/**
* POST /api/carrello/aggiungi
* Aggiunge un prodotto configurato al carrello (preventivo in stato bozza).
* Voci descrittive del configuratore che non incidono sul prezzo: sono le uniche
* accettate dal payload del client, tutto il resto lo ricostruisce il server.
*/
public function aggiungi(Request $request): JsonResponse
private const VOCI_DESCRITTIVE = ['altezza_maniglia', 'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto'];
/**
* POST /api/carrello/aggiungi
* Aggiunge un prodotto configurato al carrello (preventivo in stato bozza). Il prezzo è
* ricalcolato dalle scelte con le regole del backoffice: quello del client è ignorato.
*/
public function aggiungi(Request $request, CalcolatorePrezzo $calcolatore): JsonResponse
{
$data = $request->validate([
'token' => ['nullable', 'string', 'max:64'],
'sistema' => ['nullable', 'string', 'max:50'],
'type' => ['nullable', 'string', 'max:100'],
'nome' => ['nullable', 'string', 'max:255'],
'larghezza' => ['nullable', 'integer', 'min:0'],
'altezza' => ['nullable', 'integer', 'min:0'],
'sistema' => ['required', 'string', 'max:50'],
'type' => ['required', 'string', 'max:100'],
'qty' => ['nullable', 'integer', 'min:1', 'max:999'],
'prezzo' => ['required', 'numeric', 'min:0'],
'scelte' => ['required', 'array'],
...CatalogoController::regoleScelte('scelte.'),
'payload' => ['nullable', 'array'],
'img' => ['nullable', 'string'],
]);
$modello = CatalogoController::trovaModello($data['sistema'], $data['type']);
$calcolo = $calcolatore->calcola($modello, $data['scelte']);
$descrittive = collect($data['payload'] ?? [])
->only(self::VOCI_DESCRITTIVE)
->filter(fn ($v) => is_scalar($v) && $v !== '')
->map(fn ($v) => mb_substr((string) $v, 0, 255))
->all();
$preventivo = $this->risolviCarrello($data['token'] ?? null);
$qty = $data['qty'] ?? 1;
$prezzoUnitario = round((float) $data['prezzo'], 2);
$prezzoUnitario = $calcolo['prezzo'];
$configurazione = $calcolo['configurazione'];
$item = new PreventivoItem([
'sistema' => $data['sistema'] ?? null,
'type' => $data['type'] ?? null,
'nome_modello' => $data['nome'] ?? ($data['payload']['nome'] ?? null),
'larghezza' => $data['larghezza'] ?? ($data['payload']['width'] ?? null),
'altezza' => $data['altezza'] ?? ($data['payload']['height'] ?? null),
'sistema' => $modello->sistema->codice,
'type' => $modello->codice,
'nome_modello' => $configurazione['nome'],
'larghezza' => $configurazione['width'],
'altezza' => $configurazione['height'],
'quantita' => $qty,
'prezzo_unitario' => $prezzoUnitario,
'prezzo_totale' => round($prezzoUnitario * $qty, 2),
'payload' => $data['payload'] ?? [],
'payload' => $descrittive + $configurazione + [
'scelte' => $calcolo['scelte'],
'dettaglio_prezzo' => $calcolo['dettaglio'],
],
'immagine_path' => $this->salvaImmagine($data['img'] ?? null),
]);
@@ -5,18 +5,21 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Modello;
use App\Models\Sistema;
use App\Support\Configuratore\CalcolatorePrezzo;
use App\Support\Configuratore\ConfigurazioneModello;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CatalogoController extends Controller
{
/**
* GET /api/sistemi
* Sistemi attivi con i rispettivi modelli attivi, nell'ordine del menu.
* Sistemi attivi con i rispettivi modelli attivi e con listino, nell'ordine del menu.
*/
public function sistemi(): JsonResponse
{
$sistemi = Sistema::attivi()
->with(['modelli' => fn ($q) => $q->attivi()])
->with(['modelli' => fn ($q) => $q->nelConfiguratore()])
->get();
return response()->json($sistemi->map(fn (Sistema $s) => $this->sistema($s))->values());
@@ -29,7 +32,7 @@ class CatalogoController extends Controller
{
$sistema = Sistema::attivi()
->where('codice', $codice)
->with(['modelli' => fn ($q) => $q->attivi()])
->with(['modelli' => fn ($q) => $q->nelConfiguratore()])
->firstOrFail();
return response()->json($this->sistema($sistema));
@@ -41,12 +44,7 @@ class CatalogoController extends Controller
*/
public function listino(string $codice, string $modello): JsonResponse
{
$modello = Modello::attivi()
->where('codice', $modello)
->whereHas('sistema', fn ($q) => $q->where('codice', $codice)->where('attivo', true))
->firstOrFail();
$prezzi = $modello->listino()
$prezzi = $this->trovaModello($codice, $modello)->listino()
->orderBy('altezza')
->orderBy('larghezza')
->get(['altezza', 'larghezza', 'prezzo'])
@@ -55,6 +53,57 @@ class CatalogoController extends Controller
return response()->json($prezzi);
}
/**
* GET /api/sistemi/{codice}/modelli/{modello}/configurazione
* Menu, finiture, vetri, opzioni e misure del modello per il configuratore.
*/
public function configurazione(string $codice, string $modello, ConfigurazioneModello $configurazione): JsonResponse
{
return response()->json($configurazione->perModello($this->trovaModello($codice, $modello)));
}
/**
* POST /api/sistemi/{codice}/modelli/{modello}/prezzo
* Prezzo (IVA esclusa) della configurazione scelta; 422 se la misura è fuori listino.
*/
public function prezzo(Request $request, string $codice, string $modello, CalcolatorePrezzo $calcolatore): JsonResponse
{
$scelte = $request->validate(self::regoleScelte());
return response()->json($calcolatore->calcola($this->trovaModello($codice, $modello), $scelte));
}
/** Validazione delle scelte del configuratore, condivisa con il carrello. */
public static function regoleScelte(string $prefisso = ''): array
{
return [
"{$prefisso}larghezza" => ['required', 'integer', 'min:1', 'max:100000'],
"{$prefisso}altezza" => ['required', 'integer', 'min:1', 'max:100000'],
"{$prefisso}finitura_interna" => ['nullable', 'string', 'max:50'],
"{$prefisso}finitura_esterna" => ['nullable', 'string', 'max:50'],
"{$prefisso}maniglia" => ['nullable', 'string', 'max:50'],
"{$prefisso}vetro" => ['nullable', 'string', 'max:50'],
"{$prefisso}opzioni" => ['nullable', 'array'],
// Un codice per i gruppi a scelta singola, una lista di codici per quelli multipli.
"{$prefisso}opzioni.*" => ['nullable', function (string $campo, mixed $valore, \Closure $errore) {
$codici = is_array($valore) ? $valore : [$valore];
if (count($codici) > 50 || collect($codici)->contains(fn ($c) => ! is_string($c) || mb_strlen($c) > 100)) {
$errore('Scelta non valida.');
}
}],
];
}
/** Modello attivo e con listino di un sistema attivo, cercato per codici (404 altrimenti). */
public static function trovaModello(string $codice, string $modello): Modello
{
return Modello::nelConfiguratore()
->where('codice', $modello)
->whereHas('sistema', fn ($q) => $q->where('codice', $codice)->where('attivo', true))
->with('sistema', 'regolaVetro')
->firstOrFail();
}
private function sistema(Sistema $sistema): array
{
return [
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Support\Configuratore\Media;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class MediaController extends Controller
{
/**
* GET /api/media/{percorso}
* Immagini del configuratore dal disco public, con gli header CORS dell'API (necessari
* alle texture WebGL caricate da un'altra origine).
*/
public function __invoke(string $percorso): BinaryFileResponse
{
$disco = Storage::disk('public');
abort_if(
str_contains($percorso, '..')
|| ! Str::startsWith($percorso, Media::CARTELLE)
|| ! $disco->exists($percorso),
404
);
return response()->file($disco->path($percorso), [
'Cache-Control' => 'public, max-age=86400',
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Facades\Storage;
/**
* Immagini del configuratore salvate sul disco public: rimuove i file sostituiti o del
* record eliminato. Ogni record ha percorsi propri, quindi nessun file è condiviso.
*
* Il modello dichiara le colonne con `protected static array $immagini = [...]`.
*/
trait HaImmaginiCatalogo
{
protected static function bootHaImmaginiCatalogo(): void
{
static::updated(function ($record): void {
foreach (static::$immagini as $colonna) {
$vecchia = $record->getOriginal($colonna);
if ($record->wasChanged($colonna) && $vecchia) {
Storage::disk('public')->delete($vecchia);
}
}
});
static::deleted(function ($record): void {
$file = array_filter(array_map(fn ($c) => $record->{$c}, static::$immagini));
Storage::disk('public')->delete($file);
});
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Models;
use App\Models\Concerns\HaImmaginiCatalogo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class Finitura extends Model
{
use HaImmaginiCatalogo;
protected $table = 'finiture';
public const TIPI = [
'profilo' => 'Finitura profilo',
'maniglia' => 'Maniglia e cerniere',
];
/** Lati del serramento su cui la finitura del profilo è disponibile. */
public const LATI = [
'entrambi' => 'Interno ed esterno',
'interno' => 'Solo interno',
'esterno' => 'Solo esterno',
];
/** Scelta per modello: oltre ai lati, "nessuno" la disattiva nel modello. */
public const LATI_MODELLO = self::LATI + ['nessuno' => 'Disattiva nel modello'];
protected static array $immagini = ['anteprima', 'texture', 'texture_bump'];
protected $fillable = [
'sistema_id', 'tipo', 'lato', 'codice', 'descrizione', 'codice_mazzetta', 'colore', 'colore_corpo_base',
'roughness', 'metalness', 'anteprima', 'texture', 'texture_bump', 'bump_scala',
'predefinita', 'ordine', 'attivo',
];
protected $casts = [
'roughness' => 'float',
'metalness' => 'float',
'bump_scala' => 'float',
'predefinita' => 'boolean',
'ordine' => 'integer',
'attivo' => 'boolean',
];
public function sistema(): BelongsTo
{
return $this->belongsTo(Sistema::class);
}
public function scopeAttive(Builder $query): Builder
{
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
}
/**
* Finiture del profilo disponibili nel modello, ciascuna con `lato_modello`
* (entrambi | interno | esterno). La scelta del modello, se c'è, vince su attivazione
* e lato del sistema: può anche attivare una finitura disattiva nel sistema.
*
* @return Collection<int,Finitura>
*/
public static function delProfiloPerModello(Modello $modello): Collection
{
$scelte = DB::table('finiture_modelli')->where('modello_id', $modello->getKey())->pluck('lato', 'finitura_id');
return static::query()
->where('sistema_id', $modello->sistema_id)
->where('tipo', 'profilo')
->orderBy('ordine')
->orderBy('id')
->get()
->each(fn (Finitura $f) => $f->lato_modello = $scelte[$f->id] ?? ($f->attivo ? $f->lato : 'nessuno'))
->reject(fn (Finitura $f) => $f->lato_modello === 'nessuno')
->values();
}
public function disponibileSu(string $lato): bool
{
return in_array($this->lato_modello ?? $this->lato, ['entrambi', $lato], true);
}
protected static function booted(): void
{
// Un solo elemento predefinito per sistema e tipo.
static::saved(function (self $record): void {
if ($record->predefinita && ($record->wasRecentlyCreated || $record->wasChanged('predefinita'))) {
static::query()
->where('sistema_id', $record->sistema_id)->where('tipo', $record->tipo)
->whereKeyNot($record->getKey())
->update(['predefinita' => false]);
}
});
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* Gruppo di opzioni del configuratore (es. telaio, profili extra, motore): come si
* presenta nel menu e quale modulo del modello lo rende visibile.
*/
class GruppoOpzioni extends Model
{
protected $table = 'gruppi_opzioni';
public const UI = [
'radio' => 'Pulsanti con icona, una scelta',
'multipla' => 'Pulsanti con icona, più scelte (anche nessuna)',
'select' => 'Menu a tendina',
'nessuna' => 'Nessuno: solo prezzo (es. tipo finitura)',
];
public const SEZIONI = [
'sistema' => 'Sistema (telaio, apertura...)',
'accessori' => 'Accessori (maniglia, cerniere...)',
];
protected $fillable = ['codice', 'etichetta', 'ui', 'sezione', 'modulo', 'ordine'];
protected $casts = ['ordine' => 'integer'];
public function opzioni(): HasMany
{
return $this->hasMany(Opzione::class, 'gruppo', 'codice');
}
/** @return Collection<string,GruppoOpzioni> per codice, nell'ordine del menu */
public static function perCodice(): Collection
{
return static::query()->orderBy('ordine')->orderBy('id')->get()->keyBy('codice');
}
/** @return array<string,string> codice => etichetta */
public static function etichette(): array
{
return static::query()->orderBy('ordine')->orderBy('id')->pluck('etichetta', 'codice')->all();
}
public function haControllo(): bool
{
return $this->ui !== 'nessuna';
}
}
+74 -5
View File
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class Modello extends Model
@@ -21,23 +22,23 @@ class Modello extends Model
'accessori' => 'Accessori',
];
// Moduli dati caricati dal configuratore per il modello.
// Moduli base del configuratore (sezioni del menu e voci di prezzo); quelli dei gruppi
// di opzioni si aggiungono da GruppoOpzioni (vedi moduliDisponibili).
public const MODULI = [
'listino' => 'Listino',
'modello' => 'Finiture',
'finitura_esterna' => 'Finitura esterna (bicolore)',
'maniglia' => 'Maniglia',
'altezza_maniglia' => 'Altezza maniglia',
'vetro' => 'Vetro',
'cerniere' => 'Cerniere',
'telaio' => 'Telaio',
'avvolgibile' => 'Avvolgibile',
'select_finiture' => 'Select finiture',
];
protected $fillable = [
'sistema_id', 'codice', 'nome', 'descrizione', 'icona', 'ordine', 'attivo',
'sistema_id', 'codice', 'nome', 'descrizione', 'titolo', 'icona', 'ordine', 'attivo',
'cartella', 'file_modello', 'categoria', 'cartella_texture', 'moduli',
'listino_aggiornato_at',
'regola_vetro_id', 'listino_aggiornato_at',
];
protected $casts = [
@@ -57,18 +58,86 @@ class Modello extends Model
return $this->hasMany(ListinoPrezzo::class);
}
public function regolaVetro(): BelongsTo
{
return $this->belongsTo(RegolaVetro::class);
}
/** Opzioni personalizzate del modello (sostituiscono i gruppi del sistema). */
public function opzioni(): HasMany
{
return $this->hasMany(Opzione::class);
}
/** Tutte le finiture del profilo del sistema (per scegliere quali usare nel modello). */
public function finitureDelSistema(): HasMany
{
return $this->hasMany(Finitura::class, 'sistema_id', 'sistema_id')->where('tipo', 'profilo');
}
/** Moduli selezionabili: quelli base più uno per ogni gruppo di opzioni con un controllo. */
public static function moduliDisponibili(): array
{
$gruppi = GruppoOpzioni::query()
->where('ui', '!=', 'nessuna')
->orderBy('ordine')
->pluck('etichetta', 'modulo')
->all();
return self::MODULI + $gruppi;
}
public function haModulo(string $modulo): bool
{
return in_array($modulo, $this->moduli ?? [], true);
}
public function scopeAttivi(Builder $query): Builder
{
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
}
/** Modelli mostrati nel configuratore: attivi e con un listino (senza prezzi non si possono vendere). */
public function scopeNelConfiguratore(Builder $query): Builder
{
return $query->attivi()->whereHas('listino');
}
public function getIconaUrlAttribute(): ?string
{
return $this->icona ? Storage::disk('public')->url($this->icona) : null;
}
/**
* Rinumera l'ordine dei modelli del sistema mettendo i disattivi in fondo,
* senza cambiare l'ordine relativo all'interno dei due gruppi.
*/
public static function normalizzaOrdine(int $sistemaId): void
{
$ids = static::query()
->where('sistema_id', $sistemaId)
->orderByDesc('attivo')
->orderBy('ordine')
->orderBy('id')
->pluck('id');
DB::transaction(function () use ($ids) {
foreach ($ids as $i => $id) {
// Query diretta: niente eventi del modello (e niente ricorsione).
static::query()->whereKey($id)->update(['ordine' => $i + 1]);
}
});
}
protected static function booted(): void
{
// Attivando o disattivando un modello (o creandone uno), i disattivi tornano in fondo.
static::saved(function (Modello $modello): void {
if ($modello->wasRecentlyCreated || $modello->wasChanged(['attivo', 'sistema_id'])) {
static::normalizzaOrdine($modello->sistema_id);
}
});
// Rimuove il file dell'icona sostituita o del modello eliminato.
static::updated(function (Modello $modello): void {
$vecchia = $modello->getOriginal('icona');
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace App\Models;
use App\Models\Concerns\HaImmaginiCatalogo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
/**
* Opzione del configuratore con eventuale supplemento di prezzo. Le righe senza modello
* valgono per tutto il sistema; se un modello ha righe proprie per un gruppo, per quel
* modello sostituiscono l'intero gruppo del sistema.
*/
class Opzione extends Model
{
use HaImmaginiCatalogo;
protected $table = 'opzioni';
/** Valori del gruppo tipo_finitura, derivati dalle finiture scelte (vedi CalcolatorePrezzo). */
public const TIPI_FINITURA = [
'bianco' => 'Bianco (finitura predefinita su interno ed esterno)',
'uniforme' => 'Uniforme (stessa finitura su interno ed esterno)',
'bicolore' => 'Bicolore (finiture diverse)',
];
public const SUPPLEMENTI = [
'percentuale' => 'Percentuale sul prezzo base',
'fisso' => 'Importo fisso (€)',
'metro_lineare' => 'Al metro lineare (€/ml)',
];
protected static array $immagini = ['icona'];
protected $fillable = [
'sistema_id', 'modello_id', 'gruppo', 'valore', 'etichetta', 'icona', 'alternativa',
'supplemento_tipo', 'supplemento', 'ml_per_larghezza', 'ml_per_altezza', 'ml_aggiunta_mm',
'parametri', 'ordine', 'attivo',
];
protected $casts = [
'supplemento' => 'float',
'ml_per_larghezza' => 'float',
'ml_per_altezza' => 'float',
'ml_aggiunta_mm' => 'float',
'parametri' => 'array',
'ordine' => 'integer',
'attivo' => 'boolean',
];
public function sistema(): BelongsTo
{
return $this->belongsTo(Sistema::class);
}
public function modello(): BelongsTo
{
return $this->belongsTo(Modello::class);
}
public function definizione(): BelongsTo
{
return $this->belongsTo(GruppoOpzioni::class, 'gruppo', 'codice');
}
public function scopeDelSistema(Builder $query): Builder
{
return $query->whereNull('modello_id');
}
/**
* Opzioni attive valide per il modello, per gruppo: quelle del modello dove ne ha,
* altrimenti quelle del sistema.
*
* @return Collection<string,Collection<int,Opzione>>
*/
public static function perModello(Modello $modello): Collection
{
$righe = static::query()
->where('sistema_id', $modello->sistema_id)
->where(fn (Builder $q) => $q->whereNull('modello_id')->orWhere('modello_id', $modello->getKey()))
->orderBy('ordine')
->orderBy('id')
->get();
// Basta una riga del modello (anche disattiva) per sostituire il gruppo.
$personalizzati = $righe->whereNotNull('modello_id')->pluck('gruppo')->unique();
return $righe
->filter(fn (Opzione $o) => $o->attivo && ($o->modello_id !== null) === $personalizzati->contains($o->gruppo))
->groupBy('gruppo');
}
public function descriviSupplemento(): string
{
if (! $this->supplemento_tipo || ! $this->supplemento) {
return '—';
}
$valore = number_format($this->supplemento, 2, ',', '.');
return match ($this->supplemento_tipo) {
'percentuale' => "{$valore}%",
'metro_lineare' => "€ {$valore}/ml × ".$this->formulaMetri(),
default => "€ {$valore}",
};
}
/** Metri lineari per un serramento L x H (mm), senza arrotondamenti. */
public function metriLineari(float $larghezza, float $altezza): float
{
return max(0, $larghezza * $this->ml_per_larghezza + $altezza * $this->ml_per_altezza + $this->ml_aggiunta_mm) / 1000;
}
/** Importo del supplemento sul prezzo base, per un serramento L x H (mm). */
public function importo(float $base, float $larghezza, float $altezza): float
{
return match ($this->supplemento_tipo) {
'percentuale' => $base * $this->supplemento / 100,
'metro_lineare' => $this->supplemento * $this->metriLineari($larghezza, $altezza),
'fisso' => $this->supplemento,
default => 0.0,
};
}
/** Es. "(H x 2 + 200 mm)". */
public function formulaMetri(): string
{
$termini = array_filter([
$this->ml_per_larghezza ? 'L'.($this->ml_per_larghezza != 1 ? ' × '.(float) $this->ml_per_larghezza : '') : null,
$this->ml_per_altezza ? 'H'.($this->ml_per_altezza != 1 ? ' × '.(float) $this->ml_per_altezza : '') : null,
$this->ml_aggiunta_mm ? (float) $this->ml_aggiunta_mm.' mm' : null,
]);
return '('.(implode(' + ', $termini) ?: '0').')';
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Geometria delle lastre di una tipologia: per ogni riga
* LV = (L - detrazione_larghezza) / divisore, HV = H - detrazione_altezza, ripetuta `quantita` volte.
* Es. F2: 2 x (L - 312) / 2; traslante anta + fisso: (L - 162) / 2 e (L - 362) / 2, cioè L/2 - 81 e L/2 - 181.
*/
class RegolaVetro extends Model
{
protected $table = 'regole_vetro';
protected $fillable = [
'sistema_id', 'codice', 'descrizione', 'lastre', 'note',
];
protected $casts = [
'lastre' => 'array',
];
public function sistema(): BelongsTo
{
return $this->belongsTo(Sistema::class);
}
public function modelli(): HasMany
{
return $this->hasMany(Modello::class);
}
/**
* Misure in mm delle lastre per un serramento L x H, senza arrotondamenti.
*
* @return array<int,array{descrizione:?string,quantita:int,larghezza:float,altezza:float}>
*/
public function calcolaLastre(float $larghezza, float $altezza): array
{
return array_map(fn (array $lastra) => [
'descrizione' => $lastra['descrizione'] ?? null,
'quantita' => max(1, (int) ($lastra['quantita'] ?? 1)),
'larghezza' => ($larghezza - (float) ($lastra['detrazione_larghezza'] ?? 0)) / max(1, (float) ($lastra['divisore'] ?? 1)),
'altezza' => $altezza - (float) ($lastra['detrazione_altezza'] ?? 0),
], $this->lastre ?? []);
}
}
+21
View File
@@ -24,6 +24,27 @@ class Sistema extends Model
return $this->hasMany(Modello::class);
}
public function finiture(): HasMany
{
return $this->hasMany(Finitura::class);
}
public function vetri(): HasMany
{
return $this->hasMany(Vetro::class);
}
public function regoleVetro(): HasMany
{
return $this->hasMany(RegolaVetro::class);
}
/** Opzioni del sistema (senza quelle personalizzate per modello). */
public function opzioni(): HasMany
{
return $this->hasMany(Opzione::class)->whereNull('modello_id');
}
public function scopeAttivi(Builder $query): Builder
{
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use App\Models\Concerns\HaImmaginiCatalogo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Vetro extends Model
{
use HaImmaginiCatalogo;
protected $table = 'vetri';
protected static array $immagini = ['anteprima', 'popup', 'texture', 'texture_bump'];
protected $fillable = [
'sistema_id', 'codice', 'descrizione', 'maggiorazione_mq', 'minimo_mq',
'colore', 'roughness', 'metalness', 'opacita', 'anteprima', 'popup', 'texture',
'texture_bump', 'bump_scala', 'predefinito', 'ordine', 'attivo',
];
protected $casts = [
'maggiorazione_mq' => 'float',
'minimo_mq' => 'float',
'roughness' => 'float',
'metalness' => 'float',
'opacita' => 'float',
'bump_scala' => 'float',
'predefinito' => 'boolean',
'ordine' => 'integer',
'attivo' => 'boolean',
];
public function sistema(): BelongsTo
{
return $this->belongsTo(Sistema::class);
}
public function scopeAttivi(Builder $query): Builder
{
return $query->where('attivo', true)->orderBy('ordine')->orderBy('id');
}
protected static function booted(): void
{
// Un solo elemento predefinito per sistema.
static::saved(function (self $record): void {
if ($record->predefinito && ($record->wasRecentlyCreated || $record->wasChanged('predefinito'))) {
static::query()
->where('sistema_id', $record->sistema_id)
->whereKeyNot($record->getKey())
->update(['predefinito' => false]);
}
});
}
}
@@ -0,0 +1,260 @@
<?php
namespace App\Support\Configuratore;
use App\Models\Finitura;
use App\Models\GruppoOpzioni;
use App\Models\Modello;
use App\Models\Opzione;
use App\Models\Vetro;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
/**
* Prezzo di una configurazione, calcolato solo lato server dalle regole del backoffice:
*
* prezzo = base x (1 + supplementi % / 100) + supplementi fissi + variazione vetro
*
* - base: prima misura del listino >= a quella richiesta (mm), entro min/max del listino;
* - supplementi: tipo finitura (bianco/uniforme/bicolore) e opzioni scelte (gruppi di Opzione);
* - variazione vetro: somma delle aree fatturabili delle lastre (max(area, minimo) per lastra)
* per la maggiorazione €/m² del riempimento, con le lastre date dalla regola vetro del modello.
*
* I calcoli interni non sono arrotondati: solo il prezzo finale va a 2 decimali.
*/
class CalcolatorePrezzo
{
/**
* @param array{larghezza?:mixed,altezza?:mixed,finitura_interna?:mixed,finitura_esterna?:mixed,maniglia?:mixed,vetro?:mixed,opzioni?:array<string,mixed>} $scelte
* @return array{prezzo:float,scelte:array,configurazione:array,dettaglio:array}
*
* @throws ValidationException se la misura è fuori listino o una scelta non esiste
*/
public function calcola(Modello $modello, array $scelte): array
{
$larghezza = (int) ($scelte['larghezza'] ?? 0);
$altezza = (int) ($scelte['altezza'] ?? 0);
[$base, $misuraListino] = $this->prezzoBase($modello, $larghezza, $altezza);
$opzioni = Opzione::perModello($modello);
$supplementi = [];
$configurazione = [
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
'width' => $larghezza,
'height' => $altezza,
];
$normalizzate = ['larghezza' => $larghezza, 'altezza' => $altezza, 'opzioni' => []];
if ($modello->haModulo('modello')) {
// Finiture attive nel modello, ciascuna scelta solo tra quelle del suo lato.
$profili = Finitura::delProfiloPerModello($modello);
$interna = $this->scegli($profili->filter->disponibileSu('interno'), $scelte['finitura_interna'] ?? null, 'finitura_interna', fn ($f) => $f->predefinita);
// Senza finitura esterna separata il serramento è tutto della finitura interna.
$esterna = $modello->haModulo('finitura_esterna')
? $this->scegli($profili->filter->disponibileSu('esterno'), $scelte['finitura_esterna'] ?? null, 'finitura_esterna', fn ($f) => $f->predefinita)
: $interna;
$tipo = match (true) {
$interna->isNot($esterna) => 'bicolore',
$interna->predefinita => 'bianco',
default => 'uniforme',
};
$voce = $opzioni->get('tipo_finitura', collect())->firstWhere('valore', $tipo);
$supplementi[] = $this->supplemento('Tipo finitura', $voce, $base, $larghezza, $altezza, $tipo);
$configurazione['tipo_finitura'] = $voce?->etichetta ?? ucfirst($tipo);
$configurazione['finitura_interna'] = $interna->descrizione;
if ($modello->haModulo('finitura_esterna')) {
$configurazione['finitura_esterna'] = $esterna->descrizione;
}
$normalizzate['finitura_interna'] = $interna->codice;
$normalizzate['finitura_esterna'] = $esterna->codice;
}
if ($modello->haModulo('maniglia')) {
$maniglie = $modello->sistema->finiture()->where('tipo', 'maniglia')->attive()->get();
if ($maniglie->isNotEmpty()) {
$maniglia = $this->scegli($maniglie, $scelte['maniglia'] ?? null, 'maniglia');
$configurazione['maniglia'] = $maniglia->descrizione;
$normalizzate['maniglia'] = $maniglia->codice;
}
}
$gruppi = GruppoOpzioni::perCodice();
foreach ($opzioni as $gruppo => $righe) {
$definizione = $gruppi->get($gruppo);
if (! $definizione || ! $definizione->haControllo() || ! $modello->haModulo($definizione->modulo)) {
continue;
}
if ($definizione->ui === 'multipla') {
$scelteGruppo = $this->scegliMultiple($righe, $scelte['opzioni'][$gruppo] ?? [], "opzioni.{$gruppo}");
foreach ($scelteGruppo as $scelta) {
$supplementi[] = $this->supplemento($definizione->etichetta, $scelta, $base, $larghezza, $altezza);
}
if ($scelteGruppo->isNotEmpty()) {
$configurazione[$gruppo] = $scelteGruppo->pluck('etichetta')->implode(', ');
}
$normalizzate['opzioni'][$gruppo] = $scelteGruppo->pluck('valore')->all();
continue;
}
$scelta = $this->scegli($righe, $scelte['opzioni'][$gruppo] ?? null, "opzioni.{$gruppo}", null, 'valore');
$supplementi[] = $this->supplemento($definizione->etichetta, $scelta, $base, $larghezza, $altezza);
$configurazione[$gruppo] = $scelta->etichetta;
$normalizzate['opzioni'][$gruppo] = $scelta->valore;
}
$vetro = null;
if ($modello->haModulo('vetro')) {
$vetri = $modello->sistema->vetri()->attivi()->get();
if ($vetri->isNotEmpty()) {
$scelto = $this->scegli($vetri, $scelte['vetro'] ?? null, 'vetro', fn ($v) => $v->predefinito);
$vetro = $this->variazioneVetro($modello, $scelto, $larghezza, $altezza);
$configurazione['vetro'] = $scelto->descrizione;
$normalizzate['vetro'] = $scelto->codice;
}
}
$supplementi = array_values(array_filter($supplementi));
$percentuale = array_sum(array_column(array_filter($supplementi, fn ($s) => $s['tipo'] === 'percentuale'), 'valore'));
// Importi fissi e al metro lineare (già calcolati sulle misure).
$fisso = array_sum(array_column(array_filter($supplementi, fn ($s) => $s['tipo'] !== 'percentuale'), 'importo'));
$prezzo = $base * (1 + $percentuale / 100) + $fisso + ($vetro['importo'] ?? 0);
return [
'prezzo' => round($prezzo, 2),
'scelte' => $normalizzate,
'configurazione' => $configurazione,
'dettaglio' => [
'prezzo_base' => $base,
'misura_listino' => $misuraListino,
'supplementi' => $supplementi,
'percentuale_totale' => $percentuale,
'fisso_totale' => $fisso,
'vetro' => $vetro,
],
];
}
/** @return array{0:float,1:array{larghezza:int,altezza:int}} */
private function prezzoBase(Modello $modello, int $larghezza, int $altezza): array
{
$griglia = $modello->listino()->get(['larghezza', 'altezza', 'prezzo']);
if ($griglia->isEmpty()) {
throw ValidationException::withMessages(['larghezza' => 'Nessun listino caricato per questo modello.']);
}
$l = $this->misuraListino($griglia->pluck('larghezza'), $larghezza);
$h = $this->misuraListino($griglia->pluck('altezza'), $altezza);
$cella = ($l !== null && $h !== null)
? $griglia->first(fn ($p) => $p->larghezza === $l && $p->altezza === $h)
: null;
if (! $cella) {
throw ValidationException::withMessages(['larghezza' => 'Misura non disponibile a listino.']);
}
return [(float) $cella->prezzo, ['larghezza' => $l, 'altezza' => $h]];
}
/** Prima misura del listino >= a quella richiesta; null se fuori dal min/max del listino. */
private function misuraListino(Collection $misure, int $valore): ?int
{
$misure = $misure->unique()->sort()->values();
if ($valore <= 0 || $valore < $misure->first()) {
return null;
}
return $misure->first(fn ($m) => $m >= $valore);
}
/**
* Elemento scelto per codice (o valore); senza scelta il predefinito, altrimenti il primo.
*
* @template T of \Illuminate\Database\Eloquent\Model
*
* @param Collection<int,T> $elementi
* @return T
*/
private function scegli(Collection $elementi, mixed $codice, string $campo, ?callable $predefinito = null, string $chiave = 'codice')
{
if ($codice === null || $codice === '') {
$scelto = ($predefinito ? $elementi->first($predefinito) : null) ?? $elementi->first();
} else {
$scelto = $elementi->first(fn ($e) => (string) $e->{$chiave} === (string) $codice);
}
if (! $scelto) {
throw ValidationException::withMessages([$campo => 'Scelta non disponibile per questo modello.']);
}
return $scelto;
}
/**
* Opzioni scelte in un gruppo a scelta multipla (anche nessuna); quelle con la stessa
* `alternativa` si escludono a vicenda.
*
* @return Collection<int,Opzione>
*/
private function scegliMultiple(Collection $righe, mixed $valori, string $campo): Collection
{
$valori = collect(is_array($valori) ? $valori : [$valori])->filter(fn ($v) => is_scalar($v) && $v !== '')->map(fn ($v) => (string) $v)->unique();
$scelte = $valori->map(fn (string $v) => $this->scegli($righe, $v, $campo, null, 'valore'));
$doppie = $scelte->whereNotNull('alternativa')->groupBy('alternativa')->filter(fn ($g) => $g->count() > 1);
if ($doppie->isNotEmpty()) {
throw ValidationException::withMessages([$campo => 'Si può scegliere una sola opzione tra: '.$doppie->first()->pluck('etichetta')->implode(', ').'.']);
}
// Nell'ordine del gruppo, non in quello di invio.
return $righe->filter(fn (Opzione $o) => $scelte->contains(fn (Opzione $s) => $s->is($o)))->values();
}
private function supplemento(string $voce, ?Opzione $opzione, float $base, int $larghezza, int $altezza, ?string $valore = null): ?array
{
if (! $opzione || ! $opzione->supplemento_tipo || ! $opzione->supplemento) {
return null;
}
return [
'voce' => "{$voce}: {$opzione->etichetta}",
'valore_scelto' => $valore ?? $opzione->valore,
'tipo' => $opzione->supplemento_tipo,
'valore' => $opzione->supplemento,
'metri_lineari' => $opzione->supplemento_tipo === 'metro_lineare' ? round($opzione->metriLineari($larghezza, $altezza), 3) : null,
'importo' => $opzione->importo($base, $larghezza, $altezza),
];
}
private function variazioneVetro(Modello $modello, Vetro $vetro, int $larghezza, int $altezza): array
{
$regola = $modello->regolaVetro;
$lastre = [];
$mqFatturabili = 0.0;
foreach ($regola?->calcolaLastre($larghezza, $altezza) ?? [] as $lastra) {
$mq = max(0, $lastra['larghezza']) * max(0, $lastra['altezza']) / 1_000_000;
// Il minimo fatturabile vale per singola lastra.
$fatturabili = max($mq, $vetro->minimo_mq);
$mqFatturabili += $fatturabili * $lastra['quantita'];
$lastre[] = $lastra + ['mq' => round($mq, 3), 'mq_fatturabili' => round($fatturabili, 3)];
}
return [
'codice' => $vetro->codice,
'descrizione' => $vetro->descrizione,
'regola' => $regola?->codice,
'maggiorazione_mq' => $vetro->maggiorazione_mq,
'minimo_mq' => $vetro->minimo_mq,
'lastre' => $lastre,
'mq_fatturabili' => round($mqFatturabili, 3),
'importo' => $mqFatturabili * $vetro->maggiorazione_mq,
];
}
}
@@ -0,0 +1,148 @@
<?php
namespace App\Support\Configuratore;
use App\Models\Finitura;
use App\Models\GruppoOpzioni;
use App\Models\Modello;
use App\Models\Opzione;
use App\Models\Vetro;
/**
* Dati del configuratore per un modello: struttura del menu, finiture, vetri, opzioni e
* misure del listino, con le immagini servite dal backoffice. Sostituisce i vecchi
* form.json / modello.json / maniglia.json / vetro.json e la cartella immagini.
*
* Non espone supplementi e maggiorazioni: il prezzo lo calcola CalcolatorePrezzo.
*/
class ConfigurazioneModello
{
public function perModello(Modello $modello): array
{
$sistema = $modello->sistema;
// Finiture del profilo attive nel modello, divise per lato (interno / esterno).
$finiture = $modello->haModulo('modello') ? Finitura::delProfiloPerModello($modello) : collect();
$interne = $finiture->filter(fn (Finitura $f) => $f->disponibileSu('interno'))->map($this->finitura(...))->values();
$esterne = $modello->haModulo('finitura_esterna')
? $finiture->filter(fn (Finitura $f) => $f->disponibileSu('esterno'))->map($this->finitura(...))->values()
: collect();
$maniglie = $modello->haModulo('maniglia')
? $sistema->finiture()->where('tipo', 'maniglia')->attive()->get()->map($this->finitura(...))->values()
: collect();
$vetri = $modello->haModulo('vetro')
? $sistema->vetri()->attivi()->get()->map($this->vetro(...))->values()
: collect();
return [
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
'form' => $this->form($modello, $interne->isNotEmpty(), $esterne->isNotEmpty(), $maniglie->isNotEmpty(), $vetri->isNotEmpty()),
'finiture' => $finiture->map($this->finitura(...))->values(),
'finiture_interne' => $interne,
'finiture_esterne' => $esterne,
'maniglie' => $maniglie,
'vetri' => $vetri,
'misure' => $modello->listino()
->orderBy('altezza')
->orderBy('larghezza')
->get(['altezza', 'larghezza', 'prezzo'])
->map(fn ($p) => ['altezza' => $p->altezza, 'larghezza' => $p->larghezza, 'prezzo' => $p->prezzo])
->values(),
];
}
/** Stessa struttura del vecchio form.json (radios, select, finiture) letta dal MenuBuilder. */
private function form(Modello $modello, bool $interne, bool $esterne, bool $maniglie, bool $vetri): array
{
$radios = [];
$select = [];
$multiple = [];
$gruppi = [];
$definizioni = GruppoOpzioni::perCodice();
$opzioni = Opzione::perModello($modello);
// Nell'ordine dei gruppi: il configuratore li presenta così.
foreach ($definizioni as $gruppo => $definizione) {
$righe = $opzioni->get($gruppo);
if (! $righe || ! $definizione->haControllo() || ! $modello->haModulo($definizione->modulo)) {
continue;
}
$gruppi[$gruppo] = ['etichetta' => $definizione->etichetta, 'ui' => $definizione->ui, 'sezione' => $definizione->sezione];
$voci = $righe->map(fn (Opzione $o) => ($o->parametri ?? []) + [
'valore' => $o->valore,
'label' => $o->etichetta,
'icon' => Media::url($o->icona),
'alternativa' => $o->alternativa,
])->values();
match ($definizione->ui) {
'radio' => $radios[$gruppo] = $voci,
'multipla' => $multiple[$gruppo] = $voci,
default => $select[$gruppo] = $voci,
};
}
$menu = [];
if ($interne) {
$menu[] = ['nome' => 'finitura_interna', 'categorie' => ['interno'], 'data' => 'data_finiture_interne'];
}
if ($esterne) {
$menu[] = ['nome' => 'finitura_esterna', 'categorie' => ['esterno'], 'data' => 'data_finiture_esterne'];
}
if ($maniglie) {
$menu[] = ['nome' => 'maniglia', 'categorie' => ['maniglia', 'cerniere'], 'data' => 'data_maniglie'];
}
if ($vetri) {
$menu[] = ['nome' => 'vetro', 'categorie' => ['vetro'], 'data' => 'data_vetri'];
}
return [
'nome' => $modello->titolo ?: ($modello->descrizione ?: $modello->nome),
'radios' => (object) $radios,
'select' => (object) $select,
// Pulsanti selezionabili insieme (es. profili extra), anche nessuno.
'multiple' => (object) $multiple,
// Titolo, controllo e sezione del menu dei gruppi sopra, nell'ordine del backoffice.
'gruppi' => (object) $gruppi,
'finiture' => $menu,
];
}
/** Campi con i nomi letti da APP.Player.changeTexture/changeColor. */
private function finitura(Finitura $f): array
{
return [
'id' => $f->codice,
'descrizione' => $f->descrizione,
'anteprima' => Media::url($f->anteprima),
'texture_map' => Media::url($f->texture),
'texture_bump' => Media::url($f->texture_bump),
'bump_valore' => $f->bump_scala,
'color_valore' => $f->colore,
'color_valore_corpo_base' => $f->colore_corpo_base,
'roughness_valore' => $f->roughness,
'metalness_valore' => $f->metalness,
'predefinito' => $f->predefinita,
];
}
private function vetro(Vetro $v): array
{
return [
'id' => $v->codice,
'descrizione' => $v->descrizione,
'anteprima' => Media::url($v->anteprima),
'pop_up' => Media::url($v->popup),
'texture_map' => Media::url($v->texture),
'texture_bump' => Media::url($v->texture_bump),
'bump_valore' => $v->bump_scala,
'color_valore' => $v->colore,
'roughness_valore' => $v->roughness,
'metalness_valore' => $v->metalness,
'opacity_valore' => $v->opacita,
'predefinito' => $v->predefinito,
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Support\Configuratore;
/**
* URL pubblici delle immagini del configuratore. Passano dalla rotta API `media` (e non
* da /storage) perché le texture caricate da Three.js richiedono gli header CORS.
*/
class Media
{
/** Cartelle del disco public servite dalla rotta media. */
public const CARTELLE = ['configuratore/', 'modelli/'];
public static function url(?string $percorso): ?string
{
return $percorso ? route('media', ['percorso' => $percorso]) : null;
}
}
@@ -27,17 +27,21 @@ class ConfigurazioneFormatter
'tipo_ispezione' => 'Tipo ispezione',
'avvolgibile' => 'Avvolgibile',
'cassonetto' => 'Cassonetto',
'apertura' => 'Verso di apertura',
'telaio' => 'Telaio',
'cerniere_a_scomparsa' => 'Cerniere a scomparsa',
'profili_extra' => 'Profili extra',
];
/** Ordine di visualizzazione preferito. */
private const ORDER = [
'width', 'height', 'tipo_finitura', 'finitura_interna', 'finitura_esterna',
'maniglia', 'altezza_maniglia', 'vetro', 'cerniere_scomparsa', 'azionamento',
'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto',
'maniglia', 'altezza_maniglia', 'vetro', 'apertura', 'profili_extra', 'telaio', 'cerniere_scomparsa',
'cerniere_a_scomparsa', 'azionamento', 'apertura_portoncino', 'tipo_ispezione', 'avvolgibile', 'cassonetto',
];
/** Chiavi tecniche da non mostrare nella lista (usate altrove, es. titolo). */
private const SKIP = ['nome'];
private const SKIP = ['nome', 'scelte', 'dettaglio_prezzo'];
/**
* @param array<string,mixed>|null $configurazione