backoffice first commit

This commit is contained in:
2026-07-17 11:34:15 +02:00
parent 761db06329
commit 1c40ef6601
159 changed files with 25830 additions and 7875 deletions
@@ -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(),
];
}
}
@@ -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
{
//
}
+37
View File
@@ -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,
],
);
}
}
+52
View File
@@ -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'),
];
}
}
+30
View File
@@ -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}");
}
}
+51
View File
@@ -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;
}
}
+27
View File
@@ -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);
}
}
+32
View File
@@ -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));
}
}
+38
View File
@@ -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);
}
}