7 · Action dan widget dasbor
Tujuan: tombol Publish sekali klik pada baris yang membutuhkannya, dan dasbor yang langsung memberi tahu sesuatu.
Action adalah operasi milik backend yang bisa diminta frontend berdasarkan nama. Definisi yang menyeberang ke browser membawa label, ikon, dan teks konfirmasi — tidak pernah membawa handler-nya. Browser mengirim nama action, slug resource, dan kunci record; server mencari action itu di dalam skema yang mendeklarasikannya, mengotorisasi, lalu menjalankannya.
Lakukan ini — action publikasi
Tambahkan ke record action di tabel. Di app/Panels/Admin/Resources/Products/Tables/ProductsTable.php:
use App\Models\Product;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Date;
use PandaPanel\Actions\Action;
use PandaPanel\Actions\Enums\ActionVariant;2
3
4
5
->recordActions([
Action::make('publish')
->label('Terbitkan')
->icon('check')
->variant(ActionVariant::Outline)
->visible(static fn (?Model $record): bool
=> $record?->getAttribute('status') !== 'published')
->authorize(static fn (?Model $record): bool
=> $record !== null && ProductResource::canEdit($record))
->requiresConfirmation(
heading: 'Terbitkan produk ini?',
description: 'Produk langsung tampil di katalog.',
button: 'Terbitkan',
)
->successMessage('Produk diterbitkan.')
->action(static function (Product $record): void {
$record->forceFill([
'status' => 'published',
'published_at' => $record->published_at ?? Date::now(),
])->save();
}),
ViewAction::make(ProductResource::class),
EditAction::make(ProductResource::class),
DeleteAction::make(ProductResource::class),
])2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Muat ulang daftarnya. Setiap baris berstatus draf kini punya tombol Terbitkan; baris yang sudah terbit tidak. Menekannya memunculkan konfirmasi, mengirim {"resource": "products", "action": "publish", "record": 42}, lalu kembali dengan toast.
Dua penjaga dengan tugas berbeda
->visible(...) // menyembunyikan tombol; tidak menyiratkan soal izin
->authorize(...) // izinnya sendiri, ditanya lagi saat action dijalankan2
visible() urusan tampilan: produk yang sudah terbit tidak perlu tombol Terbitkan. authorize() adalah kontrol akses, dan ditanyakan ulang saat eksekusi — action baris yang ditolak policy tidak muncul di baris itu sekaligus ditolak di endpoint. Menyembunyikan tombol tidak pernah menjadi kontrolnya.
Kedua closure dipanggil dengan null saat action diserialisasi tanpa record — untuk header action atau bulk action — dan itulah sebabnya semua action bawaan diawali $record !== null &&.
Satu action, tiga bentuk
Jenisnya diturunkan dari apa yang Anda berikan, tidak pernah disetel langsung:
| Diberi | Jenis | Yang dilakukan frontend |
|---|---|---|
url() | link | Berpindah ke URL yang dihasilkan server |
schema() | form | Membuka dialog dan mengambil form-nya saat dibuka |
| tidak keduanya | callback | Mengirim nama action ke endpoint action |
// Minta alasan sebelum mengarsipkan — action dengan form sendiri:
Action::make('archive')
->schema(static fn (?Model $record): FormSchema => FormSchema::make()->schema([
Textarea::make('reason')->required(),
]))
->action(static function (Product $record, array $data): void {
$record->forceFill(['status' => 'archived'])->save();
Log::info('Produk diarsipkan', ['id' => $record->getKey(), 'reason' => $data['reason']]);
});2
3
4
5
6
7
8
9
$data adalah isi dialog yang sudah tervalidasi dan ter-dehydrate. Handler yang dideklarasikan dengan satu argumen tidak pernah melihatnya — itulah sebabnya menambahkan form ke action yang sudah ada bersifat aditif.
Tambahkan versi massalnya
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;2
->bulkActions([
Action::make('publish-selected')
->label('Terbitkan yang dipilih')
->icon('check')
->requiresConfirmation()
->successMessageUsing(static fn (int $count): string
=> "{$count} produk diterbitkan.")
->authorizeEachUsing(static fn (Model $record): bool
=> ProductResource::canEdit($record))
->bulkAction(static function (Collection $records): void {
DB::transaction(static function () use ($records): void {
$records->each->forceFill([
'status' => 'published',
'published_at' => Date::now(),
])->each->save();
});
}),
DeleteBulkAction::make(ProductResource::class),
])2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Eksekusi massal mengotorisasi setiap record sebelum menyentuh satu pun, jadi pilihan yang memuat satu record terlarang tidak mengubah apa-apa. executeBulk() sendiri tidak dibungkus transaksi — dengan handler bulkAction() milik Anda, pembungkusannya menjadi tanggung jawab Anda, dan itulah alasan DB::transaction() ada di sana.
Endpoint bulk dibatasi 500 kunci
Yang lebih besar dari itu sebaiknya menjadi table action yang mengantrekan job.
Lakukan ini — widget dasbor
php artisan make:panel-widget ProductStats --panel=Admin --type=stats<?php
declare(strict_types=1);
namespace App\Panels\Admin\Widgets;
use App\Models\Product;
use App\Panels\Admin\Resources\Products\ProductResource;
use PandaPanel\Widgets\Enums\StatColor;
use PandaPanel\Widgets\StatsWidget;
use PandaPanel\Widgets\Support\Stat;
final class ProductStats extends StatsWidget
{
protected static int $sort = 0;
protected static int|string|array $columnSpan = ['default' => 1, 'md' => 2, 'lg' => 3];
protected static ?string $heading = 'Katalog';
protected static ?string $description = 'Semua yang ada di tabel produk saat ini.';
/**
* @return list<Stat>
*/
public function stats(): array
{
$counts = Product::query()
->selectRaw('status, count(*) as total')
->groupBy('status')
->pluck('total', 'status');
return [
Stat::make('Produk', (int) $counts->sum())
->icon('package')
->url(ProductResource::url()),
Stat::make('Terbit', (int) $counts->get('published', 0))
->icon('check')
->color(StatColor::Success)
->description('Tampil di katalog'),
Stat::make('Nilai katalog', (float) Product::query()->sum('price'))
->icon('circle-dollar-sign')
->format(prefix: 'Rp', decimals: 2),
];
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Buka /admin. Widget-nya sudah ada di sana — discoverWidgets(app_path('Panels/Admin/Widgets')) mencakup direktori itu, jadi tidak ada yang perlu didaftarkan.
Fungsi tiap bagian
$sort | Urutan di halaman, menaik |
$columnSpan | Seberapa lebar bagian grid halaman yang ditempati. Responsif, dan dipangkas mengikuti grid |
$heading, $description | Judul dan sub-baris di atas widget |
Stat::make(label, nilai) | Nilai berupa string dicetak apa adanya; angka diformat |
->format(prefix:, suffix:, decimals:) | Pemformatan terjadi di server — sebuah angka adalah nilainya dan cara membacanya |
->url(...) | Menjadikan kartunya tautan. Tujuannya mengotorisasi dirinya sendiri saat dibuka |
->color(StatColor::…) | Himpunan tertutup, karena tiap kasus dipetakan ke kelas Tailwind literal |
Dua hiasan lain yang bisa dipakai Stat
Stat::make('Pendapatan', 12_045)->trend('up', 12.4); // ↗ 12.4% Increased
Stat::make('Pendaftar', 412)->chart([4, 9, 7, 12, 18, 21]); // sparkline, butuh minimal 2 nilai2
Arah tren menentukan warnanya, bukan tanda angkanya — kirim besarannya lalu sebutkan arahnya. Dan hitung sparkline dalam satu query; enam query demi hiasan bukan pertukaran yang sepadan.
Widget juga diotorisasi
public static function canView(): bool
{
return auth()->user()?->is_admin === true;
}2
3
4
Widget yang tidak boleh dilihat pengguna tidak ikut dikirim dalam payload, bukan disembunyikan dengan CSS.
Pastikan berhasil
- Baris produk draf menampilkan Terbitkan; tekan, dan badge barisnya berubah hijau.
- Baris produk yang sudah terbit tidak menampilkannya.
- Pilih dua draf dan terbitkan sekaligus — satu toast, satu jumlah.
/adminmenampilkan tiga angka, dan kartu pertama menautkan ke daftar produk.
Kalau gagal
| Gejala | Penyebab | Perbaikan |
|---|---|---|
PanelSchemaException: inert action | Action tanpa handler, URL, form, maupun modal | Beri action() |
PanelSchemaException: duplicate actions | Dua action memakai nama sama dalam satu set | Ganti salah satunya |
| Tombolnya menghasilkan 400 saat ditekan | Scope-nya butuh handler lain — bulk action perlu bulkAction() atau action() | Sesuaikan handler dengan tempat action-nya |
| Widget tidak muncul | Berada di luar path discovery, atau panel:cache basi | php artisan panel:clear |
| Ikonnya hilang | Key-nya belum ada di registry saat build | php artisan panel:icons |
TypeError pada $record->… | Closure mengakses record null saat serialisasi | Awali dengan $record !== null && |
Berikutnya
Sudah jalan di mesin Anda. Tersisa satu halaman.
Baca juga
- Action basics — API lengkap, dan bagaimana eksekusinya tetap aman
- Bulk actions, Action forms, Modals
- Stats widgets, Chart widgets, Widget layout
- Widget authorization