feat: Aggiungi funzionalità di eliminazione per clienti e preventivi con gestione delle dipendenze
This commit is contained in:
@@ -97,10 +97,15 @@ class ClienteResource extends Resource
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make()
|
||||
->modalDescription('Verrà eliminato definitivamente il cliente insieme a tutti i suoi preventivi e ai file associati (PDF, immagini)')
|
||||
->successRedirectUrl(static::getUrl('index')),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
Tables\Actions\DeleteBulkAction::make()
|
||||
->modalDescription('I clienti selezionati verranno eliminati definitivamente insieme a tutti i loro preventivi e ai file associati (PDF, immagini)')
|
||||
->successRedirectUrl(static::getUrl('index')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ class EditCliente extends EditRecord
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->modalDescription('Verrà eliminato definitivamente il cliente insieme a tutti i suoi preventivi e ai file associati (PDF, immagini).'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ class ViewCliente extends ViewRecord
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->modalDescription('Verrà eliminato definitivamente il cliente insieme a tutti i suoi preventivi e ai file associati (PDF, immagini).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\PreventivoResource\Pages;
|
||||
use App\Filament\Resources\PreventivoResource\RelationManagers\ItemsRelationManager;
|
||||
use App\Models\Cliente;
|
||||
use App\Models\Preventivo;
|
||||
use App\Support\PreventivoPdf;
|
||||
use Filament\Forms;
|
||||
@@ -44,7 +45,13 @@ class PreventivoResource extends Resource
|
||||
Forms\Components\Select::make('cliente_id')
|
||||
->label('Cliente')
|
||||
->relationship('cliente', 'nome')
|
||||
->searchable()
|
||||
->getOptionLabelFromRecordUsing(function (Cliente $record): string {
|
||||
$nome = trim("{$record->nome} {$record->cognome}");
|
||||
$parti = array_filter([$record->ragione_sociale, $nome]);
|
||||
|
||||
return $parti ? implode(' — ', $parti) : (string) $record->username;
|
||||
})
|
||||
->searchable(['ragione_sociale', 'nome', 'cognome'])
|
||||
->preload(),
|
||||
]);
|
||||
}
|
||||
@@ -58,9 +65,14 @@ class PreventivoResource extends Resource
|
||||
->label('N.')
|
||||
->sortable()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('cliente.nominativo')
|
||||
Tables\Columns\TextColumn::make('cliente.ragione_sociale')
|
||||
->label('Ragione sociale')
|
||||
->searchable()
|
||||
->toggleable()
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('cliente.nome_completo')
|
||||
->label('Cliente')
|
||||
->searchable(['nome', 'cognome', 'ragione_sociale'])
|
||||
->searchable(['nome', 'cognome'])
|
||||
->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('cliente.email')
|
||||
->label('Email')
|
||||
@@ -116,7 +128,8 @@ class PreventivoResource extends Resource
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
Tables\Actions\DeleteBulkAction::make()
|
||||
->successRedirectUrl(static::getUrl('index')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Support\ConfigurazioneFormatter;
|
||||
use App\Support\PreventivoPdf;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -122,8 +123,21 @@ class CarrelloController extends Controller
|
||||
'note' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($preventivo, $dati) {
|
||||
$cliente = Cliente::create($dati);
|
||||
// Se la richiesta arriva da un cliente loggato nel configuratore (token Sanctum
|
||||
// valido), il preventivo va agganciato al suo record esistente — aggiornando
|
||||
// l'anagrafica con i dati confermati nel form — e NON a un nuovo cliente.
|
||||
// Il guard Sanctum legge il token Bearer direttamente dalla richiesta:
|
||||
// per i guest (o token assenti/scaduti/non validi) restituisce null.
|
||||
$clienteLoggato = Auth::guard('sanctum')->user();
|
||||
|
||||
DB::transaction(function () use ($preventivo, $dati, $clienteLoggato) {
|
||||
$cliente = $clienteLoggato;
|
||||
|
||||
if ($cliente) {
|
||||
$cliente->update($dati);
|
||||
} else {
|
||||
$cliente = Cliente::create($dati);
|
||||
}
|
||||
|
||||
$preventivo->cliente_id = $cliente->id;
|
||||
$preventivo->stato = 'inviato';
|
||||
|
||||
@@ -31,6 +31,16 @@ class Cliente extends Model
|
||||
return $this->hasMany(Preventivo::class);
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function (Cliente $cliente): void {
|
||||
// Elimina di conseguenza i preventivi del cliente (con i relativi file)
|
||||
// e revoca i token API usati per l'accesso al configuratore.
|
||||
$cliente->preventivi()->get()->each->delete();
|
||||
$cliente->tokens()->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function getNominativoAttribute(): string
|
||||
{
|
||||
if ($this->ragione_sociale) {
|
||||
@@ -41,4 +51,11 @@ class Cliente extends Model
|
||||
|
||||
return $nome !== '' ? $nome : $this->username;
|
||||
}
|
||||
|
||||
public function getNomeCompletoAttribute(): string
|
||||
{
|
||||
$nome = trim("{$this->nome} {$this->cognome}");
|
||||
|
||||
return $nome !== '' ? $nome : $this->username;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Preventivo extends Model
|
||||
{
|
||||
@@ -26,6 +27,24 @@ class Preventivo extends Model
|
||||
'chiuso' => 'Chiuso',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function (Preventivo $preventivo): void {
|
||||
// Elimina immagini degli item (disk 'public') e PDF (disk 'local') prima della cancellazione.
|
||||
foreach ($preventivo->items()->get() as $item) {
|
||||
if ($item->immagine_path && Storage::disk('public')->exists($item->immagine_path)) {
|
||||
Storage::disk('public')->delete($item->immagine_path);
|
||||
}
|
||||
|
||||
$item->delete();
|
||||
}
|
||||
|
||||
if ($preventivo->pdf_path && Storage::disk('local')->exists($preventivo->pdf_path)) {
|
||||
Storage::disk('local')->delete($preventivo->pdf_path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function cliente(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cliente::class);
|
||||
|
||||
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/jpeg" href="/kreios-logo.jpg" />
|
||||
<title>Il tuo preventivo · Krèios</title>
|
||||
<script type="module" crossorigin src="/assets/index-D5nts1N1.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CnLA_o5j.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-vT7pz0E4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -146,7 +146,7 @@ onMounted(loadCarrello)
|
||||
|
||||
<div class="card">
|
||||
<div class="totale-row">
|
||||
<span>Totale <span class="iva-nota">IVA 22% inclusa</span></span>
|
||||
<span>Totale <span class="iva-nota">Prezzi IVA esclusa</span></span>
|
||||
<span>{{ formatEuro(carrello.totale) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,17 @@ async function request(path, options = {}) {
|
||||
export const api = {
|
||||
getCarrello: (token) => request(`/carrello/${token}`),
|
||||
rimuoviItem: (token, itemId) => request(`/carrello/${token}/item/${itemId}`, { method: 'DELETE' }),
|
||||
conferma: (token, dati) =>
|
||||
request(`/carrello/${token}/conferma`, { method: 'POST', body: JSON.stringify(dati) }),
|
||||
// Se l'utente era loggato nel configuratore, passiamo il suo token al backend così
|
||||
// il preventivo viene agganciato al suo record cliente esistente invece di crearne
|
||||
// uno nuovo (è lo stesso token usato per precompilare l'anagrafica).
|
||||
conferma: (token, dati) => {
|
||||
const headers = {}
|
||||
const clienteToken = resolveClienteToken()
|
||||
if (clienteToken) {
|
||||
headers.Authorization = `Bearer ${clienteToken}`
|
||||
}
|
||||
return request(`/carrello/${token}/conferma`, { method: 'POST', body: JSON.stringify(dati), headers })
|
||||
},
|
||||
// Dati anagrafici del cliente loggato nel configuratore, per precompilare il checkout.
|
||||
me: (clienteToken) =>
|
||||
request('/auth/me', { headers: { Authorization: `Bearer ${clienteToken}` } }),
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* La cancellazione di un cliente deve eliminare di conseguenza i suoi
|
||||
* preventivi (e gli item, già in cascata): la FK passa da SET NULL a CASCADE.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('preventivi', function (Blueprint $table) {
|
||||
$table->dropForeign(['cliente_id']);
|
||||
});
|
||||
|
||||
Schema::table('preventivi', function (Blueprint $table) {
|
||||
$table->foreign('cliente_id')->references('id')->on('clienti')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('preventivi', function (Blueprint $table) {
|
||||
$table->dropForeign(['cliente_id']);
|
||||
});
|
||||
|
||||
Schema::table('preventivi', function (Blueprint $table) {
|
||||
$table->foreign('cliente_id')->references('id')->on('clienti')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -12,7 +12,7 @@ Ciao {{ $preventivo->cliente?->nome }},
|
||||
abbiamo ricevuto la tua richiesta di preventivo **n. {{ $preventivo->numero }}**.
|
||||
|
||||
In allegato trovi il **PDF riassuntivo** con le configurazioni che hai selezionato e il totale
|
||||
indicativo (IVA 22% inclusa).
|
||||
indicativo (prezzi IVA esclusa).
|
||||
|
||||
Un nostro consulente ti ricontatterà al più presto per finalizzare l'offerta.
|
||||
|
||||
|
||||
@@ -100,6 +100,6 @@
|
||||
@endforeach
|
||||
|
||||
<div class="totale">Totale: € {{ number_format($preventivo->totale, 2, ',', '.') }}</div>
|
||||
<div class="nota">IVA 22% inclusa. Preventivo indicativo, salvo verifica tecnica e rilievo misure.</div>
|
||||
<div class="nota">Prezzi IVA esclusa. Preventivo indicativo, salvo verifica tecnica e rilievo misure.</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Mail\PreventivoCliente;
|
||||
use App\Models\Cliente;
|
||||
use App\Models\Preventivo;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ConfermaPreventivoTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** Crea un preventivo in bozza con una riga, pronto per la conferma. */
|
||||
private function creaCarrelloBozza(): Preventivo
|
||||
{
|
||||
$preventivo = Preventivo::create([
|
||||
'token' => 'token-test-123',
|
||||
'stato' => 'bozza',
|
||||
'totale' => 0,
|
||||
]);
|
||||
|
||||
$preventivo->items()->create([
|
||||
'sistema' => 'pvc',
|
||||
'type' => 'finestra_1_anta',
|
||||
'nome_modello' => 'Finestra 1 anta',
|
||||
'larghezza' => 100,
|
||||
'altezza' => 100,
|
||||
'quantita' => 1,
|
||||
'prezzo_unitario' => 500,
|
||||
'prezzo_totale' => 500,
|
||||
'payload' => ['nome' => 'Finestra 1 anta'],
|
||||
]);
|
||||
|
||||
$preventivo->ricalcolaTotale();
|
||||
|
||||
return $preventivo;
|
||||
}
|
||||
|
||||
private function datiConferma(): array
|
||||
{
|
||||
return [
|
||||
'nome' => 'Mario',
|
||||
'cognome' => 'Rossi',
|
||||
'ragione_sociale' => null,
|
||||
'email' => 'mario.rossi@example.com',
|
||||
'telefono' => '3331234567',
|
||||
'indirizzo' => 'Via Roma 1',
|
||||
'cap' => '20100',
|
||||
'citta' => 'Milano',
|
||||
'provincia' => 'MI',
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function test_conferma_da_cliente_loggato_non_crea_un_nuovo_cliente(): void
|
||||
{
|
||||
Mockery::mock('alias:App\Support\PreventivoPdf')
|
||||
->shouldReceive('genera')
|
||||
->andReturn('preventivi/preventivo-1.pdf');
|
||||
Mail::fake();
|
||||
|
||||
$cliente = Cliente::create([
|
||||
'username' => 'user1234',
|
||||
'password' => 'Segreta123!',
|
||||
'nome' => 'Mario',
|
||||
'cognome' => 'Bianchi',
|
||||
'email' => 'mario.bianchi@example.com',
|
||||
'telefono' => '3330000000',
|
||||
]);
|
||||
$token = $cliente->createToken('configuratore', ['*'])->plainTextToken;
|
||||
|
||||
$preventivo = $this->creaCarrelloBozza();
|
||||
|
||||
$dati = $this->datiConferma();
|
||||
$dati['cognome'] = 'Rossi'; // diverso dal dato salvato: l'anagrafica deve aggiornarsi
|
||||
|
||||
$response = $this->postJson("/api/carrello/{$preventivo->token}/conferma", $dati, [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Nessun nuovo utente creato: in database c'è ancora solo il cliente originale.
|
||||
$this->assertSame(1, Cliente::count());
|
||||
|
||||
$preventivo->refresh();
|
||||
$this->assertSame($cliente->id, $preventivo->cliente_id);
|
||||
$this->assertSame('inviato', $preventivo->stato);
|
||||
$this->assertNotNull($preventivo->numero);
|
||||
|
||||
// L'anagrafica del cliente loggato è aggiornata con i dati confermati nel form.
|
||||
$this->assertSame('Rossi', $cliente->fresh()->cognome);
|
||||
$this->assertSame('3331234567', $cliente->fresh()->telefono);
|
||||
|
||||
Mail::assertSent(PreventivoCliente::class);
|
||||
}
|
||||
|
||||
public function test_conferma_da_guest_crea_il_cliente(): void
|
||||
{
|
||||
Mockery::mock('alias:App\Support\PreventivoPdf')
|
||||
->shouldReceive('genera')
|
||||
->andReturn('preventivi/preventivo-1.pdf');
|
||||
Mail::fake();
|
||||
|
||||
$this->assertSame(0, Cliente::count());
|
||||
$preventivo = $this->creaCarrelloBozza();
|
||||
|
||||
$response = $this->postJson("/api/carrello/{$preventivo->token}/conferma", $this->datiConferma());
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertSame(1, Cliente::count());
|
||||
|
||||
$preventivo->refresh();
|
||||
$this->assertNotNull($preventivo->cliente_id);
|
||||
$this->assertSame('mario.rossi@example.com', $preventivo->cliente->email);
|
||||
}
|
||||
|
||||
public function test_conferma_con_token_non_valido_tratta_l_utente_come_guest(): void
|
||||
{
|
||||
Mockery::mock('alias:App\Support\PreventivoPdf')
|
||||
->shouldReceive('genera')
|
||||
->andReturn('preventivi/preventivo-1.pdf');
|
||||
Mail::fake();
|
||||
|
||||
$preventivo = $this->creaCarrelloBozza();
|
||||
|
||||
$response = $this->postJson("/api/carrello/{$preventivo->token}/conferma", $this->datiConferma(), [
|
||||
'Authorization' => 'Bearer token-non-esistente',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertSame(1, Cliente::count());
|
||||
$this->assertNotNull($preventivo->fresh()->cliente_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Filament\Resources\ClienteResource;
|
||||
use App\Filament\Resources\ClienteResource\Pages\ListClientes;
|
||||
use App\Filament\Resources\PreventivoResource;
|
||||
use App\Models\Cliente;
|
||||
use App\Models\Preventivo;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* L'eliminazione di un cliente (singola o massiva) deve ridirezionare alla
|
||||
* lista: il reload di pagina fa ri-renderizzare la sidebar, aggiornando il
|
||||
* badge "Preventivi" nella navigazione.
|
||||
*/
|
||||
class EliminazioneClienteTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
}
|
||||
|
||||
private function creaCliente(string $nome, string $username): Cliente
|
||||
{
|
||||
return Cliente::create([
|
||||
'username' => $username,
|
||||
'password' => 'Segreta123!',
|
||||
'nome' => $nome,
|
||||
'email' => strtolower($username).'@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
private function creaPreventivoInviato(Cliente $cliente): Preventivo
|
||||
{
|
||||
return Preventivo::create([
|
||||
'cliente_id' => $cliente->id,
|
||||
'token' => Str::random(40),
|
||||
'stato' => 'inviato',
|
||||
'totale' => 100,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_eliminazione_singola_ridireziona_alla_lista(): void
|
||||
{
|
||||
$cliente = $this->creaCliente('Mario', 'mario-test');
|
||||
$preventivo = $this->creaPreventivoInviato($cliente);
|
||||
|
||||
Livewire::test(ListClientes::class)
|
||||
->callTableAction('delete', $cliente)
|
||||
->assertRedirect(ClienteResource::getUrl('index'));
|
||||
|
||||
$this->assertDatabaseMissing(Cliente::class, ['id' => $cliente->id]);
|
||||
$this->assertDatabaseMissing(Preventivo::class, ['id' => $preventivo->id]);
|
||||
}
|
||||
|
||||
public function test_eliminazione_massiva_ridireziona_alla_lista(): void
|
||||
{
|
||||
$primo = $this->creaCliente('Mario', 'mario-bulk');
|
||||
$secondo = $this->creaCliente('Luigi', 'luigi-bulk');
|
||||
$this->creaPreventivoInviato($primo);
|
||||
$this->creaPreventivoInviato($secondo);
|
||||
|
||||
Livewire::test(ListClientes::class)
|
||||
->callTableBulkAction('delete', [$primo, $secondo])
|
||||
->assertRedirect(ClienteResource::getUrl('index'));
|
||||
|
||||
$this->assertSame(0, Cliente::count());
|
||||
$this->assertSame(0, Preventivo::count());
|
||||
}
|
||||
|
||||
public function test_badge_preventivi_aggiornato_dopo_eliminazione(): void
|
||||
{
|
||||
$conPreventivo = $this->creaCliente('Anna', 'anna-test');
|
||||
$senzaPreventivi = $this->creaCliente('Piero', 'pier-test');
|
||||
$this->creaPreventivoInviato($conPreventivo);
|
||||
$this->creaPreventivoInviato($senzaPreventivi);
|
||||
|
||||
$this->assertSame(2, (int) PreventivoResource::getNavigationBadge());
|
||||
|
||||
Livewire::test(ListClientes::class)
|
||||
->callTableAction('delete', $conPreventivo)
|
||||
->assertRedirect(ClienteResource::getUrl('index'));
|
||||
|
||||
// Dopo l'eliminazione il conteggio ricalcolato dal database è aggiornato.
|
||||
$this->assertSame(1, (int) PreventivoResource::getNavigationBadge());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user