backoffice first commit
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ClienteResource\Pages;
|
||||
use App\Models\Cliente;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ClienteResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Cliente::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-users';
|
||||
|
||||
protected static ?string $navigationLabel = 'Clienti';
|
||||
|
||||
protected static ?string $modelLabel = 'cliente';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'clienti';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->schema([
|
||||
Forms\Components\TextInput::make('nome')->label('Nome')->required(),
|
||||
Forms\Components\TextInput::make('cognome')->label('Cognome'),
|
||||
Forms\Components\TextInput::make('ragione_sociale')->label('Ragione sociale'),
|
||||
Forms\Components\TextInput::make('email')->label('Email')->email()->required(),
|
||||
Forms\Components\TextInput::make('telefono')->label('Telefono')->tel(),
|
||||
Forms\Components\TextInput::make('indirizzo')->label('Indirizzo'),
|
||||
Forms\Components\TextInput::make('cap')->label('CAP')->maxLength(10),
|
||||
Forms\Components\TextInput::make('citta')->label('Città'),
|
||||
Forms\Components\TextInput::make('provincia')->label('Provincia')->maxLength(5),
|
||||
Forms\Components\Textarea::make('note')->label('Note')->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('nominativo')->label('Nominativo')->searchable(['nome', 'cognome', 'ragione_sociale']),
|
||||
Tables\Columns\TextColumn::make('email')->label('Email')->searchable()->copyable(),
|
||||
Tables\Columns\TextColumn::make('telefono')->label('Telefono')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('citta')->label('Città')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('preventivi_count')->label('Preventivi')->counts('preventivi'),
|
||||
Tables\Columns\TextColumn::make('created_at')->label('Registrato')->dateTime('d/m/Y')->sortable(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListClientes::route('/'),
|
||||
'edit' => Pages\EditCliente::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ClienteResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ClienteResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateCliente extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ClienteResource::class;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ClienteResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ClienteResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditCliente extends EditRecord
|
||||
{
|
||||
protected static string $resource = ClienteResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ClienteResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ClienteResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListClientes extends ListRecords
|
||||
{
|
||||
protected static string $resource = ClienteResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ClienteResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ClienteResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewCliente extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ClienteResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\PreventivoResource\Pages;
|
||||
use App\Filament\Resources\PreventivoResource\RelationManagers\ItemsRelationManager;
|
||||
use App\Models\Preventivo;
|
||||
use App\Support\PreventivoPdf;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Infolists;
|
||||
use Filament\Infolists\Infolist;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PreventivoResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Preventivo::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-document-text';
|
||||
|
||||
protected static ?string $navigationLabel = 'Preventivi';
|
||||
|
||||
protected static ?string $modelLabel = 'preventivo';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'preventivi';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return (string) static::getModel()::where('stato', 'inviato')->count();
|
||||
}
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form->schema([
|
||||
Forms\Components\Select::make('stato')
|
||||
->label('Stato')
|
||||
->options(Preventivo::STATI)
|
||||
->required(),
|
||||
Forms\Components\Select::make('cliente_id')
|
||||
->label('Cliente')
|
||||
->relationship('cliente', 'nome')
|
||||
->searchable()
|
||||
->preload(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('numero')
|
||||
->label('N.')
|
||||
->sortable()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('cliente.nominativo')
|
||||
->label('Cliente')
|
||||
->searchable(['nome', 'cognome', 'ragione_sociale'])
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('cliente.email')
|
||||
->label('Email')
|
||||
->searchable()
|
||||
->toggleable()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('items_count')
|
||||
->label('Prodotti')
|
||||
->counts('items'),
|
||||
Tables\Columns\TextColumn::make('totale')
|
||||
->label('Totale')
|
||||
->money('EUR')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('stato')
|
||||
->label('Stato')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state) => Preventivo::STATI[$state] ?? $state)
|
||||
->color(fn (string $state) => match ($state) {
|
||||
'bozza' => 'gray',
|
||||
'inviato' => 'warning',
|
||||
'in_lavorazione' => 'info',
|
||||
'chiuso' => 'success',
|
||||
default => 'gray',
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Data')
|
||||
->dateTime('d/m/Y H:i')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('stato')
|
||||
->label('Stato')
|
||||
->options(Preventivo::STATI),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
Tables\Actions\Action::make('pdf')
|
||||
->label('PDF')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->visible(fn (Preventivo $record) => $record->stato !== 'bozza')
|
||||
->action(function (Preventivo $record) {
|
||||
if (! $record->pdf_path || ! Storage::disk('local')->exists($record->pdf_path)) {
|
||||
$record->pdf_path = PreventivoPdf::genera($record);
|
||||
$record->save();
|
||||
}
|
||||
|
||||
return Storage::disk('local')->download(
|
||||
$record->pdf_path,
|
||||
'preventivo-'.$record->numero.'.pdf'
|
||||
);
|
||||
}),
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function infolist(Infolist $infolist): Infolist
|
||||
{
|
||||
return $infolist->schema([
|
||||
Infolists\Components\Section::make('Preventivo')
|
||||
->columns(3)
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('numero')->label('Numero')->placeholder('—'),
|
||||
Infolists\Components\TextEntry::make('stato')
|
||||
->label('Stato')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state) => Preventivo::STATI[$state] ?? $state),
|
||||
Infolists\Components\TextEntry::make('totale')->label('Totale')->money('EUR'),
|
||||
Infolists\Components\TextEntry::make('confirmed_at')->label('Confermato il')->dateTime('d/m/Y H:i')->placeholder('—'),
|
||||
Infolists\Components\TextEntry::make('created_at')->label('Creato il')->dateTime('d/m/Y H:i'),
|
||||
]),
|
||||
Infolists\Components\Section::make('Cliente')
|
||||
->columns(3)
|
||||
->visible(fn (Preventivo $record) => $record->cliente !== null)
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('cliente.nominativo')->label('Nominativo'),
|
||||
Infolists\Components\TextEntry::make('cliente.email')->label('Email')->copyable(),
|
||||
Infolists\Components\TextEntry::make('cliente.telefono')->label('Telefono')->placeholder('—'),
|
||||
Infolists\Components\TextEntry::make('cliente.indirizzo')->label('Indirizzo')->placeholder('—'),
|
||||
Infolists\Components\TextEntry::make('cliente.cap')->label('CAP')->placeholder('—'),
|
||||
Infolists\Components\TextEntry::make('cliente.citta')->label('Città')->placeholder('—'),
|
||||
Infolists\Components\TextEntry::make('cliente.provincia')->label('Provincia')->placeholder('—'),
|
||||
Infolists\Components\TextEntry::make('cliente.note')->label('Note')->columnSpanFull()->placeholder('—'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
ItemsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListPreventivos::route('/'),
|
||||
'view' => Pages\ViewPreventivo::route('/{record}'),
|
||||
'edit' => Pages\EditPreventivo::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PreventivoResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PreventivoResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreatePreventivo extends CreateRecord
|
||||
{
|
||||
protected static string $resource = PreventivoResource::class;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PreventivoResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PreventivoResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPreventivo extends EditRecord
|
||||
{
|
||||
protected static string $resource = PreventivoResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PreventivoResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PreventivoResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPreventivos extends ListRecords
|
||||
{
|
||||
protected static string $resource = PreventivoResource::class;
|
||||
|
||||
// Nessuna azione di creazione: i preventivi nascono dal configuratore.
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PreventivoResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PreventivoResource;
|
||||
use App\Support\PreventivoPdf;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ViewPreventivo extends ViewRecord
|
||||
{
|
||||
protected static string $resource = PreventivoResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('visualizzaPdf')
|
||||
->label('Visualizza PDF')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->visible(fn () => $this->record->stato !== 'bozza')
|
||||
->url(fn () => route('preventivi.pdf.show', $this->record))
|
||||
->openUrlInNewTab(),
|
||||
Actions\Action::make('scaricaPdf')
|
||||
->label('Scarica PDF')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('gray')
|
||||
->visible(fn () => $this->record->stato !== 'bozza')
|
||||
->action(function () {
|
||||
if (! $this->record->pdf_path || ! Storage::disk('local')->exists($this->record->pdf_path)) {
|
||||
$this->record->pdf_path = PreventivoPdf::genera($this->record);
|
||||
$this->record->save();
|
||||
}
|
||||
|
||||
return Storage::disk('local')->download(
|
||||
$this->record->pdf_path,
|
||||
'preventivo-'.$this->record->numero.'.pdf'
|
||||
);
|
||||
}),
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PreventivoResource\RelationManagers;
|
||||
|
||||
use App\Support\ConfigurazioneFormatter;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ItemsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'items';
|
||||
|
||||
protected static ?string $title = 'Prodotti configurati';
|
||||
|
||||
protected static ?string $modelLabel = 'prodotto';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('nome_modello')
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('immagine_path')
|
||||
->label('Anteprima')
|
||||
->disk('public')
|
||||
->height(60)
|
||||
->square(),
|
||||
Tables\Columns\TextColumn::make('nome_modello')
|
||||
->label('Modello')
|
||||
->description(fn (Model $record) => trim(($record->sistema ? ucfirst($record->sistema) : '').' '.($record->type ?? ''))),
|
||||
Tables\Columns\TextColumn::make('dimensioni')
|
||||
->label('Dimensioni')
|
||||
->state(fn (Model $record) => $record->larghezza && $record->altezza
|
||||
? "{$record->larghezza} × {$record->altezza} cm" : '—'),
|
||||
Tables\Columns\TextColumn::make('quantita')->label('Q.tà'),
|
||||
Tables\Columns\TextColumn::make('prezzo_unitario')->label('Prezzo un.')->money('EUR'),
|
||||
Tables\Columns\TextColumn::make('prezzo_totale')->label('Totale')->money('EUR'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make()
|
||||
->infolist(fn ($infolist) => $infolist->schema([
|
||||
\Filament\Infolists\Components\ImageEntry::make('immagine_path')
|
||||
->label('Anteprima')
|
||||
->disk('public')
|
||||
->height(220),
|
||||
\Filament\Infolists\Components\KeyValueEntry::make('configurazione_display')
|
||||
->label('Configurazione selezionata')
|
||||
->state(fn (Model $record) => collect(ConfigurazioneFormatter::toRows($record->payload))
|
||||
->mapWithKeys(fn ($row) => [$row['label'] => $row['value']])
|
||||
->all())
|
||||
->columnSpanFull(),
|
||||
])),
|
||||
])
|
||||
->paginated(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\PreventivoAdmin;
|
||||
use App\Mail\PreventivoCliente;
|
||||
use App\Models\Cliente;
|
||||
use App\Models\Preventivo;
|
||||
use App\Models\PreventivoItem;
|
||||
use App\Support\ConfigurazioneFormatter;
|
||||
use App\Support\PreventivoPdf;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CarrelloController extends Controller
|
||||
{
|
||||
/**
|
||||
* POST /api/carrello/aggiungi
|
||||
* Aggiunge un prodotto configurato al carrello (preventivo in stato bozza).
|
||||
*/
|
||||
public function aggiungi(Request $request): 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'],
|
||||
'qty' => ['nullable', 'integer', 'min:1', 'max:999'],
|
||||
'prezzo' => ['required', 'numeric', 'min:0'],
|
||||
'payload' => ['nullable', 'array'],
|
||||
'img' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$preventivo = $this->risolviCarrello($data['token'] ?? null);
|
||||
|
||||
$qty = $data['qty'] ?? 1;
|
||||
$prezzoUnitario = round((float) $data['prezzo'], 2);
|
||||
|
||||
$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),
|
||||
'quantita' => $qty,
|
||||
'prezzo_unitario' => $prezzoUnitario,
|
||||
'prezzo_totale' => round($prezzoUnitario * $qty, 2),
|
||||
'payload' => $data['payload'] ?? [],
|
||||
'immagine_path' => $this->salvaImmagine($data['img'] ?? null),
|
||||
]);
|
||||
|
||||
$preventivo->items()->save($item);
|
||||
$preventivo->ricalcolaTotale();
|
||||
|
||||
return response()->json([
|
||||
'token' => $preventivo->token,
|
||||
'carrello' => $this->serializzaCarrello($preventivo->fresh('items')),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/** GET /api/carrello/{token} */
|
||||
public function mostra(string $token): JsonResponse
|
||||
{
|
||||
$preventivo = Preventivo::where('token', $token)->where('stato', 'bozza')
|
||||
->with('items')->first();
|
||||
|
||||
if (! $preventivo) {
|
||||
return response()->json(['token' => $token, 'carrello' => ['items' => [], 'totale' => 0]]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'token' => $preventivo->token,
|
||||
'carrello' => $this->serializzaCarrello($preventivo),
|
||||
]);
|
||||
}
|
||||
|
||||
/** DELETE /api/carrello/{token}/item/{item} */
|
||||
public function rimuoviItem(string $token, int $item): JsonResponse
|
||||
{
|
||||
$preventivo = Preventivo::where('token', $token)->where('stato', 'bozza')
|
||||
->with('items')->firstOrFail();
|
||||
|
||||
$preventivo->items()->whereKey($item)->delete();
|
||||
$preventivo->ricalcolaTotale();
|
||||
|
||||
return response()->json([
|
||||
'token' => $preventivo->token,
|
||||
'carrello' => $this->serializzaCarrello($preventivo->fresh('items')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/carrello/{token}/conferma
|
||||
* Raccoglie i dati cliente, finalizza il preventivo, genera il PDF e invia le email.
|
||||
*/
|
||||
public function conferma(Request $request, string $token): JsonResponse
|
||||
{
|
||||
$preventivo = Preventivo::where('token', $token)->where('stato', 'bozza')
|
||||
->with('items')->firstOrFail();
|
||||
|
||||
if ($preventivo->items->isEmpty()) {
|
||||
return response()->json(['message' => 'Il carrello è vuoto.'], 422);
|
||||
}
|
||||
|
||||
$dati = $request->validate([
|
||||
'nome' => ['required', 'string', 'max:255'],
|
||||
'cognome' => ['nullable', 'string', 'max:255'],
|
||||
'ragione_sociale' => ['nullable', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'telefono' => ['nullable', 'string', 'max:50'],
|
||||
'indirizzo' => ['nullable', 'string', 'max:255'],
|
||||
'cap' => ['nullable', 'string', 'max:10'],
|
||||
'citta' => ['nullable', 'string', 'max:255'],
|
||||
'provincia' => ['nullable', 'string', 'max:5'],
|
||||
'note' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($preventivo, $dati) {
|
||||
$cliente = Cliente::create($dati);
|
||||
|
||||
$preventivo->cliente_id = $cliente->id;
|
||||
$preventivo->stato = 'inviato';
|
||||
$preventivo->numero = Preventivo::prossimoNumero();
|
||||
$preventivo->confirmed_at = now();
|
||||
$preventivo->save();
|
||||
});
|
||||
|
||||
$preventivo->refresh()->load('cliente', 'items');
|
||||
|
||||
// Genera il PDF e salva il path.
|
||||
$pdfPath = PreventivoPdf::genera($preventivo);
|
||||
$preventivo->pdf_path = $pdfPath;
|
||||
$preventivo->save();
|
||||
|
||||
// Invia le email (cliente con PDF allegato, admin di notifica).
|
||||
$this->inviaEmail($preventivo);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Preventivo inviato. Verrai ricontattato al più presto.',
|
||||
'numero' => $preventivo->numero,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Trova il carrello bozza per token o ne crea uno nuovo. */
|
||||
private function risolviCarrello(?string $token): Preventivo
|
||||
{
|
||||
if ($token) {
|
||||
$preventivo = Preventivo::where('token', $token)->where('stato', 'bozza')->first();
|
||||
if ($preventivo) {
|
||||
return $preventivo;
|
||||
}
|
||||
}
|
||||
|
||||
return Preventivo::create([
|
||||
'token' => (string) Str::uuid(),
|
||||
'stato' => 'bozza',
|
||||
'totale' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Decodifica un data URL base64 e salva il PNG; ritorna il path relativo (disk public). */
|
||||
private function salvaImmagine(?string $dataUrl): ?string
|
||||
{
|
||||
if (! $dataUrl || ! Str::startsWith($dataUrl, 'data:image')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$meta, $contenuto] = array_pad(explode(',', $dataUrl, 2), 2, null);
|
||||
if ($contenuto === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$binario = base64_decode($contenuto, true);
|
||||
if ($binario === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = 'preventivi/items/'.Str::uuid()->toString().'.png';
|
||||
Storage::disk('public')->put($path, $binario);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** @return array{items:array,totale:float} */
|
||||
private function serializzaCarrello(Preventivo $preventivo): array
|
||||
{
|
||||
return [
|
||||
'items' => $preventivo->items->map(fn (PreventivoItem $item) => [
|
||||
'id' => $item->id,
|
||||
'sistema' => $item->sistema,
|
||||
'type' => $item->type,
|
||||
'nome_modello' => $item->nome_modello,
|
||||
'larghezza' => $item->larghezza,
|
||||
'altezza' => $item->altezza,
|
||||
'quantita' => $item->quantita,
|
||||
'prezzo_unitario' => (float) $item->prezzo_unitario,
|
||||
'prezzo_totale' => (float) $item->prezzo_totale,
|
||||
'immagine_url' => $item->immagine_path ? Storage::disk('public')->url($item->immagine_path) : null,
|
||||
'configurazione' => ConfigurazioneFormatter::toRows($item->payload),
|
||||
])->all(),
|
||||
'totale' => (float) $preventivo->totale,
|
||||
];
|
||||
}
|
||||
|
||||
private function inviaEmail(Preventivo $preventivo): void
|
||||
{
|
||||
if ($preventivo->cliente?->email) {
|
||||
Mail::to($preventivo->cliente->email)->send(new PreventivoCliente($preventivo));
|
||||
}
|
||||
|
||||
$adminEmail = config('preventivi.admin_email');
|
||||
if ($adminEmail) {
|
||||
Mail::to($adminEmail)->send(new PreventivoAdmin($preventivo));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Preventivo;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PreventivoAdmin extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public Preventivo $preventivo)
|
||||
{
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Nuovo preventivo n. '.$this->preventivo->numero.' da '.($this->preventivo->cliente?->nominativo ?? 'cliente'),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.preventivo-admin',
|
||||
with: [
|
||||
'preventivo' => $this->preventivo,
|
||||
'adminUrl' => rtrim(config('app.url'), '/').'/admin/preventivos/'.$this->preventivo->id,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Preventivo;
|
||||
use App\Support\PreventivoPdf;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Attachment;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PreventivoCliente extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public Preventivo $preventivo)
|
||||
{
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Il tuo preventivo Krèios n. '.$this->preventivo->numero,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.preventivo-cliente',
|
||||
with: ['preventivo' => $this->preventivo],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<int, Attachment> */
|
||||
public function attachments(): array
|
||||
{
|
||||
$path = PreventivoPdf::pathAssoluto($this->preventivo);
|
||||
|
||||
if (! $path) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
Attachment::fromPath($path)
|
||||
->as('preventivo-'.$this->preventivo->numero.'.pdf')
|
||||
->withMime('application/pdf'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Cliente extends Model
|
||||
{
|
||||
protected $table = 'clienti';
|
||||
|
||||
protected $fillable = [
|
||||
'nome', 'cognome', 'ragione_sociale', 'email', 'telefono',
|
||||
'indirizzo', 'cap', 'citta', 'provincia', 'note',
|
||||
];
|
||||
|
||||
public function preventivi(): HasMany
|
||||
{
|
||||
return $this->hasMany(Preventivo::class);
|
||||
}
|
||||
|
||||
public function getNominativoAttribute(): string
|
||||
{
|
||||
if ($this->ragione_sociale) {
|
||||
return $this->ragione_sociale;
|
||||
}
|
||||
|
||||
return trim("{$this->nome} {$this->cognome}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Preventivo extends Model
|
||||
{
|
||||
protected $table = 'preventivi';
|
||||
|
||||
protected $fillable = [
|
||||
'cliente_id', 'token', 'numero', 'stato', 'totale', 'pdf_path', 'confirmed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'totale' => 'decimal:2',
|
||||
'confirmed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const STATI = [
|
||||
'bozza' => 'Bozza',
|
||||
'inviato' => 'Inviato',
|
||||
'in_lavorazione' => 'In lavorazione',
|
||||
'chiuso' => 'Chiuso',
|
||||
];
|
||||
|
||||
public function cliente(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cliente::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(PreventivoItem::class);
|
||||
}
|
||||
|
||||
/** Ricalcola e salva il totale dagli item. */
|
||||
public function ricalcolaTotale(): void
|
||||
{
|
||||
$this->totale = $this->items()->sum('prezzo_totale');
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/** Numero progressivo assegnato alla conferma (max+1). */
|
||||
public static function prossimoNumero(): int
|
||||
{
|
||||
return (int) static::max('numero') + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PreventivoItem extends Model
|
||||
{
|
||||
protected $table = 'preventivo_items';
|
||||
|
||||
protected $fillable = [
|
||||
'preventivo_id', 'sistema', 'type', 'nome_modello', 'larghezza', 'altezza',
|
||||
'quantita', 'prezzo_unitario', 'prezzo_totale', 'payload', 'immagine_path',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'prezzo_unitario' => 'decimal:2',
|
||||
'prezzo_totale' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function preventivo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Preventivo::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->brandName('Krèios')
|
||||
->brandLogo(asset('images/kreios-logo.jpg'))
|
||||
->brandLogoHeight('2.5rem')
|
||||
->favicon(asset('images/kreios-logo.jpg'))
|
||||
->colors([
|
||||
'primary' => Color::hex('#12373C'),
|
||||
'warning' => Color::hex('#ffd700'),
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
||||
->pages([
|
||||
Pages\Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||
->widgets([
|
||||
Widgets\AccountWidget::class,
|
||||
Widgets\FilamentInfoWidget::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Normalizza l'oggetto `configurazione` (payload) proveniente dal configuratore in
|
||||
* una lista ordinata di righe { label, value } pronte per PDF e backoffice.
|
||||
*
|
||||
* I valori sono misti: alcune chiavi sono stringhe (es. finitura_interna, maniglia),
|
||||
* altre sono oggetti listino (es. tipo_finitura, cerniere_scomparsa, azionamento).
|
||||
*/
|
||||
class ConfigurazioneFormatter
|
||||
{
|
||||
/** Etichette italiane per le chiavi note. */
|
||||
private const LABELS = [
|
||||
'width' => 'Larghezza (cm)',
|
||||
'height' => 'Altezza (cm)',
|
||||
'tipo_finitura' => 'Tipo finitura',
|
||||
'finitura_interna' => 'Finitura interna',
|
||||
'finitura_esterna' => 'Finitura esterna',
|
||||
'maniglia' => 'Maniglia',
|
||||
'altezza_maniglia' => 'Altezza maniglia',
|
||||
'vetro' => 'Vetro',
|
||||
'cerniere_scomparsa' => 'Cerniere a scomparsa',
|
||||
'azionamento' => 'Azionamento',
|
||||
'apertura_portoncino' => 'Apertura',
|
||||
'tipo_ispezione' => 'Tipo ispezione',
|
||||
'avvolgibile' => 'Avvolgibile',
|
||||
'cassonetto' => 'Cassonetto',
|
||||
];
|
||||
|
||||
/** 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',
|
||||
];
|
||||
|
||||
/** Chiavi tecniche da non mostrare nella lista (usate altrove, es. titolo). */
|
||||
private const SKIP = ['nome'];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $configurazione
|
||||
* @return array<int,array{label:string,value:string}>
|
||||
*/
|
||||
public static function toRows(?array $configurazione): array
|
||||
{
|
||||
if (empty($configurazione)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = array_keys($configurazione);
|
||||
|
||||
// Ordina: prima le chiavi note nell'ordine preferito, poi le rimanenti.
|
||||
usort($keys, function ($a, $b) {
|
||||
$ia = array_search($a, self::ORDER, true);
|
||||
$ib = array_search($b, self::ORDER, true);
|
||||
$ia = $ia === false ? PHP_INT_MAX : $ia;
|
||||
$ib = $ib === false ? PHP_INT_MAX : $ib;
|
||||
|
||||
return $ia <=> $ib ?: strcmp($a, $b);
|
||||
});
|
||||
|
||||
$rows = [];
|
||||
foreach ($keys as $key) {
|
||||
if (in_array($key, self::SKIP, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = self::extractValue($configurazione[$key]);
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'label' => self::LABELS[$key] ?? self::humanize($key),
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** Estrae un valore leggibile da stringa/numero/oggetto listino. */
|
||||
private static function extractValue(mixed $value): ?string
|
||||
{
|
||||
if (is_scalar($value)) {
|
||||
return trim((string) $value);
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
// Preferisci i campi descrittivi tipici degli oggetti listino.
|
||||
foreach (['descrizione', 'finitura', 'valore', 'label', 'id'] as $field) {
|
||||
if (isset($value[$field]) && is_scalar($value[$field]) && $value[$field] !== '') {
|
||||
return trim((string) $value[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: primo valore scalare non vuoto.
|
||||
foreach ($value as $v) {
|
||||
if (is_scalar($v) && $v !== '') {
|
||||
return trim((string) $v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function humanize(string $key): string
|
||||
{
|
||||
return ucfirst(str_replace('_', ' ', $key));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Preventivo;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PreventivoPdf
|
||||
{
|
||||
/**
|
||||
* Genera (o rigenera) il PDF del preventivo, lo salva sul disk 'local' e ritorna il path relativo.
|
||||
*/
|
||||
public static function genera(Preventivo $preventivo): string
|
||||
{
|
||||
$preventivo->loadMissing('cliente', 'items');
|
||||
|
||||
$pdf = Pdf::loadView('pdf.preventivo', [
|
||||
'preventivo' => $preventivo,
|
||||
'formatter' => ConfigurazioneFormatter::class,
|
||||
])->setPaper('a4');
|
||||
|
||||
$path = 'preventivi/preventivo-'.$preventivo->id.'.pdf';
|
||||
Storage::disk('local')->put($path, $pdf->output());
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** Path assoluto del PDF sul filesystem (per allegati email). */
|
||||
public static function pathAssoluto(Preventivo $preventivo): ?string
|
||||
{
|
||||
if (! $preventivo->pdf_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk('local')->path($preventivo->pdf_path);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user