Pelajari cara membangun dashboard yayasan digital menggunakan Laravel Boost dan Filament 3 dengan metode vibe coding. Tutorial praktis untuk developer Indonesia yang ingin cepat bikin admin panel profesional tanpa ribet setup dari nol.
Bagian 1: Opening & Setup Laravel Boost
Kemarin saya ngobrol sama teman yang kerja di yayasan sosial. Dia cerita soal kerjaan sehari-hari — dan honestly, bikin saya geleng-geleng kepala.
Data donatur? Excel. Laporan keuangan? Excel lagi. Tracking penerima bantuan? You guessed it — Excel juga. Semuanya tersebar di berbagai file, berbagai folder, berbagai laptop. Mau cari siapa aja yang udah donasi bulan ini? Scroll manual. Mau bikin laporan tahunan? Copy-paste dari 12 file berbeda.
Dan yang paling bikin pusing — transparansi. Donatur sering tanya: "Dana saya kemarin dipakai untuk apa?" Tim yayasan harus bongkar-bongkar spreadsheet buat jawab pertanyaan simpel kayak gitu.
Ini tahun 2026. Yayasan sosial butuh sistem yang proper.
Problem Yayasan dengan Sistem Manual
Perspektif Admin Yayasan:
- Data donatur tersebar di mana-mana
- Susah tracking siapa sudah donasi berapa total
- Report bulanan bikin manual, prone to error
- Tidak ada reminder untuk follow-up donatur
Perspektif Manajemen:
- Tidak bisa lihat dashboard real-time
- Laporan keuangan telat dan tidak akurat
- Susah ambil keputusan karena data tidak centralized
- Audit jadi nightmare
Perspektif Donatur:
- Tidak tahu dana dipakai untuk apa
- Tidak ada transparansi
- Susah lihat impact dari donasi mereka
Solusi: Dashboard Yayasan Digital
Yang akan kita bangun di tutorial ini:
DASHBOARD YAYASAN DIGITAL
┌─────────────────────────────────────────────────────────┐
│ FITUR UTAMA │
├─────────────────────────────────────────────────────────┤
│ │
│ 📊 Dashboard Analytics │
│ • Total donatur, donasi, penerima manfaat │
│ • Chart donasi per bulan │
│ • Progress program │
│ │
│ 👥 Manajemen Donatur │
│ • Data lengkap donatur │
│ • Histori donasi per orang │
│ • Total kontribusi │
│ │
│ 💰 Manajemen Donasi │
│ • Catat setiap donasi masuk │
│ • Verifikasi pembayaran │
│ • Tracking per program │
│ │
│ 📋 Manajemen Program │
│ • Program beasiswa, sembako, dll │
│ • Target dana vs dana masuk │
│ • Penerima manfaat per program │
│ │
│ 📈 Laporan Keuangan │
│ • Pemasukan & pengeluaran │
│ • Filter per periode │
│ • Summary otomatis │
│ │
└─────────────────────────────────────────────────────────┘
Apa Itu Vibe Coding?
Di tutorial ini, kita pakai pendekatan vibe coding — cara ngoding modern di era AI:
- 70% Prompt — Kita tulis instruksi ke Claude AI untuk generate code
- 30% Review & Edit — Kita review hasilnya, pahami logic-nya, edit kalau perlu
Kenapa vibe coding?
- Lebih cepat — Boilerplate code di-handle AI
- Focus ke logic — Kita mikir "apa yang mau dibikin", bukan "gimana syntax-nya"
- Learning by doing — Sambil review output AI, kita belajar pattern yang baik
Tapi ingat — vibe coding bukan berarti copy-paste buta. 30% review itu crucial. Kamu harus paham code yang di-generate, baru bisa maintain dan extend ke depannya.
Kenapa Laravel Boost?
Laravel Boost adalah starter kit dari BuildWithAngga yang sudah include:
| Fitur | Deskripsi |
|---|---|
| Filament 3 | Admin panel modern, siap pakai |
| Authentication | Login, register, forgot password |
| Spatie Permission | Role & permission management |
| Clean Architecture | Struktur folder yang rapi |
| TailwindCSS | Styling sudah configured |
| Dark Mode | Support tema gelap |
Dengan Laravel Boost, setup yang biasanya 2-3 jam jadi 5 menit. Kita langsung fokus ke business logic.
Tech Stack
| Technology | Fungsi |
|---|---|
| Laravel 12 | PHP framework |
| Laravel Boost | Starter kit BWA |
| Filament 3 | Admin panel |
| MySQL 8 | Database |
| TailwindCSS | Styling |
Prerequisite
Sebelum mulai, pastikan kamu sudah install:
- PHP 8.2+
- Composer
- MySQL 8
- Node.js 18+
- Code editor (VS Code recommended)
Kalau belum familiar dengan Laravel, saya recommend ambil dulu kelas Laravel Fundamental di BuildWithAngga.
Setup Laravel Boost
Prompt ke Claude
Saya mau setup project Laravel Boost untuk dashboard yayasan digital.
Tolong buatkan langkah-langkah setup dari awal:
1. Clone atau download Laravel Boost
2. Install dependencies
3. Setup environment
4. Setup database MySQL dengan nama "yayasan_digital"
5. Jalankan migration dan seeder
6. Test akses ke Filament admin panel
Berikan command lengkap yang bisa saya copy-paste di terminal.
Review Output (30%)
Berikut langkah setup yang sudah saya review dan sesuaikan:
Step 1: Download Laravel Boost
# Clone dari repository (atau download ZIP dari laravelboost.com)
git clone <https://github.com/buildwithangga/laravel-boost.git> yayasan-digital
# Masuk ke folder project
cd yayasan-digital
Step 2: Install Dependencies
# Install PHP dependencies
composer install
# Install Node dependencies
npm install
Step 3: Setup Environment
# Copy file environment
cp .env.example .env
# Generate application key
php artisan key:generate
Step 4: Konfigurasi Database
Buka file .env dan update bagian database:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=yayasan_digital
DB_USERNAME=root
DB_PASSWORD=
Buat database di MySQL:
mysql -u root -p -e "CREATE DATABASE yayasan_digital"
Step 5: Jalankan Migration & Seeder
# Jalankan migration
php artisan migrate
# Jalankan seeder untuk admin user
php artisan db:seed
Step 6: Build Assets & Jalankan Server
# Build CSS/JS
npm run build
# Jalankan server
php artisan serve
Step 7: Test Login Filament
Buka http://localhost:8000/admin dan login dengan:
- Email:
[email protected] - Password:
password
Kalau berhasil masuk ke dashboard Filament, setup sudah selesai!
Yang Perlu Diperhatikan:
- ✅ Laravel Boost sudah include Filament, tidak perlu install terpisah
- ✅ User admin sudah di-seed otomatis
- ⚠️ Ganti password admin di production!
- ⚠️ Pastikan PHP extensions (pdo_mysql, mbstring, openssl) sudah aktif
Struktur Project
Setelah setup, struktur folder Laravel Boost:
yayasan-digital/
├── app/
│ ├── Filament/
│ │ ├── Resources/ # CRUD resources (akan kita buat)
│ │ ├── Widgets/ # Dashboard widgets
│ │ └── Pages/ # Custom pages
│ ├── Models/ # Eloquent models
│ └── ...
├── database/
│ ├── migrations/ # Database migrations
│ └── seeders/ # Data seeders
├── resources/
│ └── views/ # Blade views
├── routes/
└── ...
Fokus utama kita ada di folder app/Filament/Resources/ — di sinilah semua CRUD akan dibuat.
Di bagian selanjutnya, kita akan mulai bikin CRUD pertama: Donatur dan Program.
Bagian 2: CRUD Donatur & Program
Sekarang kita mulai bikin CRUD pertama. Di bagian ini, kita akan buat dua resource sekaligus: Donatur dan Program. Dua data ini adalah fondasi dari sistem yayasan — tanpa donatur, tidak ada donasi; tanpa program, tidak ada tujuan.
CRUD Donatur
Donatur adalah orang-orang baik yang menyumbangkan dana mereka ke yayasan. Kita perlu track:
- Data pribadi (nama, email, phone, alamat)
- Total donasi yang sudah mereka berikan
- Berapa kali mereka berdonasi
Prompt ke Claude: Migration & Model Donatur
Buatkan migration dan model untuk tabel "donatur" di Laravel dengan field:
- id (UUID)
- nama (string, required)
- email (string, unique, required)
- phone (string, nullable)
- alamat (text, nullable)
- total_donasi (decimal 15,2 default 0) - untuk computed total
- jumlah_donasi (integer default 0) - untuk count berapa kali donasi
- timestamps
Model harus:
- Pakai UUID sebagai primary key
- Punya fillable untuk semua field
- Punya casting untuk total_donasi ke decimal
Berikan juga contoh seeder dengan 5 data dummy donatur Indonesia.
Review Output (30%)
database/migrations/2026_01_01_000001_create_donaturs_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('donaturs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('nama');
$table->string('email')->unique();
$table->string('phone')->nullable();
$table->text('alamat')->nullable();
$table->decimal('total_donasi', 15, 2)->default(0);
$table->integer('jumlah_donasi')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('donaturs');
}
};
app/Models/Donatur.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
class Donatur extends Model
{
use HasFactory, HasUuids;
protected $fillable = [
'nama',
'email',
'phone',
'alamat',
'total_donasi',
'jumlah_donasi',
];
protected $casts = [
'total_donasi' => 'decimal:2',
'jumlah_donasi' => 'integer',
];
// Relasi ke donasi (akan dibuat nanti)
public function donasis()
{
return $this->hasMany(Donasi::class);
}
// Format total donasi ke Rupiah
public function getTotalDonasiFormattedAttribute(): string
{
return 'Rp ' . number_format($this->total_donasi, 0, ',', '.');
}
}
database/seeders/DonaturSeeder.php:
<?php
namespace Database\\Seeders;
use App\\Models\\Donatur;
use Illuminate\\Database\\Seeder;
class DonaturSeeder extends Seeder
{
public function run(): void
{
$donaturs = [
[
'nama' => 'Budi Santoso',
'email' => '[email protected]',
'phone' => '081234567890',
'alamat' => 'Jl. Sudirman No. 123, Jakarta Selatan',
'total_donasi' => 5000000,
'jumlah_donasi' => 3,
],
[
'nama' => 'Siti Rahayu',
'email' => '[email protected]',
'phone' => '082345678901',
'alamat' => 'Jl. Gatot Subroto No. 45, Bandung',
'total_donasi' => 2500000,
'jumlah_donasi' => 2,
],
[
'nama' => 'Ahmad Hidayat',
'email' => '[email protected]',
'phone' => '083456789012',
'alamat' => 'Jl. Diponegoro No. 78, Surabaya',
'total_donasi' => 10000000,
'jumlah_donasi' => 5,
],
[
'nama' => 'Dewi Lestari',
'email' => '[email protected]',
'phone' => '084567890123',
'alamat' => 'Jl. Ahmad Yani No. 90, Semarang',
'total_donasi' => 1500000,
'jumlah_donasi' => 1,
],
[
'nama' => 'Rudi Hermawan',
'email' => '[email protected]',
'phone' => '085678901234',
'alamat' => 'Jl. Merdeka No. 56, Yogyakarta',
'total_donasi' => 7500000,
'jumlah_donasi' => 4,
],
];
foreach ($donaturs as $donatur) {
Donatur::create($donatur);
}
}
}
Yang Perlu Diperhatikan:
- ✅ UUID sudah pakai trait
HasUuidsbawaan Laravel - ✅ Decimal 15,2 cukup untuk nominal sampai triliunan
- ⚠️ Jangan lupa register seeder di
DatabaseSeeder.php - 🔧 Tambahkan accessor
getTotalDonasiFormattedAttributeuntuk format Rupiah
Jalankan migration dan seeder:
php artisan migrate
php artisan db:seed --class=DonaturSeeder
Prompt ke Claude: Filament Resource Donatur
Buatkan Filament 3 Resource untuk model Donatur dengan:
Form:
- nama (TextInput, required, max 255)
- email (TextInput, email, required, unique)
- phone (TextInput, tel, nullable)
- alamat (Textarea, nullable, rows 3)
Table:
- nama (searchable, sortable)
- email (searchable)
- phone
- total_donasi (format money Indonesia, sortable)
- jumlah_donasi (suffix "kali", sortable)
- created_at (date format Indonesia)
Filters:
- Donatur dengan donasi > 0
- Filter by created date
Actions: View, Edit, Delete dengan confirmation
Pakai bahasa Indonesia untuk semua label.
Review Output (30%)
app/Filament/Resources/DonaturResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\DonaturResource\\Pages;
use App\\Models\\Donatur;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class DonaturResource extends Resource
{
protected static ?string $model = Donatur::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationLabel = 'Donatur';
protected static ?string $modelLabel = 'Donatur';
protected static ?string $pluralModelLabel = 'Donatur';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Data Donatur')
->schema([
Forms\\Components\\TextInput::make('nama')
->label('Nama Lengkap')
->required()
->maxLength(255)
->placeholder('Masukkan nama lengkap'),
Forms\\Components\\TextInput::make('email')
->label('Email')
->email()
->required()
->unique(ignoreRecord: true)
->placeholder('[email protected]'),
Forms\\Components\\TextInput::make('phone')
->label('Nomor Telepon')
->tel()
->placeholder('08xxxxxxxxxx'),
Forms\\Components\\Textarea::make('alamat')
->label('Alamat')
->rows(3)
->placeholder('Masukkan alamat lengkap'),
])
->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('nama')
->label('Nama')
->searchable()
->sortable()
->weight('bold'),
Tables\\Columns\\TextColumn::make('email')
->label('Email')
->searchable()
->icon('heroicon-o-envelope'),
Tables\\Columns\\TextColumn::make('phone')
->label('Telepon')
->icon('heroicon-o-phone'),
Tables\\Columns\\TextColumn::make('total_donasi')
->label('Total Donasi')
->money('IDR')
->sortable()
->color('success'),
Tables\\Columns\\TextColumn::make('jumlah_donasi')
->label('Jumlah Donasi')
->suffix(' kali')
->sortable()
->alignCenter(),
Tables\\Columns\\TextColumn::make('created_at')
->label('Terdaftar')
->date('d M Y')
->sortable(),
])
->filters([
Tables\\Filters\\Filter::make('pernah_donasi')
->label('Pernah Donasi')
->query(fn ($query) => $query->where('jumlah_donasi', '>', 0)),
Tables\\Filters\\Filter::make('donatur_baru')
->label('Donatur Baru (30 hari)')
->query(fn ($query) => $query->where('created_at', '>=', now()->subDays(30))),
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make()
->requiresConfirmation()
->modalHeading('Hapus Donatur')
->modalDescription('Yakin ingin menghapus data donatur ini? Data donasi terkait juga akan terhapus.')
->modalSubmitActionLabel('Ya, Hapus'),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc')
->emptyStateHeading('Belum ada donatur')
->emptyStateDescription('Donatur akan muncul di sini setelah ditambahkan.')
->emptyStateIcon('heroicon-o-users');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListDonaturs::route('/'),
'create' => Pages\\CreateDonatur::route('/create'),
'view' => Pages\\ViewDonatur::route('/{record}'),
'edit' => Pages\\EditDonatur::route('/{record}/edit'),
];
}
}
Generate Pages:
php artisan make:filament-resource Donatur --generate
Atau buat manual pages-nya:
app/Filament/Resources/DonaturResource/Pages/ListDonaturs.php:
<?php
namespace App\\Filament\\Resources\\DonaturResource\\Pages;
use App\\Filament\\Resources\\DonaturResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\ListRecords;
class ListDonaturs extends ListRecords
{
protected static string $resource = DonaturResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\CreateAction::make()
->label('Tambah Donatur'),
];
}
}
app/Filament/Resources/DonaturResource/Pages/CreateDonatur.php:
<?php
namespace App\\Filament\\Resources\\DonaturResource\\Pages;
use App\\Filament\\Resources\\DonaturResource;
use Filament\\Resources\\Pages\\CreateRecord;
class CreateDonatur extends CreateRecord
{
protected static string $resource = DonaturResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}
app/Filament/Resources/DonaturResource/Pages/EditDonatur.php:
<?php
namespace App\\Filament\\Resources\\DonaturResource\\Pages;
use App\\Filament\\Resources\\DonaturResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\EditRecord;
class EditDonatur extends EditRecord
{
protected static string $resource = DonaturResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\ViewAction::make(),
Actions\\DeleteAction::make(),
];
}
}
app/Filament/Resources/DonaturResource/Pages/ViewDonatur.php:
<?php
namespace App\\Filament\\Resources\\DonaturResource\\Pages;
use App\\Filament\\Resources\\DonaturResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\ViewRecord;
class ViewDonatur extends ViewRecord
{
protected static string $resource = DonaturResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\EditAction::make(),
];
}
}
Yang Perlu Diperhatikan:
- ✅ Label sudah Bahasa Indonesia
- ✅ Format money pakai
>money('IDR') - ✅ Navigation group untuk organize menu
- 🔧 Tambahkan
>unique(ignoreRecord: true)agar edit tidak error
CRUD Program
Program adalah kegiatan atau tujuan yayasan — misalnya program beasiswa, sembako, atau bantuan bencana. Setiap program punya target dana yang ingin dicapai.
Prompt ke Claude: Migration & Model Program
Buatkan migration dan model untuk tabel "programs" di Laravel dengan field:
- id (UUID)
- nama (string, required)
- slug (string, unique) - auto generate dari nama
- deskripsi (text, nullable)
- target_dana (decimal 15,2, required)
- dana_masuk (decimal 15,2, default 0) - computed dari donasi
- status (enum: aktif, selesai, ditunda, default aktif)
- gambar (string, nullable) - path ke image
- timestamps
Model harus:
- Pakai UUID
- Auto generate slug dari nama
- Punya accessor untuk hitung persentase progress
- Punya relasi ke penerima_manfaat (hasMany)
Berikan juga seeder dengan 4 program contoh yayasan.
Review Output (30%)
database/migrations/2026_01_01_000002_create_programs_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('programs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('nama');
$table->string('slug')->unique();
$table->text('deskripsi')->nullable();
$table->decimal('target_dana', 15, 2);
$table->decimal('dana_masuk', 15, 2)->default(0);
$table->enum('status', ['aktif', 'selesai', 'ditunda'])->default('aktif');
$table->string('gambar')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('programs');
}
};
app/Models/Program.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Support\\Str;
class Program extends Model
{
use HasFactory, HasUuids;
protected $fillable = [
'nama',
'slug',
'deskripsi',
'target_dana',
'dana_masuk',
'status',
'gambar',
];
protected $casts = [
'target_dana' => 'decimal:2',
'dana_masuk' => 'decimal:2',
];
// Auto generate slug dari nama
protected static function boot()
{
parent::boot();
static::creating(function ($program) {
if (empty($program->slug)) {
$program->slug = Str::slug($program->nama);
}
});
static::updating(function ($program) {
if ($program->isDirty('nama')) {
$program->slug = Str::slug($program->nama);
}
});
}
// Relasi ke donasi
public function donasis()
{
return $this->hasMany(Donasi::class);
}
// Relasi ke penerima manfaat
public function penerimaManfaats()
{
return $this->hasMany(PenerimaManfaat::class);
}
// Hitung persentase progress
public function getProgressPercentageAttribute(): float
{
if ($this->target_dana <= 0) {
return 0;
}
$percentage = ($this->dana_masuk / $this->target_dana) * 100;
return min($percentage, 100); // Cap at 100%
}
// Format target dana
public function getTargetDanaFormattedAttribute(): string
{
return 'Rp ' . number_format($this->target_dana, 0, ',', '.');
}
// Format dana masuk
public function getDanaMasukFormattedAttribute(): string
{
return 'Rp ' . number_format($this->dana_masuk, 0, ',', '.');
}
// Status badge color
public function getStatusColorAttribute(): string
{
return match($this->status) {
'aktif' => 'success',
'selesai' => 'info',
'ditunda' => 'warning',
default => 'gray',
};
}
}
database/seeders/ProgramSeeder.php:
<?php
namespace Database\\Seeders;
use App\\Models\\Program;
use Illuminate\\Database\\Seeder;
class ProgramSeeder extends Seeder
{
public function run(): void
{
$programs = [
[
'nama' => 'Beasiswa Anak Yatim',
'deskripsi' => 'Program beasiswa pendidikan untuk anak-anak yatim dari tingkat SD hingga SMA. Mencakup biaya sekolah, buku, dan seragam.',
'target_dana' => 100000000,
'dana_masuk' => 75000000,
'status' => 'aktif',
],
[
'nama' => 'Sembako Ramadhan',
'deskripsi' => 'Pembagian paket sembako untuk keluarga kurang mampu selama bulan Ramadhan. Setiap paket berisi beras, minyak, gula, dan kebutuhan pokok lainnya.',
'target_dana' => 50000000,
'dana_masuk' => 50000000,
'status' => 'selesai',
],
[
'nama' => 'Bantuan Bencana Alam',
'deskripsi' => 'Dana tanggap darurat untuk membantu korban bencana alam. Digunakan untuk kebutuhan mendesak seperti makanan, pakaian, dan tempat tinggal sementara.',
'target_dana' => 200000000,
'dana_masuk' => 45000000,
'status' => 'aktif',
],
[
'nama' => 'Renovasi Masjid',
'deskripsi' => 'Program renovasi dan perbaikan masjid-masjid di daerah terpencil yang membutuhkan.',
'target_dana' => 150000000,
'dana_masuk' => 0,
'status' => 'ditunda',
],
];
foreach ($programs as $program) {
Program::create($program);
}
}
}
Prompt ke Claude: Filament Resource Program
Buatkan Filament 3 Resource untuk model Program dengan:
Form:
- nama (TextInput, required)
- slug (TextInput, disabled, auto-filled dari nama via Alpine.js)
- deskripsi (RichEditor atau Textarea)
- target_dana (TextInput, numeric, prefix "Rp", required)
- status (Select: aktif, selesai, ditunda)
- gambar (FileUpload, image only, directory "programs")
Table:
- gambar (ImageColumn, circular)
- nama (searchable, sortable, with description preview)
- target_dana (money format)
- dana_masuk (money format)
- progress (ProgressColumn atau custom dengan percentage)
- status (BadgeColumn dengan warna sesuai status)
- jumlah penerima (count relation)
Filters:
- Status
- Progress (< 50%, 50-99%, 100%)
Actions: View, Edit, Delete
Review Output (30%)
app/Filament/Resources/ProgramResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\ProgramResource\\Pages;
use App\\Models\\Program;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Support\\Str;
class ProgramResource extends Resource
{
protected static ?string $model = Program::class;
protected static ?string $navigationIcon = 'heroicon-o-heart';
protected static ?string $navigationLabel = 'Program';
protected static ?string $modelLabel = 'Program';
protected static ?string $pluralModelLabel = 'Program';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Program')
->schema([
Forms\\Components\\TextInput::make('nama')
->label('Nama Program')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) =>
$set('slug', Str::slug($state))
),
Forms\\Components\\TextInput::make('slug')
->label('Slug')
->disabled()
->dehydrated()
->unique(ignoreRecord: true),
Forms\\Components\\Textarea::make('deskripsi')
->label('Deskripsi')
->rows(4)
->placeholder('Jelaskan tentang program ini...'),
Forms\\Components\\TextInput::make('target_dana')
->label('Target Dana')
->required()
->numeric()
->prefix('Rp')
->placeholder('100000000'),
Forms\\Components\\Select::make('status')
->label('Status')
->options([
'aktif' => 'Aktif',
'selesai' => 'Selesai',
'ditunda' => 'Ditunda',
])
->default('aktif')
->required(),
Forms\\Components\\FileUpload::make('gambar')
->label('Gambar Program')
->image()
->directory('programs')
->imageResizeMode('cover')
->imageCropAspectRatio('16:9')
->imageResizeTargetWidth('1200')
->imageResizeTargetHeight('675'),
])
->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\ImageColumn::make('gambar')
->label('')
->circular()
->defaultImageUrl(url('/images/placeholder-program.png')),
Tables\\Columns\\TextColumn::make('nama')
->label('Program')
->searchable()
->sortable()
->weight('bold')
->description(fn (Program $record): string =>
Str::limit($record->deskripsi ?? '', 50)
),
Tables\\Columns\\TextColumn::make('target_dana')
->label('Target')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('dana_masuk')
->label('Terkumpul')
->money('IDR')
->sortable()
->color('success'),
Tables\\Columns\\TextColumn::make('progress_percentage')
->label('Progress')
->formatStateUsing(fn (Program $record): string =>
number_format($record->progress_percentage, 0) . '%'
)
->badge()
->color(fn (Program $record): string => match(true) {
$record->progress_percentage >= 100 => 'success',
$record->progress_percentage >= 50 => 'warning',
default => 'danger',
}),
Tables\\Columns\\TextColumn::make('status')
->label('Status')
->badge()
->color(fn (string $state): string => match($state) {
'aktif' => 'success',
'selesai' => 'info',
'ditunda' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => ucfirst($state)),
Tables\\Columns\\TextColumn::make('penerima_manfaats_count')
->label('Penerima')
->counts('penerimaManfaats')
->suffix(' orang')
->alignCenter(),
])
->filters([
Tables\\Filters\\SelectFilter::make('status')
->label('Status')
->options([
'aktif' => 'Aktif',
'selesai' => 'Selesai',
'ditunda' => 'Ditunda',
]),
Tables\\Filters\\Filter::make('progress_rendah')
->label('Progress < 50%')
->query(fn ($query) => $query->whereRaw('(dana_masuk / target_dana * 100) < 50')),
Tables\\Filters\\Filter::make('hampir_tercapai')
->label('Progress 50-99%')
->query(fn ($query) => $query->whereRaw('(dana_masuk / target_dana * 100) >= 50 AND (dana_masuk / target_dana * 100) < 100')),
Tables\\Filters\\Filter::make('tercapai')
->label('Progress 100%')
->query(fn ($query) => $query->whereRaw('(dana_masuk / target_dana * 100) >= 100')),
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make()
->requiresConfirmation(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc')
->emptyStateHeading('Belum ada program')
->emptyStateDescription('Program yayasan akan muncul di sini.')
->emptyStateIcon('heroicon-o-heart');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListPrograms::route('/'),
'create' => Pages\\CreateProgram::route('/create'),
'view' => Pages\\ViewProgram::route('/{record}'),
'edit' => Pages\\EditProgram::route('/{record}/edit'),
];
}
}
Buat pages dengan command:
php artisan make:filament-resource Program --generate
Yang Perlu Diperhatikan:
- ✅ Slug auto-generate pakai
live(onBlur: true)danafterStateUpdated - ✅ Progress percentage pakai accessor dari model
- ✅ Badge color dynamic berdasarkan status dan progress
- ⚠️ Pastikan folder
storage/app/public/programsada untuk upload gambar - 🔧 Jalankan
php artisan storage:linkuntuk akses gambar
Update DatabaseSeeder
Jangan lupa register seeder di database/seeders/DatabaseSeeder.php:
<?php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
DonaturSeeder::class,
ProgramSeeder::class,
]);
}
}
Jalankan migration dan seeder:
php artisan migrate:fresh --seed
Test di Browser
Buka http://localhost:8000/admin dan cek:
- Menu "Donatur" di sidebar
- Menu "Program" di sidebar
- CRUD berfungsi dengan baik
- Filter dan search working
- Badge warna sesuai status
Di bagian selanjutnya, kita akan bikin CRUD Penerima Manfaat yang berelasi dengan Program.
Bagian 3: CRUD Penerima Manfaat
Penerima manfaat adalah orang-orang yang mendapatkan bantuan dari program yayasan. Setiap penerima terhubung ke satu program tertentu — misalnya anak yang dapat beasiswa, atau keluarga yang menerima sembako.
Data ini penting untuk:
- Tracking siapa saja yang sudah dibantu
- Laporan ke donatur (transparansi)
- Menghindari duplikasi bantuan
- Dokumentasi untuk audit
Migration & Model Penerima Manfaat
Prompt ke Claude
Buatkan migration dan model untuk tabel "penerima_manfaats" di Laravel dengan field:
- id (UUID)
- program_id (UUID, foreign key ke programs)
- nama (string, required)
- nik (string 16 karakter, unique, required) - Nomor Induk Kependudukan
- alamat (text, required)
- phone (string, nullable)
- status (enum: terdaftar, menerima, selesai - default terdaftar)
- foto (string, nullable) - path ke foto KTP/identitas
- keterangan (text, nullable) - catatan tambahan
- timestamps
Model harus:
- Pakai UUID
- Punya relasi belongsTo ke Program
- Validasi NIK harus 16 digit angka
- Punya accessor untuk status badge color
Berikan juga seeder dengan 6 data penerima manfaat yang terhubung ke program yang sudah ada.
Review Output (30%)
database/migrations/2026_01_01_000003_create_penerima_manfaats_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('penerima_manfaats', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('program_id')->constrained('programs')->onDelete('cascade');
$table->string('nama');
$table->string('nik', 16)->unique();
$table->text('alamat');
$table->string('phone')->nullable();
$table->enum('status', ['terdaftar', 'menerima', 'selesai'])->default('terdaftar');
$table->string('foto')->nullable();
$table->text('keterangan')->nullable();
$table->timestamps();
$table->index('program_id');
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('penerima_manfaats');
}
};
app/Models/PenerimaManfaat.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class PenerimaManfaat extends Model
{
use HasFactory, HasUuids;
protected $table = 'penerima_manfaats';
protected $fillable = [
'program_id',
'nama',
'nik',
'alamat',
'phone',
'status',
'foto',
'keterangan',
];
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function program(): BelongsTo
{
return $this->belongsTo(Program::class);
}
public function getStatusLabelAttribute(): string
{
return match($this->status) {
'terdaftar' => 'Terdaftar',
'menerima' => 'Menerima Bantuan',
'selesai' => 'Selesai',
default => $this->status,
};
}
public function getStatusColorAttribute(): string
{
return match($this->status) {
'terdaftar' => 'warning',
'menerima' => 'success',
'selesai' => 'info',
default => 'gray',
};
}
// Format NIK dengan spasi setiap 4 digit untuk readability
public function getNikFormattedAttribute(): string
{
return implode(' ', str_split($this->nik, 4));
}
}
database/seeders/PenerimaManfaatSeeder.php:
<?php
namespace Database\\Seeders;
use App\\Models\\PenerimaManfaat;
use App\\Models\\Program;
use Illuminate\\Database\\Seeder;
class PenerimaManfaatSeeder extends Seeder
{
public function run(): void
{
// Ambil program yang sudah ada
$beasiswa = Program::where('nama', 'Beasiswa Anak Yatim')->first();
$sembako = Program::where('nama', 'Sembako Ramadhan')->first();
$bencana = Program::where('nama', 'Bantuan Bencana Alam')->first();
if (!$beasiswa || !$sembako || !$bencana) {
$this->command->warn('Program belum ada. Jalankan ProgramSeeder terlebih dahulu.');
return;
}
$penerimaBeassiwa = [
[
'program_id' => $beasiswa->id,
'nama' => 'Andi Pratama',
'nik' => '3201234567890001',
'alamat' => 'Jl. Melati No. 12, RT 03/RW 05, Kelurahan Sukamaju, Kecamatan Cikarang, Bekasi',
'phone' => '081234567001',
'status' => 'menerima',
'keterangan' => 'Siswa kelas 5 SD, yatim piatu sejak 2023',
],
[
'program_id' => $beasiswa->id,
'nama' => 'Siti Nurhaliza',
'nik' => '3201234567890002',
'alamat' => 'Jl. Kenanga No. 45, RT 01/RW 02, Kelurahan Cibiru, Bandung',
'phone' => '081234567002',
'status' => 'menerima',
'keterangan' => 'Siswi kelas 2 SMP, yatim sejak 2024',
],
];
$penerimaSembako = [
[
'program_id' => $sembako->id,
'nama' => 'Hj. Suminah',
'nik' => '3201234567890003',
'alamat' => 'Jl. Dahlia No. 78, RT 04/RW 03, Kelurahan Margahayu, Bandung',
'phone' => '081234567003',
'status' => 'selesai',
'keterangan' => 'Janda, 3 anak, penghasilan tidak tetap',
],
[
'program_id' => $sembako->id,
'nama' => 'Pak Joko Widodo',
'nik' => '3201234567890004',
'alamat' => 'Jl. Mawar No. 23, RT 02/RW 01, Kelurahan Cimahi, Cimahi',
'phone' => '081234567004',
'status' => 'selesai',
'keterangan' => 'Buruh harian, 4 anak masih sekolah',
],
];
$penerimaBencana = [
[
'program_id' => $bencana->id,
'nama' => 'Keluarga Bp. Surya',
'nik' => '3201234567890005',
'alamat' => 'Dusun Sukamulya, Desa Cianjur, Kecamatan Pacet, Cianjur',
'phone' => '081234567005',
'status' => 'terdaftar',
'keterangan' => 'Rumah rusak akibat longsor, mengungsi di posko',
],
[
'program_id' => $bencana->id,
'nama' => 'Ibu Ratna Sari',
'nik' => '3201234567890006',
'alamat' => 'Dusun Mekarjaya, Desa Cianjur, Kecamatan Pacet, Cianjur',
'phone' => '081234567006',
'status' => 'menerima',
'keterangan' => 'Korban banjir, kehilangan harta benda',
],
];
$allPenerima = array_merge($penerimaBeassiwa, $penerimaSembako, $penerimaBencana);
foreach ($allPenerima as $penerima) {
PenerimaManfaat::create($penerima);
}
}
}
Yang Perlu Diperhatikan:
- ✅ Foreign key pakai
foreignUuid()karena programs pakai UUID - ✅ NIK dibatasi 16 karakter sesuai standar Indonesia
- ✅ Cascade delete — kalau program dihapus, penerima juga terhapus
- ⚠️ Seeder bergantung pada ProgramSeeder, pastikan urutan benar
- 🔧 Tambahkan index untuk kolom yang sering di-query (program_id, status)
Filament Resource Penerima Manfaat
Prompt ke Claude
Buatkan Filament 3 Resource untuk model PenerimaManfaat dengan:
Form:
- program_id (Select, searchable, required, relasi ke Program dengan format "nama - status")
- nama (TextInput, required)
- nik (TextInput, required, 16 karakter, numeric only, unique)
- alamat (Textarea, required, rows 3)
- phone (TextInput, tel, mask 08xx-xxxx-xxxx)
- status (Select: terdaftar, menerima, selesai)
- foto (FileUpload, image, directory "penerima")
- keterangan (Textarea, nullable)
Table:
- foto (ImageColumn, circular, size 40)
- nama (searchable, sortable)
- nik (searchable, formatted dengan spasi)
- program.nama (dengan badge warna sesuai status program)
- status (BadgeColumn dengan warna)
- phone
- created_at (date Indonesia)
Filters:
- Filter by program
- Filter by status
- Filter tanggal pendaftaran
Actions: View, Edit, Delete
Bulk Actions: Delete, Update Status
Tambahkan validasi NIK harus 16 digit angka.
Semua label dalam Bahasa Indonesia.
Review Output (30%)
app/Filament/Resources/PenerimaManfaatResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\PenerimaManfaatResource\\Pages;
use App\\Models\\PenerimaManfaat;
use App\\Models\\Program;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
class PenerimaManfaatResource extends Resource
{
protected static ?string $model = PenerimaManfaat::class;
protected static ?string $navigationIcon = 'heroicon-o-user-group';
protected static ?string $navigationLabel = 'Penerima Manfaat';
protected static ?string $modelLabel = 'Penerima Manfaat';
protected static ?string $pluralModelLabel = 'Penerima Manfaat';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 3;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Data Penerima')
->schema([
Forms\\Components\\Select::make('program_id')
->label('Program')
->relationship('program', 'nama')
->getOptionLabelFromRecordUsing(fn (Program $record) =>
"{$record->nama} — " . ucfirst($record->status)
)
->searchable()
->preload()
->required()
->placeholder('Pilih program'),
Forms\\Components\\TextInput::make('nama')
->label('Nama Lengkap')
->required()
->maxLength(255)
->placeholder('Masukkan nama lengkap'),
Forms\\Components\\TextInput::make('nik')
->label('NIK (Nomor Induk Kependudukan)')
->required()
->length(16)
->numeric()
->unique(ignoreRecord: true)
->placeholder('16 digit NIK')
->helperText('Masukkan 16 digit NIK sesuai KTP'),
Forms\\Components\\Textarea::make('alamat')
->label('Alamat Lengkap')
->required()
->rows(3)
->placeholder('Masukkan alamat lengkap termasuk RT/RW'),
Forms\\Components\\TextInput::make('phone')
->label('Nomor Telepon')
->tel()
->placeholder('08xxxxxxxxxx'),
Forms\\Components\\Select::make('status')
->label('Status')
->options([
'terdaftar' => 'Terdaftar',
'menerima' => 'Menerima Bantuan',
'selesai' => 'Selesai',
])
->default('terdaftar')
->required(),
])
->columns(2),
Forms\\Components\\Section::make('Dokumen & Catatan')
->schema([
Forms\\Components\\FileUpload::make('foto')
->label('Foto KTP / Identitas')
->image()
->directory('penerima')
->imageResizeMode('cover')
->imageCropAspectRatio('3:2')
->imageResizeTargetWidth('800')
->imageResizeTargetHeight('533')
->helperText('Upload foto KTP atau kartu identitas'),
Forms\\Components\\Textarea::make('keterangan')
->label('Keterangan Tambahan')
->rows(3)
->placeholder('Catatan kondisi penerima, kebutuhan khusus, dll'),
])
->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\ImageColumn::make('foto')
->label('')
->circular()
->size(40)
->defaultImageUrl(fn () => '<https://ui-avatars.com/api/?name=P&background=random>'),
Tables\\Columns\\TextColumn::make('nama')
->label('Nama')
->searchable()
->sortable()
->weight('bold'),
Tables\\Columns\\TextColumn::make('nik')
->label('NIK')
->searchable()
->formatStateUsing(fn (string $state): string =>
implode(' ', str_split($state, 4))
)
->fontFamily('mono')
->copyable()
->copyMessage('NIK disalin'),
Tables\\Columns\\TextColumn::make('program.nama')
->label('Program')
->badge()
->color(fn (PenerimaManfaat $record): string =>
$record->program?->status_color ?? 'gray'
)
->sortable(),
Tables\\Columns\\TextColumn::make('status')
->label('Status')
->badge()
->color(fn (string $state): string => match($state) {
'terdaftar' => 'warning',
'menerima' => 'success',
'selesai' => 'info',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match($state) {
'terdaftar' => 'Terdaftar',
'menerima' => 'Menerima',
'selesai' => 'Selesai',
default => $state,
}),
Tables\\Columns\\TextColumn::make('phone')
->label('Telepon')
->icon('heroicon-o-phone')
->toggleable(isToggledHiddenByDefault: true),
Tables\\Columns\\TextColumn::make('created_at')
->label('Terdaftar')
->date('d M Y')
->sortable(),
])
->filters([
Tables\\Filters\\SelectFilter::make('program_id')
->label('Program')
->relationship('program', 'nama')
->searchable()
->preload(),
Tables\\Filters\\SelectFilter::make('status')
->label('Status')
->options([
'terdaftar' => 'Terdaftar',
'menerima' => 'Menerima Bantuan',
'selesai' => 'Selesai',
]),
Tables\\Filters\\Filter::make('created_at')
->form([
Forms\\Components\\DatePicker::make('dari_tanggal')
->label('Dari Tanggal'),
Forms\\Components\\DatePicker::make('sampai_tanggal')
->label('Sampai Tanggal'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['dari_tanggal'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date),
)
->when(
$data['sampai_tanggal'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date),
);
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['dari_tanggal'] ?? null) {
$indicators['dari_tanggal'] = 'Dari: ' . \\Carbon\\Carbon::parse($data['dari_tanggal'])->format('d M Y');
}
if ($data['sampai_tanggal'] ?? null) {
$indicators['sampai_tanggal'] = 'Sampai: ' . \\Carbon\\Carbon::parse($data['sampai_tanggal'])->format('d M Y');
}
return $indicators;
}),
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make()
->requiresConfirmation()
->modalHeading('Hapus Penerima Manfaat')
->modalDescription('Yakin ingin menghapus data penerima manfaat ini?'),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
Tables\\Actions\\BulkAction::make('updateStatus')
->label('Update Status')
->icon('heroicon-o-arrow-path')
->form([
Forms\\Components\\Select::make('status')
->label('Status Baru')
->options([
'terdaftar' => 'Terdaftar',
'menerima' => 'Menerima Bantuan',
'selesai' => 'Selesai',
])
->required(),
])
->action(function ($records, array $data): void {
$records->each(function ($record) use ($data) {
$record->update(['status' => $data['status']]);
});
})
->deselectRecordsAfterCompletion()
->requiresConfirmation()
->modalHeading('Update Status Penerima')
->modalDescription('Status akan diupdate untuk semua penerima yang dipilih.'),
]),
])
->defaultSort('created_at', 'desc')
->emptyStateHeading('Belum ada penerima manfaat')
->emptyStateDescription('Data penerima manfaat akan muncul di sini.')
->emptyStateIcon('heroicon-o-user-group');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListPenerimaManfaats::route('/'),
'create' => Pages\\CreatePenerimaManfaat::route('/create'),
'view' => Pages\\ViewPenerimaManfaat::route('/{record}'),
'edit' => Pages\\EditPenerimaManfaat::route('/{record}/edit'),
];
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::where('status', 'menerima')->count() ?: null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'success';
}
}
Generate Pages:
php artisan make:filament-resource PenerimaManfaat --generate
app/Filament/Resources/PenerimaManfaatResource/Pages/ListPenerimaManfaats.php:
<?php
namespace App\\Filament\\Resources\\PenerimaManfaatResource\\Pages;
use App\\Filament\\Resources\\PenerimaManfaatResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\ListRecords;
class ListPenerimaManfaats extends ListRecords
{
protected static string $resource = PenerimaManfaatResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\CreateAction::make()
->label('Tambah Penerima'),
];
}
}
app/Filament/Resources/PenerimaManfaatResource/Pages/CreatePenerimaManfaat.php:
<?php
namespace App\\Filament\\Resources\\PenerimaManfaatResource\\Pages;
use App\\Filament\\Resources\\PenerimaManfaatResource;
use Filament\\Resources\\Pages\\CreateRecord;
class CreatePenerimaManfaat extends CreateRecord
{
protected static string $resource = PenerimaManfaatResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}
app/Filament/Resources/PenerimaManfaatResource/Pages/EditPenerimaManfaat.php:
<?php
namespace App\\Filament\\Resources\\PenerimaManfaatResource\\Pages;
use App\\Filament\\Resources\\PenerimaManfaatResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\EditRecord;
class EditPenerimaManfaat extends EditRecord
{
protected static string $resource = PenerimaManfaatResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\ViewAction::make(),
Actions\\DeleteAction::make(),
];
}
}
app/Filament/Resources/PenerimaManfaatResource/Pages/ViewPenerimaManfaat.php:
<?php
namespace App\\Filament\\Resources\\PenerimaManfaatResource\\Pages;
use App\\Filament\\Resources\\PenerimaManfaatResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\ViewRecord;
class ViewPenerimaManfaat extends ViewRecord
{
protected static string $resource = PenerimaManfaatResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\EditAction::make(),
];
}
}
Yang Perlu Diperhatikan:
- ✅ Validasi NIK dengan
>length(16)->numeric() - ✅ NIK bisa di-copy dengan
>copyable() - ✅ Bulk action untuk update status multiple records
- ✅ Navigation badge menampilkan jumlah yang sedang menerima bantuan
- ✅ Filter tanggal dengan date range picker
- ⚠️ Pastikan relasi
programdi-eager load untuk performa - 🔧 Tambahkan
getOptionLabelFromRecordUsinguntuk format select yang lebih informatif
Update DatabaseSeeder
database/seeders/DatabaseSeeder.php:
<?php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
DonaturSeeder::class,
ProgramSeeder::class,
PenerimaManfaatSeeder::class, // Tambahkan ini
]);
}
}
Jalankan migration dan seeder:
php artisan migrate
php artisan db:seed --class=PenerimaManfaatSeeder
Test di Browser
Buka http://localhost:8000/admin/penerima-manfaats dan cek:
- Data penerima tampil dengan benar
- Filter by program working
- Filter by status working
- NIK bisa di-copy
- Bulk update status berfungsi
- Badge di navigation menampilkan count
Di bagian selanjutnya, kita akan bikin CRUD Donasi yang menghubungkan Donatur dengan Program.
Bagian 4: CRUD Donasi
Donasi adalah jantung dari sistem yayasan. Di sinilah uang masuk — menghubungkan donatur yang berdonasi dengan program yang mereka dukung. Setiap transaksi harus tercatat rapi: siapa yang donasi, untuk program apa, berapa jumlahnya, dan sudah diverifikasi atau belum.
Data donasi juga yang nanti akan di-aggregate untuk:
- Update
total_donasidi tabel Donatur - Update
dana_masukdi tabel Program - Laporan keuangan
Migration & Model Donasi
Prompt ke Claude
Buatkan migration dan model untuk tabel "donasis" di Laravel dengan field:
- id (UUID)
- donatur_id (UUID, foreign key ke donaturs)
- program_id (UUID, foreign key ke programs)
- jumlah (decimal 15,2, required) - nominal donasi
- metode_pembayaran (enum: transfer_bank, ewallet, tunai)
- bukti_transfer (string, nullable) - path ke gambar bukti
- status (enum: pending, verified, rejected - default pending)
- catatan (text, nullable) - catatan dari donatur
- verified_at (timestamp, nullable) - kapan diverifikasi
- verified_by (UUID, nullable, foreign key ke users) - siapa yang verifikasi
- timestamps
Model harus:
- Pakai UUID
- Punya relasi belongsTo ke Donatur, Program, dan User (verifier)
- Punya accessor untuk format jumlah ke Rupiah
- Punya scope untuk filter by status
Juga buatkan Observer untuk:
- Saat donasi verified: update total_donasi di Donatur dan dana_masuk di Program
- Saat donasi rejected atau deleted: kurangi kembali jika sebelumnya verified
Berikan seeder dengan 8 data donasi yang bervariasi statusnya.
Review Output (30%)
database/migrations/2026_01_01_000004_create_donasis_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('donasis', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('donatur_id')->constrained('donaturs')->onDelete('cascade');
$table->foreignUuid('program_id')->constrained('programs')->onDelete('cascade');
$table->decimal('jumlah', 15, 2);
$table->enum('metode_pembayaran', ['transfer_bank', 'ewallet', 'tunai'])->default('transfer_bank');
$table->string('bukti_transfer')->nullable();
$table->enum('status', ['pending', 'verified', 'rejected'])->default('pending');
$table->text('catatan')->nullable();
$table->timestamp('verified_at')->nullable();
$table->foreignUuid('verified_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index('donatur_id');
$table->index('program_id');
$table->index('status');
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('donasis');
}
};
app/Models/Donasi.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Builder;
class Donasi extends Model
{
use HasFactory, HasUuids;
protected $table = 'donasis';
protected $fillable = [
'donatur_id',
'program_id',
'jumlah',
'metode_pembayaran',
'bukti_transfer',
'status',
'catatan',
'verified_at',
'verified_by',
];
protected $casts = [
'jumlah' => 'decimal:2',
'verified_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function donatur(): BelongsTo
{
return $this->belongsTo(Donatur::class);
}
public function program(): BelongsTo
{
return $this->belongsTo(Program::class);
}
public function verifier(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by');
}
// Accessor: Format jumlah ke Rupiah
public function getJumlahFormattedAttribute(): string
{
return 'Rp ' . number_format($this->jumlah, 0, ',', '.');
}
// Accessor: Label metode pembayaran
public function getMetodePembayaranLabelAttribute(): string
{
return match($this->metode_pembayaran) {
'transfer_bank' => 'Transfer Bank',
'ewallet' => 'E-Wallet',
'tunai' => 'Tunai',
default => $this->metode_pembayaran,
};
}
// Accessor: Status label
public function getStatusLabelAttribute(): string
{
return match($this->status) {
'pending' => 'Menunggu Verifikasi',
'verified' => 'Terverifikasi',
'rejected' => 'Ditolak',
default => $this->status,
};
}
// Accessor: Status color untuk badge
public function getStatusColorAttribute(): string
{
return match($this->status) {
'pending' => 'warning',
'verified' => 'success',
'rejected' => 'danger',
default => 'gray',
};
}
// Scope: Filter by status
public function scopeStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
// Scope: Hanya yang verified
public function scopeVerified(Builder $query): Builder
{
return $query->where('status', 'verified');
}
// Scope: Hanya yang pending
public function scopePending(Builder $query): Builder
{
return $query->where('status', 'pending');
}
}
app/Observers/DonasiObserver.php:
<?php
namespace App\\Observers;
use App\\Models\\Donasi;
class DonasiObserver
{
/**
* Handle the Donasi "updated" event.
* Ketika status berubah ke verified, update total di Donatur dan Program
*/
public function updated(Donasi $donasi): void
{
// Cek apakah status berubah
if ($donasi->isDirty('status')) {
$oldStatus = $donasi->getOriginal('status');
$newStatus = $donasi->status;
// Dari non-verified ke verified: tambah total
if ($oldStatus !== 'verified' && $newStatus === 'verified') {
$this->incrementTotals($donasi);
}
// Dari verified ke non-verified: kurangi total
if ($oldStatus === 'verified' && $newStatus !== 'verified') {
$this->decrementTotals($donasi);
}
}
}
/**
* Handle the Donasi "deleted" event.
* Jika donasi yang dihapus statusnya verified, kurangi total
*/
public function deleted(Donasi $donasi): void
{
if ($donasi->status === 'verified') {
$this->decrementTotals($donasi);
}
}
/**
* Tambah total_donasi di Donatur dan dana_masuk di Program
*/
private function incrementTotals(Donasi $donasi): void
{
// Update Donatur
$donasi->donatur->increment('total_donasi', $donasi->jumlah);
$donasi->donatur->increment('jumlah_donasi', 1);
// Update Program
$donasi->program->increment('dana_masuk', $donasi->jumlah);
}
/**
* Kurangi total_donasi di Donatur dan dana_masuk di Program
*/
private function decrementTotals(Donasi $donasi): void
{
// Update Donatur
$donasi->donatur->decrement('total_donasi', $donasi->jumlah);
$donasi->donatur->decrement('jumlah_donasi', 1);
// Update Program - pastikan tidak minus
$newDanaMasuk = max(0, $donasi->program->dana_masuk - $donasi->jumlah);
$donasi->program->update(['dana_masuk' => $newDanaMasuk]);
}
}
Register Observer di app/Providers/AppServiceProvider.php:
<?php
namespace App\\Providers;
use App\\Models\\Donasi;
use App\\Observers\\DonasiObserver;
use Illuminate\\Support\\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Donasi::observe(DonasiObserver::class);
}
}
database/seeders/DonasiSeeder.php:
<?php
namespace Database\\Seeders;
use App\\Models\\Donasi;
use App\\Models\\Donatur;
use App\\Models\\Program;
use Illuminate\\Database\\Seeder;
class DonasiSeeder extends Seeder
{
public function run(): void
{
$donaturs = Donatur::all();
$programs = Program::all();
if ($donaturs->isEmpty() || $programs->isEmpty()) {
$this->command->warn('Donatur atau Program belum ada. Jalankan seeder terlebih dahulu.');
return;
}
$donasis = [
[
'donatur_id' => $donaturs[0]->id,
'program_id' => $programs[0]->id, // Beasiswa
'jumlah' => 2000000,
'metode_pembayaran' => 'transfer_bank',
'status' => 'verified',
'catatan' => 'Semoga bermanfaat untuk anak-anak yatim',
'verified_at' => now()->subDays(10),
],
[
'donatur_id' => $donaturs[0]->id,
'program_id' => $programs[2]->id, // Bencana
'jumlah' => 1500000,
'metode_pembayaran' => 'ewallet',
'status' => 'verified',
'catatan' => 'Untuk saudara kita yang terkena musibah',
'verified_at' => now()->subDays(5),
],
[
'donatur_id' => $donaturs[1]->id,
'program_id' => $programs[0]->id, // Beasiswa
'jumlah' => 1000000,
'metode_pembayaran' => 'transfer_bank',
'status' => 'verified',
'catatan' => null,
'verified_at' => now()->subDays(8),
],
[
'donatur_id' => $donaturs[2]->id,
'program_id' => $programs[1]->id, // Sembako
'jumlah' => 5000000,
'metode_pembayaran' => 'transfer_bank',
'status' => 'verified',
'catatan' => 'Untuk paket sembako Ramadhan',
'verified_at' => now()->subDays(15),
],
[
'donatur_id' => $donaturs[2]->id,
'program_id' => $programs[0]->id, // Beasiswa
'jumlah' => 3000000,
'metode_pembayaran' => 'ewallet',
'status' => 'pending',
'catatan' => 'Mohon segera diverifikasi',
'verified_at' => null,
],
[
'donatur_id' => $donaturs[3]->id,
'program_id' => $programs[2]->id, // Bencana
'jumlah' => 500000,
'metode_pembayaran' => 'tunai',
'status' => 'pending',
'catatan' => 'Dititipkan via pengurus masjid',
'verified_at' => null,
],
[
'donatur_id' => $donaturs[4]->id,
'program_id' => $programs[0]->id, // Beasiswa
'jumlah' => 2500000,
'metode_pembayaran' => 'transfer_bank',
'status' => 'rejected',
'catatan' => 'Transfer dari rekening atas nama berbeda',
'verified_at' => null,
],
[
'donatur_id' => $donaturs[4]->id,
'program_id' => $programs[2]->id, // Bencana
'jumlah' => 1000000,
'metode_pembayaran' => 'transfer_bank',
'status' => 'verified',
'catatan' => 'Semoga lekas pulih',
'verified_at' => now()->subDays(3),
],
];
foreach ($donasis as $donasi) {
Donasi::create($donasi);
}
// Note: Observer akan otomatis update total_donasi dan dana_masuk
// tapi karena seeder, kita perlu trigger manual untuk yang verified
$this->command->info('Donasi seeder selesai. Jalankan command berikut untuk sync total:');
$this->command->info('php artisan tinker --execute="App\\Models\\Donatur::all()->each(fn(\\$d) => \\$d->update([\\'total_donasi\\' => \\$d->donasis()->verified()->sum(\\'jumlah\\'), \\'jumlah_donasi\\' => \\$d->donasis()->verified()->count()]));"');
}
}
Yang Perlu Diperhatikan:
- ✅ Observer handle perubahan status untuk update total otomatis
- ✅ Foreign key
verified_bypakainullOnDelete()agar tidak error jika user dihapus - ✅ Index pada kolom yang sering di-query untuk performa
- ⚠️ Observer tidak ter-trigger saat seeder karena tidak melalui event
- 🔧 Tambahkan scope untuk mempermudah query di tempat lain
Filament Resource Donasi
Prompt ke Claude
Buatkan Filament 3 Resource untuk model Donasi dengan:
Form:
- donatur_id (Select, searchable, relationship, required, tampilkan nama + email)
- program_id (Select, searchable, relationship, required, hanya program aktif)
- jumlah (TextInput, numeric, prefix "Rp", required, min 10000)
- metode_pembayaran (Select: transfer_bank, ewallet, tunai)
- bukti_transfer (FileUpload, image, directory "bukti-donasi")
- catatan (Textarea, nullable)
- Section terpisah untuk status (hanya tampil saat edit):
- status (Select: pending, verified, rejected)
- Tampilkan info verified_at dan verified_by jika sudah verified
Table:
- created_at (date Indonesia, sortable)
- donatur.nama (searchable, dengan email di bawahnya)
- program.nama (badge)
- jumlah (money format, sortable, warna hijau)
- metode_pembayaran (icon + label)
- status (badge dengan warna)
- verified_at (date atau "-")
Filters:
- Status (dengan count indicator)
- Program
- Metode Pembayaran
- Date range (created_at)
Actions:
- View
- Edit
- Delete (hanya untuk pending)
- Custom Action "Verifikasi" (ubah status jadi verified, set verified_at dan verified_by)
- Custom Action "Tolak" (ubah status jadi rejected)
Header Actions:
- Create
- Export (jika ada package)
Tampilkan stats di atas table:
- Total donasi pending (count + sum)
- Total donasi verified bulan ini
Review Output (30%)
app/Filament/Resources/DonasiResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\DonasiResource\\Pages;
use App\\Models\\Donasi;
use App\\Models\\Donatur;
use App\\Models\\Program;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Facades\\Auth;
class DonasiResource extends Resource
{
protected static ?string $model = Donasi::class;
protected static ?string $navigationIcon = 'heroicon-o-banknotes';
protected static ?string $navigationLabel = 'Donasi';
protected static ?string $modelLabel = 'Donasi';
protected static ?string $pluralModelLabel = 'Donasi';
protected static ?string $navigationGroup = 'Transaksi';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Donasi')
->schema([
Forms\\Components\\Select::make('donatur_id')
->label('Donatur')
->relationship('donatur', 'nama')
->getOptionLabelFromRecordUsing(fn (Donatur $record) =>
"{$record->nama} ({$record->email})"
)
->searchable(['nama', 'email'])
->preload()
->required()
->createOptionForm([
Forms\\Components\\TextInput::make('nama')
->required()
->maxLength(255),
Forms\\Components\\TextInput::make('email')
->email()
->required()
->unique(),
Forms\\Components\\TextInput::make('phone')
->tel(),
])
->createOptionUsing(function (array $data): string {
$donatur = Donatur::create($data);
return $donatur->id;
}),
Forms\\Components\\Select::make('program_id')
->label('Program')
->relationship(
'program',
'nama',
fn (Builder $query) => $query->where('status', 'aktif')
)
->searchable()
->preload()
->required()
->helperText('Hanya menampilkan program yang aktif'),
Forms\\Components\\TextInput::make('jumlah')
->label('Jumlah Donasi')
->required()
->numeric()
->prefix('Rp')
->minValue(10000)
->placeholder('Minimal Rp 10.000')
->currencyMask(thousandSeparator: '.', decimalSeparator: ',', precision: 0),
Forms\\Components\\Select::make('metode_pembayaran')
->label('Metode Pembayaran')
->options([
'transfer_bank' => 'Transfer Bank',
'ewallet' => 'E-Wallet (GoPay, OVO, Dana)',
'tunai' => 'Tunai',
])
->default('transfer_bank')
->required(),
Forms\\Components\\FileUpload::make('bukti_transfer')
->label('Bukti Transfer')
->image()
->directory('bukti-donasi')
->maxSize(2048)
->imageResizeMode('contain')
->imageResizeTargetWidth('1000')
->helperText('Upload bukti transfer (maks 2MB)')
->columnSpanFull(),
Forms\\Components\\Textarea::make('catatan')
->label('Catatan')
->rows(3)
->placeholder('Catatan atau pesan dari donatur')
->columnSpanFull(),
])
->columns(2),
Forms\\Components\\Section::make('Status Verifikasi')
->schema([
Forms\\Components\\Select::make('status')
->label('Status')
->options([
'pending' => 'Pending',
'verified' => 'Verified',
'rejected' => 'Rejected',
])
->default('pending')
->required()
->live(),
Forms\\Components\\Placeholder::make('verified_info')
->label('Info Verifikasi')
->content(function ($record) {
if (!$record || !$record->verified_at) {
return 'Belum diverifikasi';
}
$verifier = $record->verifier?->name ?? 'System';
return "Diverifikasi oleh {$verifier} pada " . $record->verified_at->format('d M Y H:i');
})
->visible(fn ($record) => $record !== null),
])
->columns(2)
->visible(fn ($livewire) => $livewire instanceof Pages\\EditDonasi),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('created_at')
->label('Tanggal')
->date('d M Y')
->sortable()
->description(fn (Donasi $record): string =>
$record->created_at->format('H:i')
),
Tables\\Columns\\TextColumn::make('donatur.nama')
->label('Donatur')
->searchable()
->sortable()
->description(fn (Donasi $record): string =>
$record->donatur?->email ?? ''
),
Tables\\Columns\\TextColumn::make('program.nama')
->label('Program')
->badge()
->color('info')
->sortable(),
Tables\\Columns\\TextColumn::make('jumlah')
->label('Jumlah')
->money('IDR')
->sortable()
->color('success')
->weight('bold'),
Tables\\Columns\\TextColumn::make('metode_pembayaran')
->label('Metode')
->formatStateUsing(fn (string $state): string => match($state) {
'transfer_bank' => 'Transfer Bank',
'ewallet' => 'E-Wallet',
'tunai' => 'Tunai',
default => $state,
})
->icon(fn (string $state): string => match($state) {
'transfer_bank' => 'heroicon-o-building-library',
'ewallet' => 'heroicon-o-device-phone-mobile',
'tunai' => 'heroicon-o-banknotes',
default => 'heroicon-o-question-mark-circle',
})
->toggleable(),
Tables\\Columns\\TextColumn::make('status')
->label('Status')
->badge()
->color(fn (string $state): string => match($state) {
'pending' => 'warning',
'verified' => 'success',
'rejected' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match($state) {
'pending' => 'Pending',
'verified' => 'Verified',
'rejected' => 'Rejected',
default => $state,
}),
Tables\\Columns\\TextColumn::make('verified_at')
->label('Diverifikasi')
->date('d M Y')
->placeholder('-')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\SelectFilter::make('status')
->label('Status')
->options([
'pending' => 'Pending',
'verified' => 'Verified',
'rejected' => 'Rejected',
])
->indicator('Status'),
Tables\\Filters\\SelectFilter::make('program_id')
->label('Program')
->relationship('program', 'nama')
->searchable()
->preload()
->indicator('Program'),
Tables\\Filters\\SelectFilter::make('metode_pembayaran')
->label('Metode')
->options([
'transfer_bank' => 'Transfer Bank',
'ewallet' => 'E-Wallet',
'tunai' => 'Tunai',
])
->indicator('Metode'),
Tables\\Filters\\Filter::make('created_at')
->form([
Forms\\Components\\DatePicker::make('dari_tanggal')
->label('Dari Tanggal'),
Forms\\Components\\DatePicker::make('sampai_tanggal')
->label('Sampai Tanggal'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['dari_tanggal'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date),
)
->when(
$data['sampai_tanggal'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date),
);
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['dari_tanggal'] ?? null) {
$indicators['dari_tanggal'] = 'Dari: ' . \\Carbon\\Carbon::parse($data['dari_tanggal'])->format('d M Y');
}
if ($data['sampai_tanggal'] ?? null) {
$indicators['sampai_tanggal'] = 'Sampai: ' . \\Carbon\\Carbon::parse($data['sampai_tanggal'])->format('d M Y');
}
return $indicators;
}),
])
->actions([
Tables\\Actions\\ActionGroup::make([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\Action::make('verifikasi')
->label('Verifikasi')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('Verifikasi Donasi')
->modalDescription(fn (Donasi $record) =>
"Verifikasi donasi sebesar {$record->jumlah_formatted} dari {$record->donatur->nama}?"
)
->modalSubmitActionLabel('Ya, Verifikasi')
->action(function (Donasi $record): void {
$record->update([
'status' => 'verified',
'verified_at' => now(),
'verified_by' => Auth::id(),
]);
})
->visible(fn (Donasi $record): bool => $record->status === 'pending'),
Tables\\Actions\\Action::make('tolak')
->label('Tolak')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->modalHeading('Tolak Donasi')
->modalDescription('Yakin ingin menolak donasi ini? Status akan diubah menjadi rejected.')
->modalSubmitActionLabel('Ya, Tolak')
->action(function (Donasi $record): void {
$record->update([
'status' => 'rejected',
'verified_at' => null,
'verified_by' => null,
]);
})
->visible(fn (Donasi $record): bool => $record->status === 'pending'),
Tables\\Actions\\DeleteAction::make()
->visible(fn (Donasi $record): bool => $record->status === 'pending'),
]),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\BulkAction::make('verifikasiMassal')
->label('Verifikasi Semua')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('Verifikasi Massal')
->modalDescription('Semua donasi yang dipilih akan diverifikasi.')
->action(function ($records): void {
$records->each(function ($record) {
if ($record->status === 'pending') {
$record->update([
'status' => 'verified',
'verified_at' => now(),
'verified_by' => Auth::id(),
]);
}
});
})
->deselectRecordsAfterCompletion(),
Tables\\Actions\\DeleteBulkAction::make()
->visible(fn (): bool => false), // Disable bulk delete for safety
]),
])
->defaultSort('created_at', 'desc')
->emptyStateHeading('Belum ada donasi')
->emptyStateDescription('Donasi akan muncul di sini setelah ditambahkan.')
->emptyStateIcon('heroicon-o-banknotes')
->poll('30s'); // Auto refresh setiap 30 detik
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListDonasis::route('/'),
'create' => Pages\\CreateDonasi::route('/create'),
'view' => Pages\\ViewDonasi::route('/{record}'),
'edit' => Pages\\EditDonasi::route('/{record}/edit'),
];
}
public static function getNavigationBadge(): ?string
{
return static::getModel()::pending()->count() ?: null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
public static function getGloballySearchableAttributes(): array
{
return ['donatur.nama', 'donatur.email', 'program.nama'];
}
}
app/Filament/Resources/DonasiResource/Pages/ListDonasis.php:
<?php
namespace App\\Filament\\Resources\\DonasiResource\\Pages;
use App\\Filament\\Resources\\DonasiResource;
use App\\Models\\Donasi;
use Filament\\Actions;
use Filament\\Resources\\Pages\\ListRecords;
use Filament\\Resources\\Components\\Tab;
use Illuminate\\Database\\Eloquent\\Builder;
class ListDonasis extends ListRecords
{
protected static string $resource = DonasiResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\CreateAction::make()
->label('Tambah Donasi'),
];
}
protected function getHeaderWidgets(): array
{
return [
DonasiResource\\Widgets\\DonasiStatsOverview::class,
];
}
public function getTabs(): array
{
return [
'semua' => Tab::make('Semua')
->badge(Donasi::count()),
'pending' => Tab::make('Pending')
->badge(Donasi::pending()->count())
->badgeColor('warning')
->modifyQueryUsing(fn (Builder $query) => $query->pending()),
'verified' => Tab::make('Verified')
->badge(Donasi::verified()->count())
->badgeColor('success')
->modifyQueryUsing(fn (Builder $query) => $query->verified()),
'rejected' => Tab::make('Rejected')
->badge(Donasi::where('status', 'rejected')->count())
->badgeColor('danger')
->modifyQueryUsing(fn (Builder $query) => $query->where('status', 'rejected')),
];
}
}
app/Filament/Resources/DonasiResource/Widgets/DonasiStatsOverview.php:
<?php
namespace App\\Filament\\Resources\\DonasiResource\\Widgets;
use App\\Models\\Donasi;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;
class DonasiStatsOverview extends BaseWidget
{
protected function getStats(): array
{
$pendingCount = Donasi::pending()->count();
$pendingSum = Donasi::pending()->sum('jumlah');
$verifiedThisMonth = Donasi::verified()
->whereMonth('verified_at', now()->month)
->whereYear('verified_at', now()->year);
return [
Stat::make('Menunggu Verifikasi', $pendingCount . ' donasi')
->description('Total: Rp ' . number_format($pendingSum, 0, ',', '.'))
->descriptionIcon('heroicon-o-clock')
->color('warning'),
Stat::make('Terverifikasi Bulan Ini', $verifiedThisMonth->count() . ' donasi')
->description('Total: Rp ' . number_format($verifiedThisMonth->sum('jumlah'), 0, ',', '.'))
->descriptionIcon('heroicon-o-check-circle')
->color('success'),
Stat::make('Total Donasi Masuk', 'Rp ' . number_format(Donasi::verified()->sum('jumlah'), 0, ',', '.'))
->description('Dari ' . Donasi::verified()->count() . ' transaksi')
->descriptionIcon('heroicon-o-banknotes')
->color('info'),
];
}
}
app/Filament/Resources/DonasiResource/Pages/CreateDonasi.php:
<?php
namespace App\\Filament\\Resources\\DonasiResource\\Pages;
use App\\Filament\\Resources\\DonasiResource;
use Filament\\Resources\\Pages\\CreateRecord;
class CreateDonasi extends CreateRecord
{
protected static string $resource = DonasiResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['status'] = 'pending';
return $data;
}
}
app/Filament/Resources/DonasiResource/Pages/EditDonasi.php:
<?php
namespace App\\Filament\\Resources\\DonasiResource\\Pages;
use App\\Filament\\Resources\\DonasiResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\EditRecord;
use Illuminate\\Support\\Facades\\Auth;
class EditDonasi extends EditRecord
{
protected static string $resource = DonasiResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\ViewAction::make(),
Actions\\DeleteAction::make()
->visible(fn () => $this->record->status === 'pending'),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
// Jika status berubah ke verified, set verified_at dan verified_by
if ($data['status'] === 'verified' && $this->record->status !== 'verified') {
$data['verified_at'] = now();
$data['verified_by'] = Auth::id();
}
// Jika status berubah dari verified ke lainnya, hapus verified info
if ($data['status'] !== 'verified' && $this->record->status === 'verified') {
$data['verified_at'] = null;
$data['verified_by'] = null;
}
return $data;
}
}
app/Filament/Resources/DonasiResource/Pages/ViewDonasi.php:
<?php
namespace App\\Filament\\Resources\\DonasiResource\\Pages;
use App\\Filament\\Resources\\DonasiResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\ViewRecord;
use Illuminate\\Support\\Facades\\Auth;
class ViewDonasi extends ViewRecord
{
protected static string $resource = DonasiResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\EditAction::make(),
Actions\\Action::make('verifikasi')
->label('Verifikasi')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->action(function (): void {
$this->record->update([
'status' => 'verified',
'verified_at' => now(),
'verified_by' => Auth::id(),
]);
$this->redirect($this->getResource()::getUrl('view', ['record' => $this->record]));
})
->visible(fn (): bool => $this->record->status === 'pending'),
Actions\\Action::make('tolak')
->label('Tolak')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->action(function (): void {
$this->record->update([
'status' => 'rejected',
]);
$this->redirect($this->getResource()::getUrl('view', ['record' => $this->record]));
})
->visible(fn (): bool => $this->record->status === 'pending'),
];
}
}
Yang Perlu Diperhatikan:
- ✅ Observer otomatis update total saat status berubah
- ✅ Tabs untuk filter cepat berdasarkan status
- ✅ Stats widget menampilkan ringkasan di atas table
- ✅ Inline create donatur jika belum ada
- ✅ Action verifikasi dan tolak dengan konfirmasi
- ✅ Auto-refresh table setiap 30 detik dengan
>poll('30s') - ✅ Navigation badge menampilkan jumlah pending
- ⚠️ Bulk delete di-disable untuk keamanan
- ⚠️ Delete hanya bisa untuk status pending
- 🔧 Tambahkan
mutateFormDataBeforeSaveuntuk handle verified_at/verified_by
Register Widget
Pastikan widget terdaftar. Buat folder jika belum ada:
mkdir -p app/Filament/Resources/DonasiResource/Widgets
Update DatabaseSeeder
database/seeders/DatabaseSeeder.php:
<?php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
DonaturSeeder::class,
ProgramSeeder::class,
PenerimaManfaatSeeder::class,
DonasiSeeder::class, // Tambahkan ini
]);
}
}
Jalankan migration dan seeder:
php artisan migrate
php artisan db:seed --class=DonasiSeeder
Test di Browser
Buka http://localhost:8000/admin/donasis dan cek:
- Stats overview tampil di atas table
- Tabs filter (Semua, Pending, Verified, Rejected) working
- Action Verifikasi dan Tolak muncul untuk status pending
- Badge di navigation menampilkan jumlah pending
- Observer bekerja: cek total_donasi di Donatur dan dana_masuk di Program setelah verifikasi
Di bagian selanjutnya, kita akan bikin CRUD terakhir: Laporan Keuangan.
Bagian 5: CRUD Laporan Keuangan
Laporan keuangan mencatat semua aliran dana — baik pemasukan (dari donasi) maupun pengeluaran (operasional, program, dll). Ini penting untuk transparansi dan audit.
Migration & Model
Prompt ke Claude
Buatkan migration dan model untuk tabel "laporan_keuangans" di Laravel:
- id (UUID)
- program_id (UUID, nullable, foreign key) - nullable karena ada pengeluaran umum
- jenis (enum: pemasukan, pengeluaran)
- kategori (enum: donasi, operasional, program, gaji, lainnya)
- jumlah (decimal 15,2)
- keterangan (text)
- tanggal (date)
- bukti (string, nullable) - path ke file bukti
- timestamps
Model dengan relasi ke Program, accessor untuk format rupiah dan warna berdasarkan jenis.
Review Output (30%)
database/migrations/2026_01_01_000005_create_laporan_keuangans_table.php:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('laporan_keuangans', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('program_id')->nullable()->constrained('programs')->nullOnDelete();
$table->enum('jenis', ['pemasukan', 'pengeluaran']);
$table->enum('kategori', ['donasi', 'operasional', 'program', 'gaji', 'lainnya']);
$table->decimal('jumlah', 15, 2);
$table->text('keterangan');
$table->date('tanggal');
$table->string('bukti')->nullable();
$table->timestamps();
$table->index(['jenis', 'tanggal']);
$table->index('kategori');
});
}
public function down(): void
{
Schema::dropIfExists('laporan_keuangans');
}
};
app/Models/LaporanKeuangan.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Builder;
class LaporanKeuangan extends Model
{
use HasFactory, HasUuids;
protected $table = 'laporan_keuangans';
protected $fillable = [
'program_id',
'jenis',
'kategori',
'jumlah',
'keterangan',
'tanggal',
'bukti',
];
protected $casts = [
'jumlah' => 'decimal:2',
'tanggal' => 'date',
];
public function program(): BelongsTo
{
return $this->belongsTo(Program::class);
}
public function getJumlahFormattedAttribute(): string
{
$prefix = $this->jenis === 'pemasukan' ? '+' : '-';
return $prefix . ' Rp ' . number_format($this->jumlah, 0, ',', '.');
}
public function getJenisColorAttribute(): string
{
return $this->jenis === 'pemasukan' ? 'success' : 'danger';
}
public function getKategoriLabelAttribute(): string
{
return match($this->kategori) {
'donasi' => 'Donasi',
'operasional' => 'Operasional',
'program' => 'Program',
'gaji' => 'Gaji',
'lainnya' => 'Lainnya',
default => $this->kategori,
};
}
public function scopePemasukan(Builder $query): Builder
{
return $query->where('jenis', 'pemasukan');
}
public function scopePengeluaran(Builder $query): Builder
{
return $query->where('jenis', 'pengeluaran');
}
public function scopeBulanIni(Builder $query): Builder
{
return $query->whereMonth('tanggal', now()->month)
->whereYear('tanggal', now()->year);
}
}
database/seeders/LaporanKeuanganSeeder.php:
<?php
namespace Database\\Seeders;
use App\\Models\\LaporanKeuangan;
use App\\Models\\Program;
use Illuminate\\Database\\Seeder;
class LaporanKeuanganSeeder extends Seeder
{
public function run(): void
{
$program = Program::first();
$data = [
['jenis' => 'pemasukan', 'kategori' => 'donasi', 'jumlah' => 5000000, 'keterangan' => 'Donasi dari Bapak Budi', 'tanggal' => now()->subDays(10)],
['jenis' => 'pemasukan', 'kategori' => 'donasi', 'jumlah' => 2500000, 'keterangan' => 'Donasi transfer bank', 'tanggal' => now()->subDays(8)],
['jenis' => 'pengeluaran', 'kategori' => 'operasional', 'jumlah' => 500000, 'keterangan' => 'Listrik kantor bulan ini', 'tanggal' => now()->subDays(7)],
['jenis' => 'pengeluaran', 'kategori' => 'gaji', 'jumlah' => 3000000, 'keterangan' => 'Gaji admin yayasan', 'tanggal' => now()->subDays(5)],
['jenis' => 'pengeluaran', 'kategori' => 'program', 'jumlah' => 2000000, 'keterangan' => 'Pembelian sembako', 'tanggal' => now()->subDays(3), 'program_id' => $program?->id],
['jenis' => 'pemasukan', 'kategori' => 'donasi', 'jumlah' => 10000000, 'keterangan' => 'Donasi perusahaan PT ABC', 'tanggal' => now()->subDays(2)],
['jenis' => 'pengeluaran', 'kategori' => 'operasional', 'jumlah' => 150000, 'keterangan' => 'ATK dan perlengkapan', 'tanggal' => now()->subDays(1)],
['jenis' => 'pemasukan', 'kategori' => 'lainnya', 'jumlah' => 500000, 'keterangan' => 'Bunga deposito', 'tanggal' => now()],
];
foreach ($data as $item) {
LaporanKeuangan::create($item);
}
}
}
Filament Resource
Prompt ke Claude
Buatkan Filament 3 Resource untuk LaporanKeuangan dengan:
- Form: jenis (radio), kategori (select), program_id (select nullable), jumlah, keterangan, tanggal, bukti (file)
- Table: tanggal, jenis (badge), kategori, program, jumlah (warna hijau/merah), keterangan
- Filters: jenis, kategori, program, date range
- Widget summary: total pemasukan, total pengeluaran, saldo
Review Output (30%)
app/Filament/Resources/LaporanKeuanganResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\LaporanKeuanganResource\\Pages;
use App\\Filament\\Resources\\LaporanKeuanganResource\\Widgets\\LaporanStatsOverview;
use App\\Models\\LaporanKeuangan;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
class LaporanKeuanganResource extends Resource
{
protected static ?string $model = LaporanKeuangan::class;
protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar';
protected static ?string $navigationLabel = 'Laporan Keuangan';
protected static ?string $modelLabel = 'Laporan Keuangan';
protected static ?string $navigationGroup = 'Laporan';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make()
->schema([
Forms\\Components\\Radio::make('jenis')
->label('Jenis Transaksi')
->options([
'pemasukan' => 'Pemasukan',
'pengeluaran' => 'Pengeluaran',
])
->required()
->inline(),
Forms\\Components\\Select::make('kategori')
->label('Kategori')
->options([
'donasi' => 'Donasi',
'operasional' => 'Operasional',
'program' => 'Program',
'gaji' => 'Gaji',
'lainnya' => 'Lainnya',
])
->required(),
Forms\\Components\\Select::make('program_id')
->label('Program Terkait')
->relationship('program', 'nama')
->searchable()
->preload()
->placeholder('Pilih jika terkait program tertentu'),
Forms\\Components\\TextInput::make('jumlah')
->label('Jumlah')
->required()
->numeric()
->prefix('Rp')
->minValue(1),
Forms\\Components\\DatePicker::make('tanggal')
->label('Tanggal')
->required()
->default(now()),
Forms\\Components\\Textarea::make('keterangan')
->label('Keterangan')
->required()
->rows(2),
Forms\\Components\\FileUpload::make('bukti')
->label('Bukti Transaksi')
->directory('bukti-keuangan')
->acceptedFileTypes(['image/*', 'application/pdf']),
])
->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('tanggal')
->label('Tanggal')
->date('d M Y')
->sortable(),
Tables\\Columns\\TextColumn::make('jenis')
->label('Jenis')
->badge()
->color(fn (string $state): string => $state === 'pemasukan' ? 'success' : 'danger')
->formatStateUsing(fn (string $state): string => ucfirst($state)),
Tables\\Columns\\TextColumn::make('kategori')
->label('Kategori')
->formatStateUsing(fn (string $state): string => ucfirst($state)),
Tables\\Columns\\TextColumn::make('program.nama')
->label('Program')
->placeholder('-')
->toggleable(),
Tables\\Columns\\TextColumn::make('jumlah')
->label('Jumlah')
->formatStateUsing(function (LaporanKeuangan $record): string {
$prefix = $record->jenis === 'pemasukan' ? '+' : '-';
return $prefix . ' Rp ' . number_format($record->jumlah, 0, ',', '.');
})
->color(fn (LaporanKeuangan $record): string =>
$record->jenis === 'pemasukan' ? 'success' : 'danger'
)
->weight('bold')
->sortable(),
Tables\\Columns\\TextColumn::make('keterangan')
->label('Keterangan')
->limit(40)
->searchable(),
])
->filters([
Tables\\Filters\\SelectFilter::make('jenis')
->options([
'pemasukan' => 'Pemasukan',
'pengeluaran' => 'Pengeluaran',
]),
Tables\\Filters\\SelectFilter::make('kategori')
->options([
'donasi' => 'Donasi',
'operasional' => 'Operasional',
'program' => 'Program',
'gaji' => 'Gaji',
'lainnya' => 'Lainnya',
]),
Tables\\Filters\\SelectFilter::make('program_id')
->label('Program')
->relationship('program', 'nama'),
Tables\\Filters\\Filter::make('tanggal')
->form([
Forms\\Components\\DatePicker::make('dari'),
Forms\\Components\\DatePicker::make('sampai'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when($data['dari'], fn ($q, $date) => $q->whereDate('tanggal', '>=', $date))
->when($data['sampai'], fn ($q, $date) => $q->whereDate('tanggal', '<=', $date));
}),
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\DeleteBulkAction::make(),
])
->defaultSort('tanggal', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\\ListLaporanKeuangans::route('/'),
'create' => Pages\\CreateLaporanKeuangan::route('/create'),
'edit' => Pages\\EditLaporanKeuangan::route('/{record}/edit'),
];
}
}
app/Filament/Resources/LaporanKeuanganResource/Widgets/LaporanStatsOverview.php:
<?php
namespace App\\Filament\\Resources\\LaporanKeuanganResource\\Widgets;
use App\\Models\\LaporanKeuangan;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;
class LaporanStatsOverview extends BaseWidget
{
protected function getStats(): array
{
$pemasukan = LaporanKeuangan::pemasukan()->sum('jumlah');
$pengeluaran = LaporanKeuangan::pengeluaran()->sum('jumlah');
$saldo = $pemasukan - $pengeluaran;
$pemasukanBulanIni = LaporanKeuangan::pemasukan()->bulanIni()->sum('jumlah');
$pengeluaranBulanIni = LaporanKeuangan::pengeluaran()->bulanIni()->sum('jumlah');
return [
Stat::make('Total Pemasukan', 'Rp ' . number_format($pemasukan, 0, ',', '.'))
->description('Bulan ini: Rp ' . number_format($pemasukanBulanIni, 0, ',', '.'))
->color('success')
->icon('heroicon-o-arrow-trending-up'),
Stat::make('Total Pengeluaran', 'Rp ' . number_format($pengeluaran, 0, ',', '.'))
->description('Bulan ini: Rp ' . number_format($pengeluaranBulanIni, 0, ',', '.'))
->color('danger')
->icon('heroicon-o-arrow-trending-down'),
Stat::make('Saldo', 'Rp ' . number_format($saldo, 0, ',', '.'))
->description($saldo >= 0 ? 'Surplus' : 'Defisit')
->color($saldo >= 0 ? 'success' : 'danger')
->icon('heroicon-o-banknotes'),
];
}
}
app/Filament/Resources/LaporanKeuanganResource/Pages/ListLaporanKeuangans.php:
<?php
namespace App\\Filament\\Resources\\LaporanKeuanganResource\\Pages;
use App\\Filament\\Resources\\LaporanKeuanganResource;
use App\\Filament\\Resources\\LaporanKeuanganResource\\Widgets\\LaporanStatsOverview;
use Filament\\Actions;
use Filament\\Resources\\Pages\\ListRecords;
class ListLaporanKeuangans extends ListRecords
{
protected static string $resource = LaporanKeuanganResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\CreateAction::make()->label('Tambah Transaksi'),
];
}
protected function getHeaderWidgets(): array
{
return [
LaporanStatsOverview::class,
];
}
}
Buat juga pages Create dan Edit dengan command:
php artisan make:filament-resource LaporanKeuangan --generate
Update DatabaseSeeder
$this->call([
DonaturSeeder::class,
ProgramSeeder::class,
PenerimaManfaatSeeder::class,
DonasiSeeder::class,
LaporanKeuanganSeeder::class, // Tambahkan
]);
Jalankan:
php artisan migrate
php artisan db:seed --class=LaporanKeuanganSeeder
Yang Perlu Diperhatikan:
- ✅ Warna jumlah hijau untuk pemasukan, merah untuk pengeluaran
- ✅ Stats widget menampilkan total dan saldo
- ✅ Scope
bulanIni()untuk filter periode - ✅ Support upload PDF untuk bukti
Di bagian selanjutnya, kita akan bikin Dashboard Widgets untuk overview keseluruhan.
Bagian 6: Dashboard Widgets & Polish
Sekarang kita bikin dashboard utama yang menampilkan overview yayasan — statistik, chart, dan transaksi terbaru.
Dashboard Widgets
Prompt ke Claude
Buatkan Filament 3 Dashboard Widgets untuk yayasan:
1. StatsOverview: Total Donatur, Total Donasi (Rp), Program Aktif, Penerima Manfaat
2. DonasiBulananChart: Line chart donasi per bulan (6 bulan terakhir)
3. DonasiPerProgramChart: Pie/Doughnut chart donasi per program
4. LatestDonasiTable: 5 donasi terbaru
5. ProgramProgressList: Progress bar program aktif
Review Output (30%)
app/Filament/Widgets/StatsOverview.php:
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Donatur;
use App\\Models\\Donasi;
use App\\Models\\Program;
use App\\Models\\PenerimaManfaat;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;
class StatsOverview extends BaseWidget
{
protected static ?int $sort = 1;
protected function getStats(): array
{
$totalDonasi = Donasi::verified()->sum('jumlah');
$donasiTrend = Donasi::verified()
->whereMonth('created_at', now()->month)
->sum('jumlah');
return [
Stat::make('Total Donatur', Donatur::count())
->description('Donatur terdaftar')
->icon('heroicon-o-users')
->color('primary'),
Stat::make('Total Donasi', 'Rp ' . number_format($totalDonasi, 0, ',', '.'))
->description('Bulan ini: Rp ' . number_format($donasiTrend, 0, ',', '.'))
->icon('heroicon-o-banknotes')
->color('success'),
Stat::make('Program Aktif', Program::where('status', 'aktif')->count())
->description('Dari ' . Program::count() . ' total program')
->icon('heroicon-o-heart')
->color('warning'),
Stat::make('Penerima Manfaat', PenerimaManfaat::where('status', 'menerima')->count())
->description('Sedang menerima bantuan')
->icon('heroicon-o-user-group')
->color('info'),
];
}
}
app/Filament/Widgets/DonasiBulananChart.php:
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Donasi;
use Filament\\Widgets\\ChartWidget;
use Illuminate\\Support\\Carbon;
class DonasiBulananChart extends ChartWidget
{
protected static ?string $heading = 'Donasi 6 Bulan Terakhir';
protected static ?int $sort = 2;
protected function getData(): array
{
$data = collect(range(5, 0))->map(function ($monthsAgo) {
$date = now()->subMonths($monthsAgo);
$total = Donasi::verified()
->whereMonth('created_at', $date->month)
->whereYear('created_at', $date->year)
->sum('jumlah');
return [
'month' => $date->format('M Y'),
'total' => $total,
];
});
return [
'datasets' => [
[
'label' => 'Total Donasi',
'data' => $data->pluck('total')->toArray(),
'backgroundColor' => 'rgba(34, 197, 94, 0.2)',
'borderColor' => 'rgb(34, 197, 94)',
'fill' => true,
],
],
'labels' => $data->pluck('month')->toArray(),
];
}
protected function getType(): string
{
return 'line';
}
}
app/Filament/Widgets/DonasiPerProgramChart.php:
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Program;
use Filament\\Widgets\\ChartWidget;
class DonasiPerProgramChart extends ChartWidget
{
protected static ?string $heading = 'Donasi per Program';
protected static ?int $sort = 3;
protected int | string | array $columnSpan = 1;
protected function getData(): array
{
$programs = Program::where('dana_masuk', '>', 0)
->orderByDesc('dana_masuk')
->limit(5)
->get();
$colors = [
'rgb(59, 130, 246)',
'rgb(34, 197, 94)',
'rgb(249, 115, 22)',
'rgb(168, 85, 247)',
'rgb(236, 72, 153)',
];
return [
'datasets' => [
[
'data' => $programs->pluck('dana_masuk')->toArray(),
'backgroundColor' => array_slice($colors, 0, $programs->count()),
],
],
'labels' => $programs->pluck('nama')->toArray(),
];
}
protected function getType(): string
{
return 'doughnut';
}
}
app/Filament/Widgets/LatestDonasiTable.php:
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Donasi;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Widgets\\TableWidget as BaseWidget;
class LatestDonasiTable extends BaseWidget
{
protected static ?string $heading = 'Donasi Terbaru';
protected static ?int $sort = 4;
protected int | string | array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->query(
Donasi::query()
->with(['donatur', 'program'])
->latest()
->limit(5)
)
->columns([
Tables\\Columns\\TextColumn::make('created_at')
->label('Tanggal')
->date('d M Y')
->sortable(),
Tables\\Columns\\TextColumn::make('donatur.nama')
->label('Donatur')
->searchable(),
Tables\\Columns\\TextColumn::make('program.nama')
->label('Program')
->badge(),
Tables\\Columns\\TextColumn::make('jumlah')
->label('Jumlah')
->money('IDR')
->color('success'),
Tables\\Columns\\TextColumn::make('status')
->badge()
->color(fn (string $state): string => match($state) {
'verified' => 'success',
'pending' => 'warning',
'rejected' => 'danger',
default => 'gray',
}),
])
->paginated(false);
}
}
app/Filament/Widgets/ProgramProgressWidget.php:
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Program;
use Filament\\Widgets\\Widget;
class ProgramProgressWidget extends Widget
{
protected static string $view = 'filament.widgets.program-progress-widget';
protected static ?int $sort = 5;
protected int | string | array $columnSpan = 1;
public function getPrograms()
{
return Program::where('status', 'aktif')
->orderByDesc('dana_masuk')
->limit(5)
->get();
}
}
resources/views/filament/widgets/program-progress-widget.blade.php:
<x-filament-widgets::widget>
<x-filament::section heading="Progress Program Aktif">
<div class="space-y-4">
@forelse($this->getPrograms() as $program)
<div>
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $program->nama }}
</span>
<span class="text-sm text-gray-500">
{{ number_format($program->progress_percentage, 0) }}%
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div class="h-2.5 rounded-full {{ $program->progress_percentage >= 100 ? 'bg-green-500' : ($program->progress_percentage >= 50 ? 'bg-yellow-500' : 'bg-blue-500') }}"
style="width: {{ min($program->progress_percentage, 100) }}%">
</div>
</div>
<div class="flex justify-between mt-1 text-xs text-gray-500">
<span>Rp {{ number_format($program->dana_masuk, 0, ',', '.') }}</span>
<span>Target: Rp {{ number_format($program->target_dana, 0, ',', '.') }}</span>
</div>
</div>
@empty
<p class="text-sm text-gray-500">Belum ada program aktif</p>
@endforelse
</div>
</x-filament::section>
</x-filament-widgets::widget>
Register Widgets di Dashboard
app/Filament/Pages/Dashboard.php:
<?php
namespace App\\Filament\\Pages;
use App\\Filament\\Widgets\\DonasiBulananChart;
use App\\Filament\\Widgets\\DonasiPerProgramChart;
use App\\Filament\\Widgets\\LatestDonasiTable;
use App\\Filament\\Widgets\\ProgramProgressWidget;
use App\\Filament\\Widgets\\StatsOverview;
use Filament\\Pages\\Dashboard as BaseDashboard;
class Dashboard extends BaseDashboard
{
public function getWidgets(): array
{
return [
StatsOverview::class,
DonasiBulananChart::class,
DonasiPerProgramChart::class,
ProgramProgressWidget::class,
LatestDonasiTable::class,
];
}
public function getColumns(): int | array
{
return 2;
}
}
Polish: Navigation & Sidebar
Update app/Providers/Filament/AdminPanelProvider.php:
use Filament\\Navigation\\NavigationGroup;
->navigationGroups([
NavigationGroup::make()
->label('Dashboard'),
NavigationGroup::make()
->label('Master Data')
->icon('heroicon-o-folder'),
NavigationGroup::make()
->label('Transaksi')
->icon('heroicon-o-currency-dollar'),
NavigationGroup::make()
->label('Laporan')
->icon('heroicon-o-document-chart-bar'),
])
->brandName('Yayasan Digital')
->favicon(asset('favicon.ico'))
->colors([
'primary' => \\Filament\\Support\\Colors\\Color::Emerald,
])
Yang Perlu Diperhatikan:
- ✅ Stats overview dengan trend bulan ini
- ✅ Line chart untuk donasi 6 bulan
- ✅ Doughnut chart untuk distribusi per program
- ✅ Custom widget untuk progress bar
- ✅ Table widget untuk donasi terbaru
- ⚠️ Pastikan folder
resources/views/filament/widgets/ada
Di bagian terakhir, kita akan tutup dengan rekomendasi belajar lanjutan di BuildWithAngga.
Bagian 7: Penutup & Rekomendasi Belajar Lanjutan
Selamat! Kamu sudah berhasil membangun Dashboard Yayasan Digital lengkap dengan metode vibe coding. Mari kita recap apa saja yang sudah dibuat.
Apa yang Sudah Kita Bangun
DASHBOARD YAYASAN DIGITAL — COMPLETED ✅
┌─────────────────────────────────────────────────────────┐
│ 5 CRUD LENGKAP │
├─────────────────────────────────────────────────────────┤
│ ✅ Donatur — Data orang yang berdonasi │
│ ✅ Program — Program yayasan (beasiswa, dll) │
│ ✅ Penerima — Orang yang menerima bantuan │
│ ✅ Donasi — Transaksi donasi dengan verifikasi │
│ ✅ Laporan — Pemasukan & pengeluaran │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ FITUR ADVANCE │
├─────────────────────────────────────────────────────────┤
│ ✅ Observer — Auto-update total saat verifikasi │
│ ✅ Stats Widget — Overview di setiap halaman │
│ ✅ Charts — Line & Doughnut chart │
│ ✅ Tabs Filter — Quick filter by status │
│ ✅ Bulk Action — Verifikasi massal donasi │
│ ✅ Badge Count — Notif pending di sidebar │
│ ✅ Progress Bar — Visual progress program │
└─────────────────────────────────────────────────────────┘
Statistik Project
| Item | Jumlah |
|---|---|
| Models | 5 |
| Migrations | 5 |
| Filament Resources | 5 |
| Widgets | 6 |
| Observer | 1 |
| Seeders | 5 |
| Waktu Development | ~3-4 jam |
Kelebihan Vibe Coding
Dengan pendekatan 70% prompt + 30% review, kamu bisa:
- Develop lebih cepat — Boilerplate code di-handle AI
- Focus ke business logic — Tidak stuck di syntax
- Belajar pattern baru — Lihat bagaimana AI structure code
- Tetap paham code — Review 30% memastikan kamu mengerti
Kapan Pakai Vibe Coding vs Manual?
| Vibe Coding | Manual Coding |
|---|---|
| CRUD standard | Logic bisnis kompleks |
| Boilerplate code | Algoritma custom |
| Setup awal project | Optimisasi performa |
| Prototype cepat | Security-critical |
| Belajar pattern baru | Code yang sudah dikuasai |
Enhancement Ideas
Untuk pengembangan lebih lanjut:
- Export PDF — Laporan keuangan bulanan
- Email Notification — Notif ke donatur saat donasi verified
- Public Page — Halaman donasi publik dengan Midtrans
- Multi-tenant — Support banyak yayasan dalam satu sistem
- API — Endpoint untuk integrasi mobile app
- Audit Log — Track semua perubahan data
Rekomendasi Kelas Premium BuildWithAngga
Untuk mendalami skill yang dipakai di tutorial ini, saya rekomendasikan kelas-kelas berikut:
| Kelas | Yang Dipelajari | Cocok Untuk |
|---|---|---|
| Laravel Web Development | Fundamental Laravel, MVC, Eloquent, Auth | Pemula Laravel |
| Filament Admin Panel | Deep dive Filament 3, custom component, plugin | Bikin admin panel |
| Laravel API Development | REST API, Sanctum, API Resources | Backend developer |
| Full-Stack Laravel Vue | SPA dengan Vue 3 + Laravel API | Full-stack web app |
| Laravel Livewire | Real-time UI tanpa JavaScript | Alternatif SPA |
| Laravel Testing | PHPUnit, Feature Test, TDD | Code quality |
Benefit Kelas Premium BuildWithAngga
┌─────────────────────────────────────────────────────────┐
│ 🎓 BENEFIT KELAS PREMIUM BUILDWITHANGGA │
├─────────────────────────────────────────────────────────┤
│ │
│ ✅ AKSES SELAMANYA (Lifetime Access) │
│ Bayar sekali, akses materi selamanya. │
│ Tidak ada biaya langganan bulanan. │
│ Update materi gratis tanpa bayar lagi. │
│ │
│ ✅ PORTFOLIO PROJECT NYATA │
│ Setiap kelas menghasilkan projek portfolio. │
│ Bukan cuma teori — langsung praktik. │
│ Siap ditunjukkan ke recruiter atau client. │
│ │
│ ✅ SERTIFIKAT COMPLETION │
│ Sertifikat resmi setelah selesai kelas. │
│ Bisa ditambahkan ke LinkedIn dan CV. │
│ Bukti kompetensi yang terverifikasi. │
│ │
│ ✅ MENTORSHIP & SUPPORT │
│ Tanya jawab langsung dengan mentor expert. │
│ Response dalam 24 jam di forum diskusi. │
│ Bantuan debug dan review code. │
│ │
│ ✅ KOMUNITAS 900.000+ DEVELOPER │
│ Networking dengan sesama developer Indonesia. │
│ Sharing pengalaman dan tips karir. │
│ Info lowongan kerja dan freelance. │
│ │
│ ✅ SOURCE CODE LENGKAP │
│ Semua source code bisa di-download. │
│ Siap pakai untuk projek sendiri. │
│ Termasuk asset design dan dokumentasi. │
│ │
│ ✅ MATERI SELALU UPDATE │
│ Mengikuti versi terbaru teknologi. │
│ Laravel 12, Filament 3, Vue 3, dll. │
│ Tidak ketinggalan trend industri. │
│ │
│ ✅ KONSULTASI PROJEK │
│ Bantuan untuk projek freelance atau kerja. │
│ Review architecture dan best practice. │
│ Solusi untuk masalah teknis spesifik. │
│ │
└─────────────────────────────────────────────────────────┘
Mulai Belajar Sekarang
Jangan tunggu sampai "siap" — mulai aja dulu. Skill programming itu seperti otot, makin sering dilatih makin kuat.
Dengan kombinasi vibe coding untuk produktivitas + pemahaman fundamental dari kelas terstruktur, kamu bisa jadi developer yang efisien DAN kompeten.
Kunjungi buildwithangga.com dan pilih kelas yang sesuai dengan goal kamu.
Sampai ketemu di kelas! 🚀
Ditulis oleh:
Angga Risky Setiawan
Founder BuildWithAngga
Website: buildwithangga.com