Pelajari cara membangun sistem Penerimaan Mahasiswa Baru (PMB) online menggunakan Laravel 12 dan Filament 4 dengan pendekatan vibe coding 80% prompt 20% review. Tutorial lengkap menggunakan Claude AI untuk developer Indonesia yang ingin membangun aplikasi kampus modern dengan fitur pendaftaran multi-step, pembayaran Midtrans, dan generate kartu ujian PDF.
Bagian 1: Opening — Kenapa Sistem PMB Butuh Modernisasi
Saya pernah bantu satu universitas swasta di Jawa Timur yang masih pakai sistem PMB "semi-manual". Pendaftaran online — tapi cuma form Google Forms. Data masuk ke spreadsheet. Verifikasi berkas? Print satu-satu, cek manual, kasih stempel.
Hasilnya? Setiap gelombang pendaftaran, tim PMB kerja lembur berminggu-minggu. Data duplikat di mana-mana. Ada pendaftar yang bayar tapi tidak tercatat. Ada yang sudah verifikasi tapi kartu ujiannya hilang.
Ini tahun 2026. Sistem seperti itu seharusnya sudah tidak ada.
Masalah Sistem PMB Manual
Mari kita breakdown masalah dari tiga perspektif:
Perspektif Calon Mahasiswa:
- Harus datang ke kampus berkali-kali untuk submit berkas
- Tidak tahu status pendaftaran real-time
- Antri panjang untuk verifikasi dan ambil kartu ujian
- Kalau ada berkas kurang, harus balik lagi
Perspektif Orang Tua:
- Tidak bisa monitor progress anak
- Khawatir pembayaran tidak tercatat
- Tidak ada transparansi proses seleksi
Perspektif Panitia PMB:
- Data tersebar di banyak spreadsheet
- Verifikasi manual = human error tinggi
- Rekap laporan butuh waktu berhari-hari
- Koordinasi antar divisi sulit
Solusi: Sistem PMB Online Terintegrasi
Yang akan kita bangun di tutorial ini adalah sistem PMB lengkap:
FLOW SISTEM PMB ONLINE
┌─────────────────────────────────────────────────────────┐
│ CALON MAHASISWA │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. Registrasi Akun (email + password) │
│ ↓ │
│ 2. Pilih Jalur & Program Studi │
│ • Jalur: Reguler / Prestasi / KIP-Kuliah │
│ • Prodi: Pilihan 1 dan Pilihan 2 │
│ ↓ │
│ 3. Isi Formulir Biodata (5 tahap) │
│ • Data pribadi (NIK, alamat lengkap) │
│ • Data orang tua/wali │
│ • Data pendidikan (sekolah, nilai rapor) │
│ • Upload dokumen (foto, KTP, ijazah) │
│ ↓ │
│ 4. Bayar Biaya Pendaftaran │
│ • Midtrans: VA, QRIS, GoPay, dll │
│ ↓ │
│ 5. Tunggu Verifikasi Admin │
│ ↓ │
│ 6. Cetak Kartu Ujian (PDF) │
│ ↓ │
│ 7. Ikut Ujian │
│ ↓ │
│ 8. Cek Pengumuman Kelulusan │
│ ↓ │
│ 9. Daftar Ulang + Bayar UKT (jika lulus) │
│ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ ADMIN / PANITIA PMB │
├─────────────────────────────────────────────────────────┤
│ │
│ • Kelola master data (fakultas, prodi, gelombang) │
│ • Verifikasi berkas pendaftar │
│ • Atur jadwal dan ruang ujian │
│ • Input nilai ujian │
│ • Generate pengumuman kelulusan │
│ • Export data ke Excel │
│ • Monitor statistik real-time │
│ │
└─────────────────────────────────────────────────────────┘
Manual vs Online: Perbandingan
| Aspek | Manual/Spreadsheet | Sistem Online |
|---|---|---|
| Pendaftaran | Datang ke kampus | Kapan saja, di mana saja |
| Submit berkas | Print + antri | Upload langsung |
| Cek status | Telepon panitia | Real-time di dashboard |
| Pembayaran | Transfer + konfirmasi manual | Auto-verified via Midtrans |
| Verifikasi | Cek fisik satu-satu | Digital dengan checklist |
| Kartu ujian | Ambil di kampus | Download PDF |
| Pengumuman | Tempel di papan | Cek online dengan nomor peserta |
| Laporan | Rekap manual berhari-hari | Export Excel sekali klik |
| Human error | Tinggi | Minimal |
Kenapa Vibe Coding Cocok untuk Project Ini
Sistem PMB punya banyak bagian yang repetitif:
- CRUD untuk master data (fakultas, prodi, gelombang)
- Form dengan banyak field (biodata lengkap)
- Status management (draft → submitted → verified → ...)
- PDF generation dari template
- Export data ke format tertentu
Ini semua adalah pattern yang AI sangat bagus dalam generate. Dengan pendekatan 80/20:
80% dari prompt ke Claude:
- Migrations dan models
- Filament resources (CRUD)
- Form wizard steps
- PDF template
- Export logic
20% review dan edit manual:
- Validation rules spesifik Indonesia (NIK, format alamat)
- Business logic seleksi (rumus ranking, passing grade)
- Security (permission, data access)
- Edge cases (pendaftar batal, refund)
Tech Stack yang Akan Kita Pakai
| Technology | Fungsi |
|---|---|
| Laravel 12 | Backend framework |
| Filament 4 | Admin panel + Applicant portal |
| Blade + Tailwind | Frontend pages |
| Midtrans | Payment gateway |
| DomPDF | Generate kartu ujian PDF |
| Laravel Excel | Export data pendaftar |
| Claude AI | Vibe coding assistant |
Kenapa Filament 4 dengan dual panel?
Filament 4 support multiple panels dalam satu aplikasi. Kita akan buat:
- Admin Panel (
/admin) — untuk panitia PMB- Kelola master data
- Verifikasi pendaftar
- Input nilai
- Generate pengumuman
- Applicant Panel (
/pendaftaran) — untuk calon mahasiswa- Isi formulir pendaftaran
- Upload dokumen
- Bayar biaya
- Cetak kartu ujian
- Cek hasil
Ini lebih elegant daripada bikin 2 aplikasi terpisah atau mixed routes yang berantakan.
Yang Akan Kita Pelajari
Di akhir tutorial ini, kamu akan bisa:
✓ Setup Filament 4 dengan multiple panels
✓ Design database untuk sistem akademik kompleks
✓ Build multi-step form wizard dengan validation
✓ Handle file uploads (foto, dokumen)
✓ Integrate Midtrans untuk pembayaran
✓ Generate PDF (kartu ujian, bukti daftar ulang)
✓ Export data ke Excel
✓ Implement status workflow
✓ Build public announcement page
Mindset Sebelum Mulai
Sistem PMB adalah sistem yang kompleks dengan banyak stakeholder. Tapi dengan pendekatan vibe coding, kita akan pecah jadi bagian-bagian kecil yang manageable.
Setiap bagian akan punya pattern yang sama:
- Prompt — jelaskan ke Claude apa yang kamu mau
- Review — cek output, pastikan sesuai kebutuhan
- Edit — adjust untuk edge cases dan context spesifik
- Test — pastikan working sebelum lanjut
💡 TIPS PROJECT SISTEM KAMPUS:
1. Data akademik itu sensitif — perhatikan security
2. Tanggal-tanggal penting (deadline) harus strict
3. Status flow harus jelas dan tidak bisa di-bypass
4. Backup data sebelum setiap operasi batch
5. Log semua perubahan untuk audit trail
Sistem PMB yang baik bukan cuma soal teknologi,
tapi juga soal SOP yang jelas di belakangnya.
Siap membangun sistem PMB modern? Di bagian selanjutnya, kita akan setup project Laravel 12 dengan Filament 4 dual panel.
Bagian 2: Setup Project Laravel 12 + Filament 4 Dual Panel
Di bagian ini kita akan setup foundation project — Laravel 12 dengan Filament 4 yang punya dua panel terpisah: Admin untuk panitia PMB dan Applicant untuk calon mahasiswa.
Prompt 1: Install Laravel 12
Saya mau memulai project sistem PMB (Penerimaan Mahasiswa Baru)
dengan Laravel 12.
Berikan:
1. Command untuk create project baru dengan nama "pmb-online"
2. Konfigurasi .env untuk database MySQL
3. Command untuk generate application key dan jalankan migration
Database name: pmb_online_db
Response Claude (80%):
# 1. Create project Laravel 12
composer create-project laravel/laravel pmb-online "12.*"
cd pmb-online
# 2. Edit file .env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=pmb_online_db
DB_USERNAME=root
DB_PASSWORD=
# 3. Buat database
mysql -u root -p -e "CREATE DATABASE pmb_online_db"
# 4. Generate key dan migrate
php artisan key:generate
php artisan migrate
Manual Review (20%):
- ✅ Laravel 12 dengan wildcard version
- ✅ Database naming convention
- ⚠️ Sesuaikan DB_PASSWORD dengan setup lokal
Prompt 2: Install Filament 4 dengan Dual Panel
Install Filament 4 di project Laravel dan setup 2 panels:
1. Admin Panel:
- Path: /admin
- Untuk: Panitia PMB (staff universitas)
- Auth: guard 'web' dengan role check
2. Applicant Panel:
- Path: /pendaftaran
- Untuk: Calon mahasiswa
- Auth: guard 'web' (user biasa)
Kedua panel pakai tabel users yang sama, tapi dibedakan
dengan kolom 'role' (admin/staff/applicant).
Response Claude (80%):
# Install Filament 4
composer require filament/filament:"^4.0"
# Install panel pertama (Admin)
php artisan filament:install --panels
# Saat ditanya:
# - Panel ID: admin
# - Path: admin
Setelah install, kita perlu buat panel kedua secara manual dan modify User model.
Migration untuk tambah role:
<?php
// database/migrations/xxxx_add_role_to_users_table.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::table('users', function (Blueprint $table) {
$table->enum('role', ['admin', 'staff', 'applicant'])
->default('applicant')
->after('password');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};
Update User Model:
<?php
// app/Models/User.php
namespace App\\Models;
use Filament\\Models\\Contracts\\FilamentUser;
use Filament\\Panel;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
class User extends Authenticatable implements FilamentUser
{
use Notifiable;
protected $fillable = [
'name',
'email',
'password',
'role',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function canAccessPanel(Panel $panel): bool
{
if ($panel->getId() === 'admin') {
return in_array($this->role, ['admin', 'staff']);
}
if ($panel->getId() === 'applicant') {
return $this->role === 'applicant';
}
return false;
}
public function isAdmin(): bool
{
return $this->role === 'admin';
}
public function isStaff(): bool
{
return $this->role === 'staff';
}
public function isApplicant(): bool
{
return $this->role === 'applicant';
}
}
Manual Review (20%):
- ✅ FilamentUser interface implemented
- ✅ canAccessPanel untuk control akses per panel
- ✅ Helper methods untuk role checking
Prompt 3: Konfigurasi Admin Panel Provider
Konfigurasi AdminPanelProvider dengan:
- Warna primary hijau (tema kampus)
- Brand name "PMB Universitas Nusantara"
- Logo dari /images/logo.png
- Navigation groups: Master Data, Pendaftaran, Ujian, Laporan
- Dark mode enabled
- Favicon
Response Claude (80%):
<?php
// app/Providers/Filament/AdminPanelProvider.php
namespace App\\Providers\\Filament;
use Filament\\Http\\Middleware\\Authenticate;
use Filament\\Http\\Middleware\\DisableBladeIconComponents;
use Filament\\Http\\Middleware\\DispatchServingFilamentEvent;
use Filament\\Pages;
use Filament\\Panel;
use Filament\\PanelProvider;
use Filament\\Support\\Colors\\Color;
use Filament\\Widgets;
use Illuminate\\Cookie\\Middleware\\AddQueuedCookiesToResponse;
use Illuminate\\Cookie\\Middleware\\EncryptCookies;
use Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;
use Illuminate\\Routing\\Middleware\\SubstituteBindings;
use Illuminate\\Session\\Middleware\\AuthenticateSession;
use Illuminate\\Session\\Middleware\\StartSession;
use Illuminate\\View\\Middleware\\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->colors([
'primary' => Color::Emerald,
'danger' => Color::Rose,
'warning' => Color::Amber,
'success' => Color::Green,
'info' => Color::Sky,
])
->brandName('PMB Universitas Nusantara')
->brandLogo(asset('images/logo.png'))
->brandLogoHeight('2.5rem')
->favicon(asset('favicon.ico'))
->darkMode(true)
->navigationGroups([
'Master Data',
'Pendaftaran',
'Ujian',
'Laporan',
])
->sidebarCollapsibleOnDesktop()
->discoverResources(in: app_path('Filament/Admin/Resources'), for: 'App\\\\Filament\\\\Admin\\\\Resources')
->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\\\Filament\\\\Admin\\\\Pages')
->discoverWidgets(in: app_path('Filament/Admin/Widgets'), for: 'App\\\\Filament\\\\Admin\\\\Widgets')
->pages([
Pages\\Dashboard::class,
])
->widgets([
Widgets\\AccountWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}
Manual Review (20%):
- ✅ Warna Emerald (hijau kampus)
- ✅ Navigation groups sesuai kebutuhan PMB
- ✅ Resource path di folder Admin (untuk separation)
- ⚠️ Buat folder /public/images/ dan taruh logo
Prompt 4: Buat Applicant Panel Provider
Buatkan ApplicantPanelProvider untuk calon mahasiswa dengan:
- Path: /pendaftaran
- Warna primary biru
- Brand name "Portal Pendaftaran - Universitas Nusantara"
- Tanpa navigation groups (simpler UI)
- Registration enabled (calon mahasiswa bisa daftar akun)
- Email verification required
- Profile page untuk edit data diri
Response Claude (80%):
<?php
// app/Providers/Filament/ApplicantPanelProvider.php
namespace App\\Providers\\Filament;
use Filament\\Http\\Middleware\\Authenticate;
use Filament\\Http\\Middleware\\DisableBladeIconComponents;
use Filament\\Http\\Middleware\\DispatchServingFilamentEvent;
use Filament\\Pages;
use Filament\\Panel;
use Filament\\PanelProvider;
use Filament\\Support\\Colors\\Color;
use Filament\\Widgets;
use Illuminate\\Cookie\\Middleware\\AddQueuedCookiesToResponse;
use Illuminate\\Cookie\\Middleware\\EncryptCookies;
use Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;
use Illuminate\\Routing\\Middleware\\SubstituteBindings;
use Illuminate\\Session\\Middleware\\AuthenticateSession;
use Illuminate\\Session\\Middleware\\StartSession;
use Illuminate\\View\\Middleware\\ShareErrorsFromSession;
use App\\Filament\\Applicant\\Pages\\Registration;
class ApplicantPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('applicant')
->path('pendaftaran')
->login()
->registration(Registration::class)
->passwordReset()
->emailVerification()
->profile()
->colors([
'primary' => Color::Blue,
'danger' => Color::Rose,
'warning' => Color::Amber,
'success' => Color::Green,
])
->brandName('Portal Pendaftaran')
->brandLogo(asset('images/logo.png'))
->brandLogoHeight('2.5rem')
->favicon(asset('favicon.ico'))
->darkMode(false)
->topNavigation()
->discoverResources(in: app_path('Filament/Applicant/Resources'), for: 'App\\\\Filament\\\\Applicant\\\\Resources')
->discoverPages(in: app_path('Filament/Applicant/Pages'), for: 'App\\\\Filament\\\\Applicant\\\\Pages')
->discoverWidgets(in: app_path('Filament/Applicant/Widgets'), for: 'App\\\\Filament\\\\Applicant\\\\Widgets')
->pages([
Pages\\Dashboard::class,
])
->widgets([
Widgets\\AccountWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}
Custom Registration Page:
<?php
// app/Filament/Applicant/Pages/Registration.php
namespace App\\Filament\\Applicant\\Pages;
use Filament\\Forms\\Components\\TextInput;
use Filament\\Forms\\Form;
use Filament\\Pages\\Auth\\Register as BaseRegister;
class Registration extends BaseRegister
{
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('name')
->label('Nama Lengkap')
->required()
->maxLength(255),
$this->getEmailFormComponent()
->label('Email'),
$this->getPasswordFormComponent()
->label('Password'),
$this->getPasswordConfirmationFormComponent()
->label('Konfirmasi Password'),
]);
}
protected function mutateFormDataBeforeRegister(array $data): array
{
$data['role'] = 'applicant';
return $data;
}
}
Manual Review (20%):
- ✅ Separate panel dengan path berbeda
- ✅ Registration dengan auto-set role applicant
- ✅ Email verification enabled
- ✅ Top navigation untuk simpler UI
- ⚠️ Setup mail config untuk verification email
Prompt 5: Register Panel Providers
Bagaimana cara register kedua PanelProvider di Laravel 12?
Response Claude (80%):
<?php
// bootstrap/providers.php
return [
App\\Providers\\AppServiceProvider::class,
App\\Providers\\Filament\\AdminPanelProvider::class,
App\\Providers\\Filament\\ApplicantPanelProvider::class,
];
Manual Review (20%):
- ✅ Kedua provider registered
- Pastikan urutan AdminPanelProvider sebelum ApplicantPanelProvider (admin as default)
Setup Mail untuk Email Verification
# .env - untuk development pakai Mailtrap atau log
MAIL_MAILER=log
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="PMB Universitas Nusantara"
Untuk production, ganti ke SMTP provider seperti Mailtrap, SendGrid, atau mailserver kampus.
Buat Folder Structure
# Buat folder untuk kedua panels
mkdir -p app/Filament/Admin/Resources
mkdir -p app/Filament/Admin/Pages
mkdir -p app/Filament/Admin/Widgets
mkdir -p app/Filament/Applicant/Resources
mkdir -p app/Filament/Applicant/Pages
mkdir -p app/Filament/Applicant/Widgets
# Buat folder untuk logo
mkdir -p public/images
Buat Admin User untuk Testing
php artisan tinker
use App\\Models\\User;
User::create([
'name' => 'Admin PMB',
'email' => '[email protected]',
'password' => bcrypt('password123'),
'role' => 'admin',
'email_verified_at' => now(),
]);
Test Akses Panels
php artisan serve
Buka browser dan test:
| Panel | URL | Credentials |
|---|---|---|
| Admin | http://localhost:8000/admin | [email protected] / password123 |
| Applicant | http://localhost:8000/pendaftaran | (register akun baru) |
Pastikan:
- ✅ Admin bisa login ke /admin
- ✅ Admin TIDAK bisa akses /pendaftaran
- ✅ Applicant bisa register di /pendaftaran
- ✅ Applicant TIDAK bisa akses /admin
Struktur Project Sekarang
pmb-online/
├── app/
│ ├── Filament/
│ │ ├── Admin/ ← Resources untuk panitia
│ │ │ ├── Pages/
│ │ │ ├── Resources/
│ │ │ └── Widgets/
│ │ └── Applicant/ ← Resources untuk pendaftar
│ │ ├── Pages/
│ │ │ └── Registration.php
│ │ ├── Resources/
│ │ └── Widgets/
│ ├── Models/
│ │ └── User.php ← Dengan role & canAccessPanel
│ └── Providers/
│ └── Filament/
│ ├── AdminPanelProvider.php
│ └── ApplicantPanelProvider.php
├── database/
│ └── migrations/
│ └── xxxx_add_role_to_users_table.php
├── public/
│ └── images/ ← Logo kampus
└── bootstrap/
└── providers.php ← Both panels registered
💡 RECAP SETUP DUAL PANEL:
Prompts: 5
Files created/modified:
- 1 migration (add role)
- 2 Panel Providers (Admin, Applicant)
- 1 Custom Registration page
- 1 User model update
Key concepts:
- canAccessPanel() untuk control akses
- Separate resource folders per panel
- Custom registration dengan role assignment
- Email verification untuk applicant
Yang di-review manual:
- Role checking logic
- Registration flow
- Mail configuration
Setup selesai! Di bagian selanjutnya, kita akan design database untuk master data — fakultas, program studi, gelombang pendaftaran, dan jalur masuk.
Bagian 3: Database Design — Master Data
Sebelum bisa terima pendaftaran, kita perlu setup master data dulu: fakultas, program studi, gelombang pendaftaran, jalur masuk, dan jadwal ujian. Ini adalah data referensi yang jarang berubah tapi crucial untuk sistem PMB.
Prompt 1: ERD Master Data
Design ERD untuk master data sistem PMB dengan tabel:
1. faculties - Fakultas (contoh: Fakultas Teknik, Fakultas Ekonomi)
2. study_programs - Program Studi, relasi ke faculties
3. admission_waves - Gelombang pendaftaran (Gelombang 1, 2, dst)
4. admission_paths - Jalur masuk (Reguler, Prestasi, KIP-Kuliah)
5. exam_schedules - Jadwal ujian per gelombang
6. announcements - Pengumuman umum
Buatkan dalam format ASCII art dengan penjelasan relasi.
Response Claude (80%):
┌──────────────────┐
│ FACULTIES │
├──────────────────┤
│ id (PK) │
│ name │ "Fakultas Teknik"
│ code │ "FT"
│ dean_name │ Nama dekan
│ is_active │
│ created_at │
│ updated_at │
└────────┬─────────┘
│
│ 1:N
▼
┌──────────────────┐
│ STUDY_PROGRAMS │
├──────────────────┤
│ id (PK) │
│ faculty_id (FK) │───► faculties.id
│ name │ "Teknik Informatika"
│ code │ "TI"
│ degree │ S1/D3/D4
│ quota │ Kuota per gelombang
│ accreditation │ A/B/C/Unggul/Baik Sekali
│ tuition_fee │ Biaya UKT
│ description │
│ is_active │
│ created_at │
│ updated_at │
└──────────────────┘
┌──────────────────┐
│ ADMISSION_WAVES │
├──────────────────┤
│ id (PK) │
│ name │ "Gelombang 1"
│ year │ 2026
│ registration_ │ Tanggal buka
│ start_date │
│ registration_ │ Tanggal tutup
│ end_date │
│ exam_date │ Tanggal ujian
│ announcement_ │ Tanggal pengumuman
│ date │
│ re_registration_ │ Deadline daftar ulang
│ deadline │
│ status │ upcoming/open/closed/completed
│ is_active │
│ created_at │
│ updated_at │
└──────────────────┘
┌──────────────────┐
│ ADMISSION_PATHS │
├──────────────────┤
│ id (PK) │
│ name │ "Jalur Reguler"
│ code │ "REG"
│ registration_fee │ Biaya pendaftaran
│ requirements │ JSON: syarat-syarat
│ description │
│ exam_weight │ JSON: bobot nilai
│ is_active │
│ created_at │
│ updated_at │
└──────────────────┘
┌──────────────────┐ ┌──────────────────┐
│ EXAM_SCHEDULES │ │ ANNOUNCEMENTS │
├──────────────────┤ ├──────────────────┤
│ id (PK) │ │ id (PK) │
│ admission_wave_ │ │ title │
│ id (FK) │───► │ content │
│ exam_type │ │ type │
│ exam_date │ │ attachment │
│ start_time │ │ is_published │
│ end_time │ │ published_at │
│ rooms │ JSON │ created_at │
│ created_at │ │ updated_at │
│ updated_at │ └──────────────────┘
└──────────────────┘
RELASI:
• Faculty (1) ──── (N) Study Programs
• Admission Wave (1) ──── (N) Exam Schedules
Manual Review (20%):
- ✅ Relasi one-to-many faculty → programs
- ✅ Status enum untuk admission_waves
- ✅ JSON fields untuk flexible data (requirements, rooms)
- Struktur sudah bagus untuk kebutuhan PMB
Prompt 2: Migration Faculties
Buatkan migration untuk tabel faculties dengan:
- name (nama fakultas)
- code (kode singkat, unique, uppercase)
- dean_name (nama dekan, nullable)
- is_active (default true)
- Index untuk code
Response Claude (80%):
<?php
// database/migrations/2024_01_01_000001_create_faculties_table.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('faculties', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('code', 10)->unique();
$table->string('dean_name')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('faculties');
}
};
Manual Review (20%):
- ✅ Code dengan max length 10 (cukup untuk kode fakultas)
- ✅ Unique constraint otomatis create index
- Tidak perlu edit
Prompt 3: Migration Study Programs
Buatkan migration untuk study_programs dengan:
- Foreign key ke faculties
- name, code (unique per fakultas)
- degree (enum: D3, D4, S1, S2, S3)
- quota (integer, kuota mahasiswa)
- accreditation (enum: A, B, C, Unggul, Baik Sekali, Baik)
- tuition_fee (integer, UKT dalam rupiah)
- description (text, nullable)
- is_active
- Composite index untuk faculty_id + code
Response Claude (80%):
<?php
// database/migrations/2024_01_01_000002_create_study_programs_table.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('study_programs', function (Blueprint $table) {
$table->id();
$table->foreignId('faculty_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('code', 10);
$table->enum('degree', ['D3', 'D4', 'S1', 'S2', 'S3'])->default('S1');
$table->integer('quota')->default(0);
$table->enum('accreditation', [
'A', 'B', 'C',
'Unggul', 'Baik Sekali', 'Baik'
])->nullable();
$table->integer('tuition_fee')->default(0);
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['faculty_id', 'code']);
});
}
public function down(): void
{
Schema::dropIfExists('study_programs');
}
};
Manual Review (20%):
- ✅ Composite unique untuk faculty_id + code
- ✅ Accreditation include format baru (Unggul, Baik Sekali, Baik)
- ✅ Cascade delete — kalau fakultas dihapus, prodi ikut (hati-hati di production!)
Prompt 4: Migration Admission Waves
Buatkan migration untuk admission_waves (gelombang pendaftaran) dengan:
- name ("Gelombang 1", "Gelombang 2", dst)
- year (tahun akademik)
- registration_start_date, registration_end_date
- exam_date
- announcement_date
- re_registration_deadline
- status (enum: upcoming, open, closed, completed)
- is_active
- Index untuk year dan status
Response Claude (80%):
<?php
// database/migrations/2024_01_01_000003_create_admission_waves_table.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('admission_waves', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->year('year');
$table->date('registration_start_date');
$table->date('registration_end_date');
$table->date('exam_date')->nullable();
$table->date('announcement_date')->nullable();
$table->date('re_registration_deadline')->nullable();
$table->enum('status', [
'upcoming', // Belum dibuka
'open', // Sedang dibuka
'closed', // Pendaftaran ditutup, menunggu ujian
'completed' // Sudah selesai semua proses
])->default('upcoming');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['year', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('admission_waves');
}
};
Manual Review (20%):
- ✅ Semua tanggal penting ada
- ✅ Status enum dengan values yang jelas
- ✅ Composite index untuk query umum
Prompt 5: Migration Admission Paths
Buatkan migration untuk admission_paths (jalur masuk) dengan:
- name ("Jalur Reguler", "Jalur Prestasi", "Jalur KIP-Kuliah")
- code (REG, PRESTASI, KIP)
- registration_fee (biaya pendaftaran dalam rupiah)
- requirements (JSON array: syarat-syarat jalur)
- description (text)
- exam_weights (JSON object: bobot nilai ujian)
- is_active
Response Claude (80%):
<?php
// database/migrations/2024_01_01_000004_create_admission_paths_table.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('admission_paths', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('code', 20)->unique();
$table->integer('registration_fee')->default(0);
$table->json('requirements')->nullable();
$table->text('description')->nullable();
$table->json('exam_weights')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('admission_paths');
}
};
Contoh data exam_weights:
{
"written_exam": 40,
"interview": 30,
"achievement": 20,
"report_card": 10
}
Contoh data requirements:
[
"Lulusan SMA/SMK/MA sederajat",
"Nilai rata-rata rapor minimal 7.0",
"Usia maksimal 25 tahun",
"Sehat jasmani dan rohani"
]
Manual Review (20%):
- ✅ JSON fields untuk flexibility
- ✅ Registration fee per jalur (KIP mungkin gratis)
Prompt 6: Migration Exam Schedules
Buatkan migration untuk exam_schedules dengan:
- Foreign key ke admission_waves
- exam_type (enum: written, interview, practical, health_check)
- exam_date
- start_time, end_time
- rooms (JSON array of room info)
- notes (nullable)
Response Claude (80%):
<?php
// database/migrations/2024_01_01_000005_create_exam_schedules_table.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('exam_schedules', function (Blueprint $table) {
$table->id();
$table->foreignId('admission_wave_id')->constrained()->cascadeOnDelete();
$table->enum('exam_type', [
'written', // Ujian tulis
'interview', // Wawancara
'practical', // Ujian praktik (untuk prodi tertentu)
'health_check' // Tes kesehatan
]);
$table->date('exam_date');
$table->time('start_time');
$table->time('end_time');
$table->json('rooms')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['admission_wave_id', 'exam_type']);
});
}
public function down(): void
{
Schema::dropIfExists('exam_schedules');
}
};
Contoh data rooms:
[
{"room": "Gedung A - Ruang 101", "capacity": 40},
{"room": "Gedung A - Ruang 102", "capacity": 40},
{"room": "Gedung B - Ruang 201", "capacity": 35}
]
Manual Review (20%):
- ✅ Multiple exam types supported
- ✅ Flexible rooms dengan JSON
Prompt 7: Migration Announcements
Buatkan migration untuk announcements (pengumuman umum) dengan:
- title
- content (rich text)
- type (enum: info, important, result)
- attachment (file path, nullable)
- is_published
- published_at
Response Claude (80%):
<?php
// database/migrations/2024_01_01_000006_create_announcements_table.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('announcements', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->longText('content');
$table->enum('type', [
'info', // Informasi umum
'important', // Pengumuman penting
'result' // Pengumuman hasil
])->default('info');
$table->string('attachment')->nullable();
$table->boolean('is_published')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->index(['is_published', 'type']);
});
}
public function down(): void
{
Schema::dropIfExists('announcements');
}
};
Manual Review (20%):
- ✅ LongText untuk content (support rich text panjang)
- ✅ Index untuk query published announcements
Prompt 8: Eloquent Models
Buatkan Eloquent models untuk semua tabel master data dengan:
- Proper relationships
- Fillable fields
- Casts untuk JSON dan boolean
- Scopes untuk active records
- Accessors yang berguna
Response Claude (80%):
Faculty Model:
<?php
// app/Models/Faculty.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Faculty extends Model
{
protected $fillable = [
'name',
'code',
'dean_name',
'is_active',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
public function studyPrograms(): HasMany
{
return $this->hasMany(StudyProgram::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function getFullNameAttribute(): string
{
return "{$this->code} - {$this->name}";
}
}
StudyProgram Model:
<?php
// app/Models/StudyProgram.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class StudyProgram extends Model
{
protected $fillable = [
'faculty_id',
'name',
'code',
'degree',
'quota',
'accreditation',
'tuition_fee',
'description',
'is_active',
];
protected function casts(): array
{
return [
'quota' => 'integer',
'tuition_fee' => 'integer',
'is_active' => 'boolean',
];
}
public function faculty(): BelongsTo
{
return $this->belongsTo(Faculty::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function getFullNameAttribute(): string
{
return "{$this->degree} {$this->name}";
}
public function getFormattedTuitionAttribute(): string
{
return 'Rp ' . number_format($this->tuition_fee, 0, ',', '.');
}
public function getAccreditationBadgeColorAttribute(): string
{
return match($this->accreditation) {
'A', 'Unggul' => 'success',
'B', 'Baik Sekali' => 'info',
'C', 'Baik' => 'warning',
default => 'gray',
};
}
}
AdmissionWave Model:
<?php
// app/Models/AdmissionWave.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Carbon\\Carbon;
class AdmissionWave extends Model
{
protected $fillable = [
'name',
'year',
'registration_start_date',
'registration_end_date',
'exam_date',
'announcement_date',
're_registration_deadline',
'status',
'is_active',
];
protected function casts(): array
{
return [
'year' => 'integer',
'registration_start_date' => 'date',
'registration_end_date' => 'date',
'exam_date' => 'date',
'announcement_date' => 'date',
're_registration_deadline' => 'date',
'is_active' => 'boolean',
];
}
public function examSchedules(): HasMany
{
return $this->hasMany(ExamSchedule::class);
}
public function registrations(): HasMany
{
return $this->hasMany(Registration::class);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOpen($query)
{
return $query->where('status', 'open');
}
public function scopeCurrentYear($query)
{
return $query->where('year', date('Y'));
}
public function getFullNameAttribute(): string
{
return "{$this->name} - {$this->year}";
}
public function getStatusBadgeColorAttribute(): string
{
return match($this->status) {
'upcoming' => 'gray',
'open' => 'success',
'closed' => 'warning',
'completed' => 'info',
default => 'gray',
};
}
public function getIsOpenAttribute(): bool
{
$today = Carbon::today();
return $this->status === 'open'
&& $today->between($this->registration_start_date, $this->registration_end_date);
}
public function getRegistrationPeriodAttribute(): string
{
return $this->registration_start_date->format('d M Y')
. ' - '
. $this->registration_end_date->format('d M Y');
}
}
AdmissionPath Model:
<?php
// app/Models/AdmissionPath.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class AdmissionPath extends Model
{
protected $fillable = [
'name',
'code',
'registration_fee',
'requirements',
'description',
'exam_weights',
'is_active',
];
protected function casts(): array
{
return [
'registration_fee' => 'integer',
'requirements' => 'array',
'exam_weights' => 'array',
'is_active' => 'boolean',
];
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function getFormattedFeeAttribute(): string
{
if ($this->registration_fee === 0) {
return 'Gratis';
}
return 'Rp ' . number_format($this->registration_fee, 0, ',', '.');
}
}
ExamSchedule Model:
<?php
// app/Models/ExamSchedule.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class ExamSchedule extends Model
{
protected $fillable = [
'admission_wave_id',
'exam_type',
'exam_date',
'start_time',
'end_time',
'rooms',
'notes',
];
protected function casts(): array
{
return [
'exam_date' => 'date',
'start_time' => 'datetime:H:i',
'end_time' => 'datetime:H:i',
'rooms' => 'array',
];
}
public function admissionWave(): BelongsTo
{
return $this->belongsTo(AdmissionWave::class);
}
public function getExamTypeLabelAttribute(): string
{
return match($this->exam_type) {
'written' => 'Ujian Tulis',
'interview' => 'Wawancara',
'practical' => 'Ujian Praktik',
'health_check' => 'Tes Kesehatan',
default => $this->exam_type,
};
}
public function getTimeRangeAttribute(): string
{
return $this->start_time->format('H:i') . ' - ' . $this->end_time->format('H:i');
}
public function getTotalCapacityAttribute(): int
{
if (empty($this->rooms)) return 0;
return collect($this->rooms)->sum('capacity');
}
}
Announcement Model:
<?php
// app/Models/Announcement.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Support\\Facades\\Storage;
class Announcement extends Model
{
protected $fillable = [
'title',
'content',
'type',
'attachment',
'is_published',
'published_at',
];
protected function casts(): array
{
return [
'is_published' => 'boolean',
'published_at' => 'datetime',
];
}
public function scopePublished($query)
{
return $query->where('is_published', true)
->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function getTypeBadgeColorAttribute(): string
{
return match($this->type) {
'info' => 'info',
'important' => 'danger',
'result' => 'success',
default => 'gray',
};
}
public function getTypeLabelAttribute(): string
{
return match($this->type) {
'info' => 'Informasi',
'important' => 'Penting',
'result' => 'Hasil Seleksi',
default => $this->type,
};
}
public function getAttachmentUrlAttribute(): ?string
{
return $this->attachment ? Storage::url($this->attachment) : null;
}
}
Manual Review (20%):
- ✅ Semua relationships benar
- ✅ JSON casts untuk array fields
- ✅ Accessors untuk formatted values
- ✅ Scopes untuk common queries
- ✅ Badge color attributes untuk UI
Prompt 9: Seeder Data Awal
Buatkan DatabaseSeeder untuk populate:
- 5 fakultas dengan kode
- 15-20 program studi (3-4 per fakultas)
- 2 gelombang pendaftaran untuk tahun 2026
- 3 jalur masuk (Reguler, Prestasi, KIP-Kuliah)
Pakai data realistis Indonesia.
Response Claude (80%):
<?php
// database/seeders/DatabaseSeeder.php
namespace Database\\Seeders;
use App\\Models\\Faculty;
use App\\Models\\StudyProgram;
use App\\Models\\AdmissionWave;
use App\\Models\\AdmissionPath;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->seedFacultiesAndPrograms();
$this->seedAdmissionWaves();
$this->seedAdmissionPaths();
}
private function seedFacultiesAndPrograms(): void
{
$faculties = [
[
'name' => 'Fakultas Teknik',
'code' => 'FT',
'dean_name' => 'Dr. Ir. Bambang Sudrajat, M.T.',
'programs' => [
['name' => 'Teknik Informatika', 'code' => 'TI', 'degree' => 'S1', 'quota' => 120, 'accreditation' => 'Unggul', 'tuition_fee' => 8500000],
['name' => 'Teknik Elektro', 'code' => 'TE', 'degree' => 'S1', 'quota' => 80, 'accreditation' => 'A', 'tuition_fee' => 8000000],
['name' => 'Teknik Mesin', 'code' => 'TM', 'degree' => 'S1', 'quota' => 80, 'accreditation' => 'A', 'tuition_fee' => 8000000],
['name' => 'Teknik Sipil', 'code' => 'TS', 'degree' => 'S1', 'quota' => 100, 'accreditation' => 'Unggul', 'tuition_fee' => 7500000],
],
],
[
'name' => 'Fakultas Ekonomi dan Bisnis',
'code' => 'FEB',
'dean_name' => 'Prof. Dr. Siti Rahayu, S.E., M.M.',
'programs' => [
['name' => 'Manajemen', 'code' => 'MN', 'degree' => 'S1', 'quota' => 150, 'accreditation' => 'Unggul', 'tuition_fee' => 7000000],
['name' => 'Akuntansi', 'code' => 'AK', 'degree' => 'S1', 'quota' => 120, 'accreditation' => 'A', 'tuition_fee' => 7000000],
['name' => 'Ekonomi Pembangunan', 'code' => 'EP', 'degree' => 'S1', 'quota' => 60, 'accreditation' => 'Baik Sekali', 'tuition_fee' => 6500000],
],
],
[
'name' => 'Fakultas Ilmu Komputer',
'code' => 'FILKOM',
'dean_name' => 'Dr. Ahmad Fauzi, S.Kom., M.Cs.',
'programs' => [
['name' => 'Sistem Informasi', 'code' => 'SI', 'degree' => 'S1', 'quota' => 100, 'accreditation' => 'Unggul', 'tuition_fee' => 8500000],
['name' => 'Teknologi Informasi', 'code' => 'TIF', 'degree' => 'S1', 'quota' => 80, 'accreditation' => 'A', 'tuition_fee' => 8000000],
['name' => 'Data Science', 'code' => 'DS', 'degree' => 'S1', 'quota' => 60, 'accreditation' => 'Baik Sekali', 'tuition_fee' => 9000000],
],
],
[
'name' => 'Fakultas Hukum',
'code' => 'FH',
'dean_name' => 'Prof. Dr. Hendra Wijaya, S.H., M.H.',
'programs' => [
['name' => 'Ilmu Hukum', 'code' => 'IH', 'degree' => 'S1', 'quota' => 120, 'accreditation' => 'A', 'tuition_fee' => 6500000],
],
],
[
'name' => 'Fakultas Ilmu Komunikasi',
'code' => 'FIKOM',
'dean_name' => 'Dr. Maya Sari, S.I.Kom., M.I.Kom.',
'programs' => [
['name' => 'Ilmu Komunikasi', 'code' => 'IKOM', 'degree' => 'S1', 'quota' => 100, 'accreditation' => 'A', 'tuition_fee' => 7000000],
['name' => 'Hubungan Masyarakat', 'code' => 'HUMAS', 'degree' => 'S1', 'quota' => 60, 'accreditation' => 'Baik Sekali', 'tuition_fee' => 6500000],
['name' => 'Jurnalistik', 'code' => 'JRN', 'degree' => 'S1', 'quota' => 50, 'accreditation' => 'B', 'tuition_fee' => 6000000],
],
],
];
foreach ($faculties as $facultyData) {
$programs = $facultyData['programs'];
unset($facultyData['programs']);
$faculty = Faculty::create($facultyData);
foreach ($programs as $program) {
$faculty->studyPrograms()->create($program);
}
}
}
private function seedAdmissionWaves(): void
{
AdmissionWave::create([
'name' => 'Gelombang 1',
'year' => 2026,
'registration_start_date' => '2026-01-15',
'registration_end_date' => '2026-03-31',
'exam_date' => '2026-04-15',
'announcement_date' => '2026-04-30',
're_registration_deadline' => '2026-05-15',
'status' => 'open',
'is_active' => true,
]);
AdmissionWave::create([
'name' => 'Gelombang 2',
'year' => 2026,
'registration_start_date' => '2026-05-01',
'registration_end_date' => '2026-06-30',
'exam_date' => '2026-07-10',
'announcement_date' => '2026-07-25',
're_registration_deadline' => '2026-08-10',
'status' => 'upcoming',
'is_active' => true,
]);
}
private function seedAdmissionPaths(): void
{
AdmissionPath::create([
'name' => 'Jalur Reguler',
'code' => 'REG',
'registration_fee' => 350000,
'requirements' => [
'Lulusan SMA/SMK/MA sederajat',
'Nilai rata-rata rapor minimal 7.0',
'Usia maksimal 25 tahun saat mendaftar',
'Sehat jasmani dan rohani',
],
'exam_weights' => [
'written_exam' => 50,
'interview' => 30,
'report_card' => 20,
],
'description' => 'Jalur seleksi umum melalui ujian tulis dan wawancara.',
'is_active' => true,
]);
AdmissionPath::create([
'name' => 'Jalur Prestasi',
'code' => 'PRESTASI',
'registration_fee' => 250000,
'requirements' => [
'Lulusan SMA/SMK/MA sederajat',
'Nilai rata-rata rapor minimal 8.0',
'Memiliki prestasi akademik atau non-akademik tingkat kabupaten/kota atau lebih tinggi',
'Melampirkan sertifikat/piagam prestasi',
],
'exam_weights' => [
'achievement' => 40,
'interview' => 35,
'report_card' => 25,
],
'description' => 'Jalur seleksi bagi siswa berprestasi tanpa ujian tulis.',
'is_active' => true,
]);
AdmissionPath::create([
'name' => 'Jalur KIP-Kuliah',
'code' => 'KIP',
'registration_fee' => 0,
'requirements' => [
'Lulusan SMA/SMK/MA sederajat',
'Terdaftar sebagai penerima KIP atau dari keluarga kurang mampu',
'Memiliki SKTM atau dokumen pendukung lainnya',
'Nilai rata-rata rapor minimal 7.5',
],
'exam_weights' => [
'written_exam' => 40,
'interview' => 30,
'report_card' => 30,
],
'description' => 'Jalur khusus bagi calon mahasiswa dari keluarga kurang mampu. Biaya pendaftaran GRATIS.',
'is_active' => true,
]);
}
}
Manual Review (20%):
- ✅ Data realistis Indonesia
- ✅ UKT dalam range wajar (6-9 juta)
- ✅ Akreditasi sesuai format baru
- ✅ KIP dengan registration_fee = 0
Jalankan seeder:
php artisan migrate:fresh --seed
💡 RECAP DATABASE MASTER DATA:
Prompts: 9
Tables created: 6
- faculties
- study_programs
- admission_waves
- admission_paths
- exam_schedules
- announcements
Models created: 6 dengan relationships dan accessors
Seeder: Data awal 5 fakultas, 14 prodi, 2 gelombang, 3 jalur
Yang di-review manual:
- Cascade delete implications
- JSON structure untuk requirements/weights
- Status enum values
- Accreditation format baru
Di bagian selanjutnya, kita akan buat migration untuk tabel utama: registrations — yang menyimpan semua data pendaftar dari biodata sampai hasil seleksi.
Bagian 4: Database Design — Tabel Registrations
Ini adalah tabel paling kompleks di sistem PMB. Tabel registrations menyimpan semua data pendaftar — dari biodata lengkap, data orang tua, riwayat pendidikan, dokumen upload, sampai hasil ujian dan status kelulusan.
Prompt 1: Analisis Fields yang Dibutuhkan
Analisis field-field yang dibutuhkan untuk tabel registrations
di sistem PMB Indonesia. Kelompokkan menjadi:
1. Relasi ke tabel lain
2. Data pribadi pendaftar
3. Data orang tua/wali
4. Data pendidikan
5. Dokumen upload
6. Data ujian dan seleksi
7. Status dan payment
8. Daftar ulang
Perhatikan format data Indonesia (NIK 16 digit, alamat
dengan RT/RW/kelurahan/kecamatan, dll).
Response Claude (80%):
ANALISIS FIELD REGISTRATIONS
1. RELASI KE TABEL LAIN
─────────────────────────────────────────
• user_id → users (pendaftar)
• admission_wave_id → admission_waves (gelombang)
• admission_path_id → admission_paths (jalur)
• first_choice_id → study_programs (pilihan 1)
• second_choice_id → study_programs (pilihan 2)
• accepted_program_id → study_programs (diterima di)
2. DATA PRIBADI
─────────────────────────────────────────
• registration_number (auto-generate, unique)
• nik (16 digit, unique)
• full_name
• nickname
• birth_place
• birth_date
• gender (male/female)
• religion (islam/kristen/katolik/hindu/buddha/konghucu)
• blood_type (A/B/AB/O)
• phone
• email (dari user, readonly)
3. ALAMAT LENGKAP
─────────────────────────────────────────
• address (jalan, nomor rumah)
• rt, rw
• village (kelurahan/desa)
• district (kecamatan)
• city (kabupaten/kota)
• province
• postal_code
4. DATA ORANG TUA
─────────────────────────────────────────
Ayah:
• father_name
• father_nik
• father_birth_date
• father_education (SD/SMP/SMA/D3/S1/S2/S3)
• father_occupation
• father_income (range)
• father_phone
Ibu:
• mother_name
• mother_nik
• mother_birth_date
• mother_education
• mother_occupation
• mother_income
• mother_phone
Wali (jika bukan orang tua kandung):
• guardian_name
• guardian_phone
• guardian_relation
5. DATA PENDIDIKAN
─────────────────────────────────────────
• school_name
• school_npsn (Nomor Pokok Sekolah Nasional)
• school_type (SMA/SMK/MA/Paket C)
• school_address
• school_city
• school_province
• graduation_year
• major (jurusan untuk SMK)
Nilai Rapor (rata-rata per semester):
• semester_1_avg ... semester_5_avg
• average_score (kalkulasi total)
6. DOKUMEN UPLOAD
─────────────────────────────────────────
• photo (pas foto 3x4)
• ktp_scan
• family_card_scan (KK)
• birth_certificate_scan (akta lahir)
• diploma_scan (ijazah/SKL)
• transcript_scan (transkrip nilai)
• health_certificate_scan (surat kesehatan)
• achievement_certificates (JSON array, sertifikat prestasi)
• kip_card_scan (untuk jalur KIP)
7. DATA UJIAN & SELEKSI
─────────────────────────────────────────
• exam_number (nomor peserta ujian)
• exam_room
• exam_seat
• written_exam_score
• interview_score
• achievement_score
• final_score (kalkulasi dengan bobot)
• ranking (per program studi)
• result (passed/failed/waitlist)
• announced_at
8. STATUS & PAYMENT
─────────────────────────────────────────
Status pendaftaran:
• status (enum dengan banyak nilai)
• submitted_at
• verification_notes
• verified_at
• verified_by (admin user_id)
Payment:
• payment_status (unpaid/pending/paid)
• payment_amount
• midtrans_transaction_id
• payment_type
• paid_at
9. DAFTAR ULANG
─────────────────────────────────────────
• re_registration_status (pending/completed)
• nim (Nomor Induk Mahasiswa, assigned saat daftar ulang)
• ukt_amount
• ukt_paid_at
• re_registration_completed_at
Manual Review (20%):
- ✅ Komprehensif untuk kebutuhan PMB Indonesia
- ✅ Format Indonesia (NIK, NPSN, RT/RW)
- ⚠️ Tabel akan sangat lebar — perlu pertimbangkan normalisasi untuk production scale
Prompt 2: Migration Registrations
Buatkan migration untuk tabel registrations berdasarkan
analisis di atas. Pisahkan dengan comment section untuk
readability. Include proper indexes untuk:
- user_id
- admission_wave_id + status
- exam_number
- registration_number
- nik
Response Claude (80%):
<?php
// database/migrations/2024_01_01_000010_create_registrations_table.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('registrations', function (Blueprint $table) {
$table->id();
// ============================================
// RELASI
// ============================================
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('admission_wave_id')->constrained()->restrictOnDelete();
$table->foreignId('admission_path_id')->constrained()->restrictOnDelete();
$table->foreignId('first_choice_id')->constrained('study_programs')->restrictOnDelete();
$table->foreignId('second_choice_id')->nullable()->constrained('study_programs')->restrictOnDelete();
$table->foreignId('accepted_program_id')->nullable()->constrained('study_programs')->nullOnDelete();
// ============================================
// NOMOR REGISTRASI
// ============================================
$table->string('registration_number', 30)->unique();
// ============================================
// DATA PRIBADI
// ============================================
$table->string('nik', 16)->unique();
$table->string('full_name');
$table->string('nickname', 50)->nullable();
$table->string('birth_place');
$table->date('birth_date');
$table->enum('gender', ['male', 'female']);
$table->enum('religion', [
'islam', 'kristen', 'katolik', 'hindu', 'buddha', 'konghucu'
]);
$table->enum('blood_type', ['A', 'B', 'AB', 'O'])->nullable();
$table->string('phone', 20);
// ============================================
// ALAMAT
// ============================================
$table->text('address');
$table->string('rt', 5)->nullable();
$table->string('rw', 5)->nullable();
$table->string('village', 100); // Kelurahan/Desa
$table->string('district', 100); // Kecamatan
$table->string('city', 100); // Kabupaten/Kota
$table->string('province', 100);
$table->string('postal_code', 10)->nullable();
// ============================================
// DATA AYAH
// ============================================
$table->string('father_name');
$table->string('father_nik', 16)->nullable();
$table->date('father_birth_date')->nullable();
$table->enum('father_education', [
'sd', 'smp', 'sma', 'd1', 'd2', 'd3', 'd4', 's1', 's2', 's3'
])->nullable();
$table->string('father_occupation')->nullable();
$table->enum('father_income', [
'below_1m', // < 1 juta
'1m_to_3m', // 1-3 juta
'3m_to_5m', // 3-5 juta
'5m_to_10m', // 5-10 juta
'above_10m' // > 10 juta
])->nullable();
$table->string('father_phone', 20)->nullable();
// ============================================
// DATA IBU
// ============================================
$table->string('mother_name');
$table->string('mother_nik', 16)->nullable();
$table->date('mother_birth_date')->nullable();
$table->enum('mother_education', [
'sd', 'smp', 'sma', 'd1', 'd2', 'd3', 'd4', 's1', 's2', 's3'
])->nullable();
$table->string('mother_occupation')->nullable();
$table->enum('mother_income', [
'below_1m', '1m_to_3m', '3m_to_5m', '5m_to_10m', 'above_10m'
])->nullable();
$table->string('mother_phone', 20)->nullable();
// ============================================
// DATA WALI (OPSIONAL)
// ============================================
$table->string('guardian_name')->nullable();
$table->string('guardian_phone', 20)->nullable();
$table->string('guardian_relation', 50)->nullable();
// ============================================
// DATA PENDIDIKAN
// ============================================
$table->string('school_name');
$table->string('school_npsn', 20)->nullable();
$table->enum('school_type', ['sma', 'smk', 'ma', 'paket_c']);
$table->text('school_address')->nullable();
$table->string('school_city', 100)->nullable();
$table->string('school_province', 100)->nullable();
$table->year('graduation_year');
$table->string('school_major')->nullable(); // Jurusan untuk SMK
// Nilai Rapor per Semester
$table->decimal('semester_1_avg', 5, 2)->nullable();
$table->decimal('semester_2_avg', 5, 2)->nullable();
$table->decimal('semester_3_avg', 5, 2)->nullable();
$table->decimal('semester_4_avg', 5, 2)->nullable();
$table->decimal('semester_5_avg', 5, 2)->nullable();
$table->decimal('average_score', 5, 2)->nullable(); // Rata-rata keseluruhan
// ============================================
// DOKUMEN UPLOAD
// ============================================
$table->string('photo')->nullable();
$table->string('ktp_scan')->nullable();
$table->string('family_card_scan')->nullable();
$table->string('birth_certificate_scan')->nullable();
$table->string('diploma_scan')->nullable();
$table->string('transcript_scan')->nullable();
$table->string('health_certificate_scan')->nullable();
$table->json('achievement_certificates')->nullable(); // Array of file paths
$table->string('kip_card_scan')->nullable(); // Untuk jalur KIP
// ============================================
// DATA UJIAN & SELEKSI
// ============================================
$table->string('exam_number', 30)->nullable()->unique();
$table->string('exam_room', 50)->nullable();
$table->string('exam_seat', 10)->nullable();
$table->decimal('written_exam_score', 5, 2)->nullable();
$table->decimal('interview_score', 5, 2)->nullable();
$table->decimal('achievement_score', 5, 2)->nullable();
$table->decimal('final_score', 5, 2)->nullable();
$table->integer('ranking')->nullable();
$table->enum('result', ['passed', 'failed', 'waitlist'])->nullable();
$table->timestamp('announced_at')->nullable();
// ============================================
// STATUS PENDAFTARAN
// ============================================
$table->enum('status', [
'draft', // Masih mengisi form
'submitted', // Sudah submit, menunggu bayar
'paid', // Sudah bayar, menunggu verifikasi
'verification_failed', // Dokumen ditolak
'verified', // Dokumen diverifikasi
'exam_scheduled', // Jadwal ujian sudah ditentukan
'exam_completed', // Sudah selesai ujian
'selection', // Dalam proses seleksi
'announced', // Sudah diumumkan
'accepted', // Diterima
'rejected', // Tidak diterima
're_registered', // Sudah daftar ulang
'cancelled' // Dibatalkan
])->default('draft');
$table->timestamp('submitted_at')->nullable();
$table->text('verification_notes')->nullable();
$table->timestamp('verified_at')->nullable();
$table->foreignId('verified_by')->nullable()->constrained('users')->nullOnDelete();
// ============================================
// PAYMENT
// ============================================
$table->enum('payment_status', ['unpaid', 'pending', 'paid'])->default('unpaid');
$table->integer('payment_amount')->nullable();
$table->string('midtrans_transaction_id')->nullable();
$table->string('payment_type', 50)->nullable();
$table->timestamp('paid_at')->nullable();
// ============================================
// DAFTAR ULANG
// ============================================
$table->enum('re_registration_status', ['pending', 'completed'])->nullable();
$table->string('nim', 20)->nullable()->unique(); // Nomor Induk Mahasiswa
$table->integer('ukt_amount')->nullable();
$table->timestamp('ukt_paid_at')->nullable();
$table->timestamp('re_registration_completed_at')->nullable();
$table->timestamps();
// ============================================
// INDEXES
// ============================================
$table->index(['admission_wave_id', 'status']);
$table->index(['admission_wave_id', 'admission_path_id']);
$table->index(['first_choice_id', 'status']);
$table->index(['status', 'payment_status']);
});
}
public function down(): void
{
Schema::dropIfExists('registrations');
}
};
Manual Review (20%):
- ✅ Semua field terorganisir dengan comment sections
- ✅ Proper foreign key constraints (restrict untuk data penting)
- ✅ Composite indexes untuk query umum
- ✅ Nullable untuk optional fields
- ⚠️ Migration ini sangat panjang — di production bisa dipecah jadi beberapa migration
Prompt 3: Registration Model
Buatkan Registration model dengan:
- Semua relationships (belongsTo, hasOne)
- Casts untuk dates, JSON, decimal
- Scopes untuk filter status, wave, path
- Accessors untuk:
- formatted dates
- status labels dalam Bahasa Indonesia
- badge colors
- full address
- calculated scores
- Auto-generate registration_number saat creating
- Auto-calculate average_score dari nilai semester
Response Claude (80%):
<?php
// app/Models/Registration.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Support\\Facades\\Storage;
use Illuminate\\Support\\Str;
class Registration extends Model
{
protected $fillable = [
// Relasi
'user_id',
'admission_wave_id',
'admission_path_id',
'first_choice_id',
'second_choice_id',
'accepted_program_id',
// Identitas
'registration_number',
'nik',
'full_name',
'nickname',
'birth_place',
'birth_date',
'gender',
'religion',
'blood_type',
'phone',
// Alamat
'address',
'rt',
'rw',
'village',
'district',
'city',
'province',
'postal_code',
// Data Ayah
'father_name',
'father_nik',
'father_birth_date',
'father_education',
'father_occupation',
'father_income',
'father_phone',
// Data Ibu
'mother_name',
'mother_nik',
'mother_birth_date',
'mother_education',
'mother_occupation',
'mother_income',
'mother_phone',
// Data Wali
'guardian_name',
'guardian_phone',
'guardian_relation',
// Data Pendidikan
'school_name',
'school_npsn',
'school_type',
'school_address',
'school_city',
'school_province',
'graduation_year',
'school_major',
'semester_1_avg',
'semester_2_avg',
'semester_3_avg',
'semester_4_avg',
'semester_5_avg',
'average_score',
// Dokumen
'photo',
'ktp_scan',
'family_card_scan',
'birth_certificate_scan',
'diploma_scan',
'transcript_scan',
'health_certificate_scan',
'achievement_certificates',
'kip_card_scan',
// Ujian
'exam_number',
'exam_room',
'exam_seat',
'written_exam_score',
'interview_score',
'achievement_score',
'final_score',
'ranking',
'result',
'announced_at',
// Status
'status',
'submitted_at',
'verification_notes',
'verified_at',
'verified_by',
// Payment
'payment_status',
'payment_amount',
'midtrans_transaction_id',
'payment_type',
'paid_at',
// Daftar Ulang
're_registration_status',
'nim',
'ukt_amount',
'ukt_paid_at',
're_registration_completed_at',
];
protected function casts(): array
{
return [
'birth_date' => 'date',
'father_birth_date' => 'date',
'mother_birth_date' => 'date',
'graduation_year' => 'integer',
'semester_1_avg' => 'decimal:2',
'semester_2_avg' => 'decimal:2',
'semester_3_avg' => 'decimal:2',
'semester_4_avg' => 'decimal:2',
'semester_5_avg' => 'decimal:2',
'average_score' => 'decimal:2',
'written_exam_score' => 'decimal:2',
'interview_score' => 'decimal:2',
'achievement_score' => 'decimal:2',
'final_score' => 'decimal:2',
'payment_amount' => 'integer',
'ukt_amount' => 'integer',
'achievement_certificates' => 'array',
'submitted_at' => 'datetime',
'verified_at' => 'datetime',
'announced_at' => 'datetime',
'paid_at' => 'datetime',
'ukt_paid_at' => 'datetime',
're_registration_completed_at' => 'datetime',
];
}
// ============================================
// BOOT
// ============================================
protected static function booted(): void
{
static::creating(function (Registration $registration) {
if (empty($registration->registration_number)) {
$registration->registration_number = self::generateRegistrationNumber($registration);
}
});
static::saving(function (Registration $registration) {
// Auto-calculate average score
$registration->calculateAverageScore();
});
}
public static function generateRegistrationNumber(Registration $registration): string
{
$wave = AdmissionWave::find($registration->admission_wave_id);
$path = AdmissionPath::find($registration->admission_path_id);
$year = $wave?->year ?? date('Y');
$waveNum = $wave ? Str::padLeft($wave->id, 2, '0') : '01';
$pathCode = $path?->code ?? 'REG';
$lastNumber = self::where('admission_wave_id', $registration->admission_wave_id)
->where('admission_path_id', $registration->admission_path_id)
->max('id') ?? 0;
$sequence = Str::padLeft($lastNumber + 1, 5, '0');
return "PMB-{$year}-{$waveNum}-{$pathCode}-{$sequence}";
}
public function calculateAverageScore(): void
{
$scores = array_filter([
$this->semester_1_avg,
$this->semester_2_avg,
$this->semester_3_avg,
$this->semester_4_avg,
$this->semester_5_avg,
], fn($score) => $score !== null);
if (count($scores) > 0) {
$this->average_score = round(array_sum($scores) / count($scores), 2);
}
}
// ============================================
// RELATIONSHIPS
// ============================================
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function admissionWave(): BelongsTo
{
return $this->belongsTo(AdmissionWave::class);
}
public function admissionPath(): BelongsTo
{
return $this->belongsTo(AdmissionPath::class);
}
public function firstChoice(): BelongsTo
{
return $this->belongsTo(StudyProgram::class, 'first_choice_id');
}
public function secondChoice(): BelongsTo
{
return $this->belongsTo(StudyProgram::class, 'second_choice_id');
}
public function acceptedProgram(): BelongsTo
{
return $this->belongsTo(StudyProgram::class, 'accepted_program_id');
}
public function verifiedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by');
}
// ============================================
// SCOPES
// ============================================
public function scopeForWave($query, $waveId)
{
return $query->where('admission_wave_id', $waveId);
}
public function scopeForPath($query, $pathId)
{
return $query->where('admission_path_id', $pathId);
}
public function scopeWithStatus($query, $status)
{
return $query->where('status', $status);
}
public function scopeSubmitted($query)
{
return $query->whereNotIn('status', ['draft', 'cancelled']);
}
public function scopePendingVerification($query)
{
return $query->where('status', 'paid');
}
public function scopeVerified($query)
{
return $query->whereIn('status', ['verified', 'exam_scheduled', 'exam_completed', 'selection', 'announced', 'accepted', 're_registered']);
}
public function scopeAccepted($query)
{
return $query->whereIn('status', ['accepted', 're_registered']);
}
public function scopePendingPayment($query)
{
return $query->where('status', 'submitted')
->where('payment_status', 'unpaid');
}
// ============================================
// ACCESSORS - STATUS
// ============================================
public function getStatusLabelAttribute(): string
{
return match($this->status) {
'draft' => 'Draft',
'submitted' => 'Menunggu Pembayaran',
'paid' => 'Menunggu Verifikasi',
'verification_failed' => 'Verifikasi Gagal',
'verified' => 'Terverifikasi',
'exam_scheduled' => 'Ujian Dijadwalkan',
'exam_completed' => 'Ujian Selesai',
'selection' => 'Proses Seleksi',
'announced' => 'Diumumkan',
'accepted' => 'Diterima',
'rejected' => 'Tidak Diterima',
're_registered' => 'Sudah Daftar Ulang',
'cancelled' => 'Dibatalkan',
default => $this->status,
};
}
public function getStatusColorAttribute(): string
{
return match($this->status) {
'draft' => 'gray',
'submitted' => 'warning',
'paid' => 'info',
'verification_failed' => 'danger',
'verified' => 'success',
'exam_scheduled' => 'primary',
'exam_completed' => 'primary',
'selection' => 'warning',
'announced' => 'info',
'accepted' => 'success',
'rejected' => 'danger',
're_registered' => 'success',
'cancelled' => 'gray',
default => 'gray',
};
}
public function getPaymentStatusLabelAttribute(): string
{
return match($this->payment_status) {
'unpaid' => 'Belum Bayar',
'pending' => 'Menunggu Konfirmasi',
'paid' => 'Sudah Bayar',
default => $this->payment_status,
};
}
public function getResultLabelAttribute(): ?string
{
return match($this->result) {
'passed' => 'LULUS',
'failed' => 'TIDAK LULUS',
'waitlist' => 'CADANGAN',
default => null,
};
}
public function getResultColorAttribute(): string
{
return match($this->result) {
'passed' => 'success',
'failed' => 'danger',
'waitlist' => 'warning',
default => 'gray',
};
}
// ============================================
// ACCESSORS - DATA FORMATTING
// ============================================
public function getGenderLabelAttribute(): string
{
return match($this->gender) {
'male' => 'Laki-laki',
'female' => 'Perempuan',
default => '-',
};
}
public function getReligionLabelAttribute(): string
{
return match($this->religion) {
'islam' => 'Islam',
'kristen' => 'Kristen',
'katolik' => 'Katolik',
'hindu' => 'Hindu',
'buddha' => 'Buddha',
'konghucu' => 'Konghucu',
default => '-',
};
}
public function getFullAddressAttribute(): string
{
$parts = array_filter([
$this->address,
$this->rt ? "RT {$this->rt}" : null,
$this->rw ? "RW {$this->rw}" : null,
$this->village,
$this->district,
$this->city,
$this->province,
$this->postal_code,
]);
return implode(', ', $parts);
}
public function getBirthInfoAttribute(): string
{
return "{$this->birth_place}, {$this->birth_date->format('d F Y')}";
}
public function getAgeAttribute(): int
{
return $this->birth_date->age;
}
public function getSchoolTypeLabelAttribute(): string
{
return match($this->school_type) {
'sma' => 'SMA',
'smk' => 'SMK',
'ma' => 'MA',
'paket_c' => 'Paket C',
default => '-',
};
}
// ============================================
// ACCESSORS - DOCUMENTS
// ============================================
public function getPhotoUrlAttribute(): ?string
{
return $this->photo ? Storage::url($this->photo) : null;
}
public function getDocumentUrls(): array
{
$documents = [];
$fields = [
'photo' => 'Pas Foto',
'ktp_scan' => 'KTP',
'family_card_scan' => 'Kartu Keluarga',
'birth_certificate_scan' => 'Akta Lahir',
'diploma_scan' => 'Ijazah/SKL',
'transcript_scan' => 'Transkrip Nilai',
'health_certificate_scan' => 'Surat Kesehatan',
'kip_card_scan' => 'Kartu KIP',
];
foreach ($fields as $field => $label) {
if ($this->{$field}) {
$documents[] = [
'field' => $field,
'label' => $label,
'url' => Storage::url($this->{$field}),
'exists' => true,
];
}
}
// Achievement certificates (array)
if (!empty($this->achievement_certificates)) {
foreach ($this->achievement_certificates as $index => $cert) {
$documents[] = [
'field' => "achievement_{$index}",
'label' => "Sertifikat Prestasi " . ($index + 1),
'url' => Storage::url($cert),
'exists' => true,
];
}
}
return $documents;
}
// ============================================
// ACCESSORS - SCORES
// ============================================
public function getSemesterScoresAttribute(): array
{
return [
1 => $this->semester_1_avg,
2 => $this->semester_2_avg,
3 => $this->semester_3_avg,
4 => $this->semester_4_avg,
5 => $this->semester_5_avg,
];
}
public function getFormattedFinalScoreAttribute(): string
{
return $this->final_score ? number_format($this->final_score, 2) : '-';
}
// ============================================
// ACCESSORS - PAYMENT
// ============================================
public function getFormattedPaymentAmountAttribute(): string
{
return 'Rp ' . number_format($this->payment_amount ?? 0, 0, ',', '.');
}
public function getFormattedUktAmountAttribute(): string
{
return 'Rp ' . number_format($this->ukt_amount ?? 0, 0, ',', '.');
}
// ============================================
// HELPER METHODS
// ============================================
public function canEditForm(): bool
{
return in_array($this->status, ['draft', 'verification_failed']);
}
public function canPay(): bool
{
return $this->status === 'submitted' && $this->payment_status === 'unpaid';
}
public function canPrintExamCard(): bool
{
return in_array($this->status, ['verified', 'exam_scheduled', 'exam_completed', 'selection', 'announced', 'accepted', 're_registered'])
&& $this->exam_number !== null;
}
public function canReRegister(): bool
{
return $this->status === 'accepted'
&& $this->result === 'passed'
&& $this->re_registration_status !== 'completed';
}
public function isPassed(): bool
{
return $this->result === 'passed';
}
public function calculateFinalScore(): void
{
$path = $this->admissionPath;
if (!$path || empty($path->exam_weights)) {
return;
}
$weights = $path->exam_weights;
$totalWeight = 0;
$weightedScore = 0;
if (isset($weights['written_exam']) && $this->written_exam_score !== null) {
$weightedScore += ($this->written_exam_score * $weights['written_exam'] / 100);
$totalWeight += $weights['written_exam'];
}
if (isset($weights['interview']) && $this->interview_score !== null) {
$weightedScore += ($this->interview_score * $weights['interview'] / 100);
$totalWeight += $weights['interview'];
}
if (isset($weights['achievement']) && $this->achievement_score !== null) {
$weightedScore += ($this->achievement_score * $weights['achievement'] / 100);
$totalWeight += $weights['achievement'];
}
if (isset($weights['report_card']) && $this->average_score !== null) {
$weightedScore += ($this->average_score * $weights['report_card'] / 100);
$totalWeight += $weights['report_card'];
}
if ($totalWeight > 0) {
$this->final_score = round(($weightedScore / $totalWeight) * 100, 2);
}
}
}
Manual Review (20%):
- ✅ Comprehensive model dengan semua relationships
- ✅ Auto-generate registration number
- ✅ Auto-calculate average score
- ✅ Status labels dalam Bahasa Indonesia
- ✅ Document URLs helper
- ✅ Permission helpers (canPay, canPrintExamCard, etc)
- ✅ Final score calculation dengan bobot
Update User Model
Tambahkan relationship ke Registration di User model:
// app/Models/User.php - tambahkan method ini
public function registration(): HasOne
{
return $this->hasOne(Registration::class);
}
public function hasActiveRegistration(): bool
{
return $this->registration()
->whereNotIn('status', ['cancelled', 'rejected'])
->exists();
}
Jalankan Migration
php artisan migrate
Status Flow Diagram
┌─────────┐
│ DRAFT │ ← User mulai isi form
└────┬────┘
│ Submit form
▼
┌───────────┐
│ SUBMITTED │ ← Menunggu pembayaran
└─────┬─────┘
│ Bayar via Midtrans
▼
┌──────────┐
│ PAID │ ← Menunggu verifikasi admin
└────┬─────┘
│
├──────────────────────┐
│ Dokumen OK │ Dokumen bermasalah
▼ ▼
┌──────────┐ ┌─────────────────────┐
│ VERIFIED │ │ VERIFICATION_FAILED │ → Kembali edit
└────┬─────┘ └─────────────────────┘
│ Admin atur jadwal
▼
┌────────────────┐
│ EXAM_SCHEDULED │ ← Cetak kartu ujian
└───────┬────────┘
│ Selesai ujian
▼
┌────────────────┐
│ EXAM_COMPLETED │ ← Admin input nilai
└───────┬────────┘
│ Proses seleksi
▼
┌───────────┐
│ SELECTION │ ← Hitung ranking
└─────┬─────┘
│ Pengumuman
▼
┌───────────┐
│ ANNOUNCED │
└─────┬─────┘
│
├──────────────────────┐
│ Lulus │ Tidak Lulus
▼ ▼
┌──────────┐ ┌──────────┐
│ ACCEPTED │ │ REJECTED │
└────┬─────┘ └──────────┘
│ Daftar ulang + bayar UKT
▼
┌───────────────┐
│ RE_REGISTERED │ ← Proses selesai
└───────────────┘
💡 RECAP TABEL REGISTRATIONS:
Prompts: 3
Migration: 1 file dengan ~150 fields
Model: 1 file dengan ~500 lines
Field categories:
- Relasi: 6 foreign keys
- Data pribadi: ~15 fields
- Alamat: ~8 fields
- Orang tua: ~16 fields
- Pendidikan: ~15 fields
- Dokumen: ~9 fields
- Ujian: ~10 fields
- Status & payment: ~12 fields
- Daftar ulang: ~5 fields
Model features:
- Auto-generate registration_number
- Auto-calculate average_score
- 14 status dengan labels Indonesia
- Document URL helpers
- Permission checkers
- Final score calculation
Yang di-review manual:
- Status flow logic
- Score calculation formula
- Document handling
Di bagian selanjutnya, kita akan buat Filament Resources untuk master data — Faculty, StudyProgram, AdmissionWave, dan AdmissionPath dengan dashboard widgets untuk statistik pendaftar.
Bagian 5: Filament Admin — Master Data Resources
Sekarang kita buat admin panel untuk mengelola master data. Di bagian ini kita akan buat Resources untuk Faculty, StudyProgram, AdmissionWave, AdmissionPath, dan Dashboard widgets untuk monitoring statistik pendaftar.
Prompt 1: FacultyResource
Buatkan Filament 4 FacultyResource untuk Admin panel dengan:
Form:
- name (required)
- code (required, uppercase, auto-format)
- dean_name (nullable)
- is_active toggle
Table:
- code (badge primary)
- name (searchable)
- dean_name
- study_programs count
- is_active (icon boolean)
- created_at
Actions: edit, delete
Bulk: delete
Navigation:
- Group: Master Data
- Icon: heroicon-o-building-library
- Sort: 1
Response Claude (80%):
<?php
// app/Filament/Admin/Resources/FacultyResource.php
namespace App\\Filament\\Admin\\Resources;
use App\\Filament\\Admin\\Resources\\FacultyResource\\Pages;
use App\\Models\\Faculty;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Support\\Str;
class FacultyResource extends Resource
{
protected static ?string $model = Faculty::class;
protected static ?string $navigationIcon = 'heroicon-o-building-library';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 1;
protected static ?string $navigationLabel = 'Fakultas';
protected static ?string $modelLabel = 'Fakultas';
protected static ?string $pluralModelLabel = 'Fakultas';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Fakultas')
->schema([
Forms\\Components\\TextInput::make('name')
->label('Nama Fakultas')
->required()
->maxLength(255)
->columnSpan(2),
Forms\\Components\\TextInput::make('code')
->label('Kode')
->required()
->maxLength(10)
->unique(ignoreRecord: true)
->dehydrateStateUsing(fn ($state) => Str::upper($state))
->helperText('Contoh: FT, FEB, FIKOM'),
Forms\\Components\\TextInput::make('dean_name')
->label('Nama Dekan')
->maxLength(255)
->placeholder('Dr. Nama Lengkap, M.T.'),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true)
->helperText('Fakultas tidak aktif tidak akan tampil di form pendaftaran'),
])
->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('code')
->label('Kode')
->badge()
->color('primary')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('name')
->label('Nama Fakultas')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('dean_name')
->label('Dekan')
->searchable()
->placeholder('-')
->toggleable(),
Tables\\Columns\\TextColumn::make('study_programs_count')
->label('Jumlah Prodi')
->counts('studyPrograms')
->sortable()
->alignCenter(),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean()
->alignCenter(),
Tables\\Columns\\TextColumn::make('created_at')
->label('Dibuat')
->dateTime('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Status Aktif'),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
])
->defaultSort('code');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListFaculties::route('/'),
'create' => Pages\\CreateFaculty::route('/create'),
'edit' => Pages\\EditFaculty::route('/{record}/edit'),
];
}
}
Generate Pages:
mkdir -p app/Filament/Admin/Resources/FacultyResource/Pages
<?php
// app/Filament/Admin/Resources/FacultyResource/Pages/ListFaculties.php
namespace App\\Filament\\Admin\\Resources\\FacultyResource\\Pages;
use App\\Filament\\Admin\\Resources\\FacultyResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\ListRecords;
class ListFaculties extends ListRecords
{
protected static string $resource = FacultyResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\CreateAction::make(),
];
}
}
<?php
// app/Filament/Admin/Resources/FacultyResource/Pages/CreateFaculty.php
namespace App\\Filament\\Admin\\Resources\\FacultyResource\\Pages;
use App\\Filament\\Admin\\Resources\\FacultyResource;
use Filament\\Resources\\Pages\\CreateRecord;
class CreateFaculty extends CreateRecord
{
protected static string $resource = FacultyResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}
<?php
// app/Filament/Admin/Resources/FacultyResource/Pages/EditFaculty.php
namespace App\\Filament\\Admin\\Resources\\FacultyResource\\Pages;
use App\\Filament\\Admin\\Resources\\FacultyResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\EditRecord;
class EditFaculty extends EditRecord
{
protected static string $resource = FacultyResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\DeleteAction::make(),
];
}
}
Manual Review (20%):
- ✅ Labels dalam Bahasa Indonesia
- ✅ Code auto-uppercase
- ✅ Study programs count
- Tidak perlu edit
Prompt 2: StudyProgramResource
Buatkan StudyProgramResource dengan:
Form:
- faculty_id (select relationship, searchable)
- name, code
- degree (select: D3, D4, S1, S2, S3)
- quota (numeric)
- accreditation (select dengan format baru)
- tuition_fee (currency format Rp)
- description (textarea)
- is_active toggle
Table:
- faculty.name (badge)
- code + name combined
- degree badge
- accreditation badge dengan warna
- quota dengan progress bar (filled/quota)
- formatted tuition
- is_active
Filter: by faculty, by degree, by accreditation
Navigation:
- Group: Master Data
- Icon: heroicon-o-academic-cap
- Sort: 2
Response Claude (80%):
<?php
// app/Filament/Admin/Resources/StudyProgramResource.php
namespace App\\Filament\\Admin\\Resources;
use App\\Filament\\Admin\\Resources\\StudyProgramResource\\Pages;
use App\\Models\\StudyProgram;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Support\\Str;
class StudyProgramResource extends Resource
{
protected static ?string $model = StudyProgram::class;
protected static ?string $navigationIcon = 'heroicon-o-academic-cap';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 2;
protected static ?string $navigationLabel = 'Program Studi';
protected static ?string $modelLabel = 'Program Studi';
protected static ?string $pluralModelLabel = 'Program Studi';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Program Studi')
->schema([
Forms\\Components\\Select::make('faculty_id')
->label('Fakultas')
->relationship('faculty', 'name')
->searchable()
->preload()
->required(),
Forms\\Components\\TextInput::make('name')
->label('Nama Program Studi')
->required()
->maxLength(255),
Forms\\Components\\TextInput::make('code')
->label('Kode')
->required()
->maxLength(10)
->dehydrateStateUsing(fn ($state) => Str::upper($state)),
Forms\\Components\\Select::make('degree')
->label('Jenjang')
->options([
'D3' => 'D3 (Diploma)',
'D4' => 'D4 (Sarjana Terapan)',
'S1' => 'S1 (Sarjana)',
'S2' => 'S2 (Magister)',
'S3' => 'S3 (Doktor)',
])
->required()
->default('S1'),
])
->columns(2),
Forms\\Components\\Section::make('Kuota & Akreditasi')
->schema([
Forms\\Components\\TextInput::make('quota')
->label('Kuota Mahasiswa')
->numeric()
->default(0)
->minValue(0)
->suffix('orang'),
Forms\\Components\\Select::make('accreditation')
->label('Akreditasi')
->options([
'Unggul' => 'Unggul',
'Baik Sekali' => 'Baik Sekali',
'Baik' => 'Baik',
'A' => 'A',
'B' => 'B',
'C' => 'C',
])
->placeholder('Pilih akreditasi'),
Forms\\Components\\TextInput::make('tuition_fee')
->label('UKT per Semester')
->numeric()
->prefix('Rp')
->default(0)
->minValue(0),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true),
])
->columns(2),
Forms\\Components\\Section::make('Deskripsi')
->schema([
Forms\\Components\\Textarea::make('description')
->label('Deskripsi Program Studi')
->rows(4)
->columnSpanFull(),
])
->collapsible(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('faculty.code')
->label('Fakultas')
->badge()
->color('info')
->sortable(),
Tables\\Columns\\TextColumn::make('code')
->label('Kode')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('name')
->label('Nama Program Studi')
->searchable()
->sortable()
->wrap(),
Tables\\Columns\\TextColumn::make('degree')
->label('Jenjang')
->badge()
->color('primary'),
Tables\\Columns\\TextColumn::make('accreditation')
->label('Akreditasi')
->badge()
->color(fn (string $state): string => match ($state) {
'A', 'Unggul' => 'success',
'B', 'Baik Sekali' => 'info',
'C', 'Baik' => 'warning',
default => 'gray',
})
->placeholder('-'),
Tables\\Columns\\TextColumn::make('quota')
->label('Kuota')
->numeric()
->alignCenter(),
Tables\\Columns\\TextColumn::make('formatted_tuition')
->label('UKT')
->sortable(query: fn ($query, $direction) =>
$query->orderBy('tuition_fee', $direction)
),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean()
->alignCenter(),
])
->filters([
Tables\\Filters\\SelectFilter::make('faculty')
->relationship('faculty', 'name')
->label('Fakultas')
->searchable()
->preload(),
Tables\\Filters\\SelectFilter::make('degree')
->label('Jenjang')
->options([
'D3' => 'D3',
'D4' => 'D4',
'S1' => 'S1',
'S2' => 'S2',
'S3' => 'S3',
]),
Tables\\Filters\\SelectFilter::make('accreditation')
->label('Akreditasi')
->options([
'Unggul' => 'Unggul',
'Baik Sekali' => 'Baik Sekali',
'Baik' => 'Baik',
'A' => 'A',
'B' => 'B',
'C' => 'C',
]),
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Status Aktif'),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
])
->defaultSort('faculty.code');
}
public static function getPages(): array
{
return [
'index' => Pages\\ListStudyPrograms::route('/'),
'create' => Pages\\CreateStudyProgram::route('/create'),
'edit' => Pages\\EditStudyProgram::route('/{record}/edit'),
];
}
}
Manual Review (20%):
- ✅ Accreditation colors sesuai peringkat
- ✅ UKT dengan prefix Rp
- ✅ Filters lengkap
- Tidak perlu edit
Prompt 3: AdmissionWaveResource
Buatkan AdmissionWaveResource dengan:
Form:
- name, year
- Tanggal-tanggal: registration_start/end, exam_date,
announcement_date, re_registration_deadline
- status (select dengan visual)
- is_active toggle
Table:
- name + year combined
- registration period
- exam_date
- announcement_date
- status badge dengan warna
- registrations count
- is_active
Tampilkan juga jadwal ujian sebagai relation manager.
Navigation:
- Group: Master Data
- Icon: heroicon-o-calendar-days
- Sort: 3
Response Claude (80%):
<?php
// app/Filament/Admin/Resources/AdmissionWaveResource.php
namespace App\\Filament\\Admin\\Resources;
use App\\Filament\\Admin\\Resources\\AdmissionWaveResource\\Pages;
use App\\Filament\\Admin\\Resources\\AdmissionWaveResource\\RelationManagers;
use App\\Models\\AdmissionWave;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class AdmissionWaveResource extends Resource
{
protected static ?string $model = AdmissionWave::class;
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 3;
protected static ?string $navigationLabel = 'Gelombang Pendaftaran';
protected static ?string $modelLabel = 'Gelombang';
protected static ?string $pluralModelLabel = 'Gelombang Pendaftaran';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Gelombang')
->schema([
Forms\\Components\\TextInput::make('name')
->label('Nama Gelombang')
->required()
->maxLength(255)
->placeholder('Gelombang 1'),
Forms\\Components\\TextInput::make('year')
->label('Tahun Akademik')
->numeric()
->required()
->default(date('Y'))
->minValue(2020)
->maxValue(2100),
Forms\\Components\\Select::make('status')
->label('Status')
->options([
'upcoming' => '🕐 Akan Datang',
'open' => '✅ Dibuka',
'closed' => '🔒 Ditutup',
'completed' => '✔️ Selesai',
])
->required()
->default('upcoming'),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true),
])
->columns(2),
Forms\\Components\\Section::make('Jadwal Penting')
->schema([
Forms\\Components\\DatePicker::make('registration_start_date')
->label('Tanggal Buka Pendaftaran')
->required()
->native(false)
->displayFormat('d F Y'),
Forms\\Components\\DatePicker::make('registration_end_date')
->label('Tanggal Tutup Pendaftaran')
->required()
->native(false)
->displayFormat('d F Y')
->afterOrEqual('registration_start_date'),
Forms\\Components\\DatePicker::make('exam_date')
->label('Tanggal Ujian')
->native(false)
->displayFormat('d F Y')
->afterOrEqual('registration_end_date'),
Forms\\Components\\DatePicker::make('announcement_date')
->label('Tanggal Pengumuman')
->native(false)
->displayFormat('d F Y')
->afterOrEqual('exam_date'),
Forms\\Components\\DatePicker::make('re_registration_deadline')
->label('Batas Daftar Ulang')
->native(false)
->displayFormat('d F Y')
->afterOrEqual('announcement_date'),
])
->columns(2)
->description('Pastikan tanggal berurutan: Pendaftaran → Ujian → Pengumuman → Daftar Ulang'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('full_name')
->label('Gelombang')
->searchable(['name', 'year'])
->sortable(),
Tables\\Columns\\TextColumn::make('registration_period')
->label('Periode Pendaftaran'),
Tables\\Columns\\TextColumn::make('exam_date')
->label('Ujian')
->date('d M Y')
->placeholder('-'),
Tables\\Columns\\TextColumn::make('announcement_date')
->label('Pengumuman')
->date('d M Y')
->placeholder('-'),
Tables\\Columns\\TextColumn::make('status')
->label('Status')
->badge()
->color(fn (string $state): string => match ($state) {
'upcoming' => 'gray',
'open' => 'success',
'closed' => 'warning',
'completed' => 'info',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => match ($state) {
'upcoming' => 'Akan Datang',
'open' => 'Dibuka',
'closed' => 'Ditutup',
'completed' => 'Selesai',
default => $state,
}),
Tables\\Columns\\TextColumn::make('registrations_count')
->label('Pendaftar')
->counts('registrations')
->alignCenter(),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean()
->alignCenter(),
])
->filters([
Tables\\Filters\\SelectFilter::make('year')
->label('Tahun')
->options(fn () => AdmissionWave::distinct()->pluck('year', 'year')->toArray()),
Tables\\Filters\\SelectFilter::make('status')
->label('Status')
->options([
'upcoming' => 'Akan Datang',
'open' => 'Dibuka',
'closed' => 'Ditutup',
'completed' => 'Selesai',
]),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make()
->before(function (AdmissionWave $record) {
if ($record->registrations()->exists()) {
throw new \\Exception('Tidak dapat menghapus gelombang yang sudah memiliki pendaftar.');
}
}),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
])
->defaultSort('year', 'desc');
}
public static function getRelations(): array
{
return [
RelationManagers\\ExamSchedulesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListAdmissionWaves::route('/'),
'create' => Pages\\CreateAdmissionWave::route('/create'),
'edit' => Pages\\EditAdmissionWave::route('/{record}/edit'),
];
}
}
ExamSchedules Relation Manager:
<?php
// app/Filament/Admin/Resources/AdmissionWaveResource/RelationManagers/ExamSchedulesRelationManager.php
namespace App\\Filament\\Admin\\Resources\\AdmissionWaveResource\\RelationManagers;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\RelationManagers\\RelationManager;
use Filament\\Tables;
use Filament\\Tables\\Table;
class ExamSchedulesRelationManager extends RelationManager
{
protected static string $relationship = 'examSchedules';
protected static ?string $title = 'Jadwal Ujian';
protected static ?string $modelLabel = 'Jadwal';
public function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Select::make('exam_type')
->label('Jenis Ujian')
->options([
'written' => 'Ujian Tulis',
'interview' => 'Wawancara',
'practical' => 'Ujian Praktik',
'health_check' => 'Tes Kesehatan',
])
->required(),
Forms\\Components\\DatePicker::make('exam_date')
->label('Tanggal')
->required()
->native(false),
Forms\\Components\\TimePicker::make('start_time')
->label('Jam Mulai')
->required()
->seconds(false),
Forms\\Components\\TimePicker::make('end_time')
->label('Jam Selesai')
->required()
->seconds(false)
->after('start_time'),
Forms\\Components\\Repeater::make('rooms')
->label('Ruangan')
->schema([
Forms\\Components\\TextInput::make('room')
->label('Nama Ruangan')
->required(),
Forms\\Components\\TextInput::make('capacity')
->label('Kapasitas')
->numeric()
->required()
->minValue(1),
])
->columns(2)
->columnSpanFull()
->defaultItems(1),
Forms\\Components\\Textarea::make('notes')
->label('Catatan')
->columnSpanFull(),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('exam_type_label')
->label('Jenis Ujian'),
Tables\\Columns\\TextColumn::make('exam_date')
->label('Tanggal')
->date('d M Y'),
Tables\\Columns\\TextColumn::make('time_range')
->label('Waktu'),
Tables\\Columns\\TextColumn::make('total_capacity')
->label('Kapasitas')
->suffix(' orang'),
])
->headerActions([
Tables\\Actions\\CreateAction::make(),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
]);
}
}
Manual Review (20%):
- ✅ Date validations (afterOrEqual)
- ✅ Status dengan emoji visual
- ✅ Delete protection jika ada pendaftar
- ✅ Repeater untuk rooms
Prompt 4: AdmissionPathResource
Buatkan AdmissionPathResource dengan:
Form:
- name, code
- registration_fee (currency Rp)
- requirements (repeater untuk list syarat)
- exam_weights (key-value repeater untuk bobot)
- description (rich editor)
- is_active
Table:
- code badge
- name
- formatted fee (gratis jika 0)
- requirements count
- is_active
Navigation:
- Group: Master Data
- Icon: heroicon-o-arrow-path
- Sort: 4
Response Claude (80%):
<?php
// app/Filament/Admin/Resources/AdmissionPathResource.php
namespace App\\Filament\\Admin\\Resources;
use App\\Filament\\Admin\\Resources\\AdmissionPathResource\\Pages;
use App\\Models\\AdmissionPath;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Support\\Str;
class AdmissionPathResource extends Resource
{
protected static ?string $model = AdmissionPath::class;
protected static ?string $navigationIcon = 'heroicon-o-arrow-path';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 4;
protected static ?string $navigationLabel = 'Jalur Pendaftaran';
protected static ?string $modelLabel = 'Jalur';
protected static ?string $pluralModelLabel = 'Jalur Pendaftaran';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Jalur')
->schema([
Forms\\Components\\TextInput::make('name')
->label('Nama Jalur')
->required()
->maxLength(255)
->placeholder('Jalur Reguler'),
Forms\\Components\\TextInput::make('code')
->label('Kode')
->required()
->maxLength(20)
->unique(ignoreRecord: true)
->dehydrateStateUsing(fn ($state) => Str::upper($state))
->helperText('Contoh: REG, PRESTASI, KIP'),
Forms\\Components\\TextInput::make('registration_fee')
->label('Biaya Pendaftaran')
->numeric()
->prefix('Rp')
->default(0)
->minValue(0)
->helperText('Isi 0 untuk gratis (misal KIP-Kuliah)'),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true),
])
->columns(2),
Forms\\Components\\Section::make('Persyaratan')
->schema([
Forms\\Components\\Repeater::make('requirements')
->label('Daftar Persyaratan')
->simple(
Forms\\Components\\TextInput::make('requirement')
->required()
->placeholder('Masukkan persyaratan')
)
->defaultItems(3)
->addActionLabel('Tambah Persyaratan')
->columnSpanFull(),
]),
Forms\\Components\\Section::make('Bobot Penilaian')
->schema([
Forms\\Components\\KeyValue::make('exam_weights')
->label('Bobot Nilai')
->keyLabel('Komponen')
->valueLabel('Bobot (%)')
->keyPlaceholder('Contoh: written_exam')
->valuePlaceholder('Contoh: 40')
->columnSpanFull()
->helperText('Total bobot harus 100%. Keys: written_exam, interview, achievement, report_card'),
])
->collapsible(),
Forms\\Components\\Section::make('Deskripsi')
->schema([
Forms\\Components\\RichEditor::make('description')
->label('Deskripsi Jalur')
->columnSpanFull(),
])
->collapsible(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('code')
->label('Kode')
->badge()
->color('primary')
->searchable(),
Tables\\Columns\\TextColumn::make('name')
->label('Nama Jalur')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('formatted_fee')
->label('Biaya')
->badge()
->color(fn (AdmissionPath $record): string =>
$record->registration_fee === 0 ? 'success' : 'warning'
),
Tables\\Columns\\TextColumn::make('requirements')
->label('Persyaratan')
->formatStateUsing(fn ($state) => count($state ?? []) . ' syarat')
->alignCenter(),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean()
->alignCenter(),
])
->filters([
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Status Aktif'),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListAdmissionPaths::route('/'),
'create' => Pages\\CreateAdmissionPath::route('/create'),
'edit' => Pages\\EditAdmissionPath::route('/{record}/edit'),
];
}
}
Manual Review (20%):
- ✅ Simple repeater untuk requirements
- ✅ KeyValue untuk exam_weights
- ✅ Fee gratis dengan badge hijau
- Tidak perlu edit
Prompt 5: Dashboard Widgets
Buatkan dashboard widgets untuk Admin panel:
1. StatsOverview - 4 stats:
- Total Pendaftar (semua gelombang aktif)
- Menunggu Verifikasi
- Sudah Bayar
- Diterima
2. RegistrationChart - Line chart pendaftaran per hari (30 hari terakhir)
3. PathDistributionChart - Pie chart distribusi per jalur
4. LatestRegistrations - Table 5 pendaftar terbaru
Response Claude (80%):
<?php
// app/Filament/Admin/Widgets/StatsOverview.php
namespace App\\Filament\\Admin\\Widgets;
use App\\Models\\Registration;
use App\\Models\\AdmissionWave;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;
class StatsOverview extends BaseWidget
{
protected static ?int $sort = 1;
protected function getStats(): array
{
$activeWaveIds = AdmissionWave::where('is_active', true)->pluck('id');
$total = Registration::whereIn('admission_wave_id', $activeWaveIds)
->whereNotIn('status', ['draft', 'cancelled'])
->count();
$pendingVerification = Registration::whereIn('admission_wave_id', $activeWaveIds)
->where('status', 'paid')
->count();
$paid = Registration::whereIn('admission_wave_id', $activeWaveIds)
->where('payment_status', 'paid')
->count();
$accepted = Registration::whereIn('admission_wave_id', $activeWaveIds)
->whereIn('status', ['accepted', 're_registered'])
->count();
return [
Stat::make('Total Pendaftar', number_format($total))
->description('Gelombang aktif')
->icon('heroicon-o-users')
->color('primary'),
Stat::make('Menunggu Verifikasi', number_format($pendingVerification))
->description('Perlu review dokumen')
->icon('heroicon-o-clock')
->color($pendingVerification > 0 ? 'warning' : 'success'),
Stat::make('Sudah Bayar', number_format($paid))
->description('Pembayaran confirmed')
->icon('heroicon-o-banknotes')
->color('success'),
Stat::make('Diterima', number_format($accepted))
->description('Lulus seleksi')
->icon('heroicon-o-academic-cap')
->color('info'),
];
}
}
<?php
// app/Filament/Admin/Widgets/RegistrationChart.php
namespace App\\Filament\\Admin\\Widgets;
use App\\Models\\Registration;
use Filament\\Widgets\\ChartWidget;
use Illuminate\\Support\\Carbon;
class RegistrationChart extends ChartWidget
{
protected static ?string $heading = 'Pendaftaran 30 Hari Terakhir';
protected static ?int $sort = 2;
protected int | string | array $columnSpan = 'full';
protected function getData(): array
{
$data = [];
$labels = [];
for ($i = 29; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i);
$labels[] = $date->format('d M');
$count = Registration::whereDate('created_at', $date)
->whereNotIn('status', ['draft', 'cancelled'])
->count();
$data[] = $count;
}
return [
'datasets' => [
[
'label' => 'Pendaftar',
'data' => $data,
'fill' => true,
'backgroundColor' => 'rgba(16, 185, 129, 0.2)',
'borderColor' => 'rgb(16, 185, 129)',
],
],
'labels' => $labels,
];
}
protected function getType(): string
{
return 'line';
}
}
<?php
// app/Filament/Admin/Widgets/PathDistributionChart.php
namespace App\\Filament\\Admin\\Widgets;
use App\\Models\\Registration;
use App\\Models\\AdmissionPath;
use App\\Models\\AdmissionWave;
use Filament\\Widgets\\ChartWidget;
class PathDistributionChart extends ChartWidget
{
protected static ?string $heading = 'Distribusi per Jalur';
protected static ?int $sort = 3;
protected function getData(): array
{
$activeWaveIds = AdmissionWave::where('is_active', true)->pluck('id');
$paths = AdmissionPath::withCount(['registrations' => function ($query) use ($activeWaveIds) {
$query->whereIn('admission_wave_id', $activeWaveIds)
->whereNotIn('status', ['draft', 'cancelled']);
}])->get();
$colors = [
'rgba(59, 130, 246, 0.8)', // blue
'rgba(16, 185, 129, 0.8)', // green
'rgba(245, 158, 11, 0.8)', // amber
'rgba(239, 68, 68, 0.8)', // red
'rgba(139, 92, 246, 0.8)', // purple
];
return [
'datasets' => [
[
'data' => $paths->pluck('registrations_count')->toArray(),
'backgroundColor' => array_slice($colors, 0, $paths->count()),
],
],
'labels' => $paths->pluck('name')->toArray(),
];
}
protected function getType(): string
{
return 'pie';
}
}
<?php
// app/Filament/Admin/Widgets/LatestRegistrations.php
namespace App\\Filament\\Admin\\Widgets;
use App\\Models\\Registration;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Widgets\\TableWidget as BaseWidget;
class LatestRegistrations extends BaseWidget
{
protected static ?string $heading = 'Pendaftar Terbaru';
protected static ?int $sort = 4;
protected int | string | array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->query(
Registration::query()
->whereNotIn('status', ['draft', 'cancelled'])
->latest()
->limit(5)
)
->columns([
Tables\\Columns\\TextColumn::make('registration_number')
->label('No. Pendaftaran')
->searchable(),
Tables\\Columns\\TextColumn::make('full_name')
->label('Nama')
->searchable(),
Tables\\Columns\\TextColumn::make('admissionPath.name')
->label('Jalur')
->badge(),
Tables\\Columns\\TextColumn::make('firstChoice.name')
->label('Pilihan 1')
->limit(20),
Tables\\Columns\\TextColumn::make('status')
->label('Status')
->badge()
->color(fn (Registration $record): string => $record->status_color)
->formatStateUsing(fn (Registration $record): string => $record->status_label),
Tables\\Columns\\TextColumn::make('created_at')
->label('Tanggal')
->dateTime('d M Y H:i'),
])
->paginated(false);
}
}
Register widgets di AdminPanelProvider:
// Di AdminPanelProvider, pastikan widgets ter-discover
->discoverWidgets(in: app_path('Filament/Admin/Widgets'), for: 'App\\\\Filament\\\\Admin\\\\Widgets')
Manual Review (20%):
- ✅ Stats dengan conditional colors
- ✅ Line chart 30 hari
- ✅ Pie chart distribusi jalur
- ✅ Latest registrations table
💡 RECAP FILAMENT ADMIN - MASTER DATA:
Prompts: 5
Resources created: 4
- FacultyResource
- StudyProgramResource
- AdmissionWaveResource (+ ExamSchedules RelationManager)
- AdmissionPathResource
Widgets created: 4
- StatsOverview
- RegistrationChart
- PathDistributionChart
- LatestRegistrations
Features:
- Labels Bahasa Indonesia
- Badge colors berdasarkan status/nilai
- Date validations
- Delete protection
- Relation managers
- Dashboard charts
Yang di-review manual:
- Akreditasi color mapping
- Status emojis
- Delete validation
Di bagian selanjutnya, kita akan buat RegistrationResource untuk admin mengelola pendaftar — verifikasi dokumen, input nilai, dan generate pengumuman.
Bagian 6: Filament Admin — Registration Management
Ini adalah bagian paling kompleks dari admin panel — mengelola pendaftar. Admin perlu bisa melihat semua data pendaftar, verifikasi dokumen, input nilai ujian, dan manage status pendaftaran.
Prompt 1: RegistrationResource — Table View
Buatkan RegistrationResource untuk Admin panel dengan:
Table columns:
- registration_number (searchable, copyable)
- photo (circular image)
- full_name (searchable)
- admissionPath.name (badge)
- firstChoice.name (limit 20)
- status (badge dengan warna dari model)
- payment_status (badge)
- created_at
Filters:
- admission_wave (select)
- admission_path (select)
- status (select)
- payment_status (select)
- first_choice program (select)
TIDAK bisa create/edit dari admin (hanya view dan actions).
Navigation:
- Group: Pendaftaran
- Icon: heroicon-o-document-text
- Sort: 1
- Badge: jumlah pending verification
Response Claude (80%):
<?php
// app/Filament/Admin/Resources/RegistrationResource.php
namespace App\\Filament\\Admin\\Resources;
use App\\Filament\\Admin\\Resources\\RegistrationResource\\Pages;
use App\\Models\\Registration;
use App\\Models\\AdmissionWave;
use App\\Models\\AdmissionPath;
use App\\Models\\StudyProgram;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Infolists;
use Filament\\Infolists\\Infolist;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
class RegistrationResource extends Resource
{
protected static ?string $model = Registration::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static ?string $navigationGroup = 'Pendaftaran';
protected static ?int $navigationSort = 1;
protected static ?string $navigationLabel = 'Data Pendaftar';
protected static ?string $modelLabel = 'Pendaftar';
protected static ?string $pluralModelLabel = 'Data Pendaftar';
public static function getNavigationBadge(): ?string
{
$count = static::getModel()::where('status', 'paid')->count();
return $count > 0 ? (string) $count : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
public static function canCreate(): bool
{
return false;
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('registration_number')
->label('No. Registrasi')
->searchable()
->copyable()
->copyMessage('Nomor registrasi disalin!')
->sortable()
->weight('bold'),
Tables\\Columns\\ImageColumn::make('photo')
->label('Foto')
->circular()
->defaultImageUrl(asset('images/default-avatar.png')),
Tables\\Columns\\TextColumn::make('full_name')
->label('Nama Lengkap')
->searchable()
->sortable()
->limit(25),
Tables\\Columns\\TextColumn::make('admissionWave.full_name')
->label('Gelombang')
->badge()
->color('info')
->toggleable(),
Tables\\Columns\\TextColumn::make('admissionPath.code')
->label('Jalur')
->badge()
->color('primary'),
Tables\\Columns\\TextColumn::make('firstChoice.name')
->label('Pilihan 1')
->limit(20)
->tooltip(fn (Registration $record): string => $record->firstChoice?->name ?? '-'),
Tables\\Columns\\TextColumn::make('status')
->label('Status')
->badge()
->color(fn (Registration $record): string => $record->status_color)
->formatStateUsing(fn (Registration $record): string => $record->status_label),
Tables\\Columns\\TextColumn::make('payment_status')
->label('Pembayaran')
->badge()
->color(fn (string $state): string => match ($state) {
'paid' => 'success',
'pending' => 'warning',
'unpaid' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (Registration $record): string => $record->payment_status_label),
Tables\\Columns\\TextColumn::make('created_at')
->label('Tanggal Daftar')
->dateTime('d M Y H:i')
->sortable()
->toggleable(),
])
->filters([
Tables\\Filters\\SelectFilter::make('admission_wave_id')
->label('Gelombang')
->options(fn () => AdmissionWave::where('is_active', true)->pluck('name', 'id'))
->searchable(),
Tables\\Filters\\SelectFilter::make('admission_path_id')
->label('Jalur')
->options(fn () => AdmissionPath::where('is_active', true)->pluck('name', 'id')),
Tables\\Filters\\SelectFilter::make('status')
->label('Status')
->options([
'draft' => 'Draft',
'submitted' => 'Menunggu Pembayaran',
'paid' => 'Menunggu Verifikasi',
'verification_failed' => 'Verifikasi Gagal',
'verified' => 'Terverifikasi',
'exam_scheduled' => 'Ujian Dijadwalkan',
'exam_completed' => 'Ujian Selesai',
'selection' => 'Proses Seleksi',
'announced' => 'Diumumkan',
'accepted' => 'Diterima',
'rejected' => 'Tidak Diterima',
're_registered' => 'Sudah Daftar Ulang',
]),
Tables\\Filters\\SelectFilter::make('payment_status')
->label('Pembayaran')
->options([
'unpaid' => 'Belum Bayar',
'pending' => 'Pending',
'paid' => 'Sudah Bayar',
]),
Tables\\Filters\\SelectFilter::make('first_choice_id')
->label('Pilihan 1')
->options(fn () => StudyProgram::where('is_active', true)->pluck('name', 'id'))
->searchable(),
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\ActionGroup::make([
Tables\\Actions\\Action::make('verify')
->label('Verifikasi')
->icon('heroicon-o-check-circle')
->color('success')
->visible(fn (Registration $record): bool => $record->status === 'paid')
->url(fn (Registration $record): string =>
static::getUrl('verify', ['record' => $record])
),
Tables\\Actions\\Action::make('inputScore')
->label('Input Nilai')
->icon('heroicon-o-pencil-square')
->color('warning')
->visible(fn (Registration $record): bool =>
in_array($record->status, ['exam_completed', 'selection'])
)
->url(fn (Registration $record): string =>
static::getUrl('score', ['record' => $record])
),
]),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\BulkAction::make('bulkVerify')
->label('Verifikasi Terpilih')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->action(function ($records) {
$records->each(function ($record) {
if ($record->status === 'paid') {
$record->update([
'status' => 'verified',
'verified_at' => now(),
'verified_by' => auth()->id(),
]);
}
});
}),
Tables\\Actions\\BulkAction::make('exportExcel')
->label('Export Excel')
->icon('heroicon-o-arrow-down-tray')
->action(function ($records) {
// Implement export logic
return response()->streamDownload(function () use ($records) {
// Excel export implementation
}, 'pendaftar.xlsx');
}),
]),
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListRegistrations::route('/'),
'view' => Pages\\ViewRegistration::route('/{record}'),
'verify' => Pages\\VerifyRegistration::route('/{record}/verify'),
'score' => Pages\\InputScore::route('/{record}/score'),
];
}
}
Manual Review (20%):
- ✅ Navigation badge untuk pending verification
- ✅ canCreate() = false
- ✅ Status/payment colors dari model
- ✅ Action groups untuk clean UI
- ✅ Bulk verify action
Prompt 2: ViewRegistration Page — Infolist
Buatkan ViewRegistration page dengan Infolist yang menampilkan:
Section 1 - Status & Registrasi:
- Status badge, payment status, registration number
- Gelombang, jalur, pilihan prodi 1 & 2
Section 2 - Data Pribadi:
- Foto (besar), NIK, nama, TTL, gender, agama
- Alamat lengkap, telepon
Section 3 - Data Orang Tua:
- Data ayah (nama, pendidikan, pekerjaan, penghasilan)
- Data ibu (sama)
- Data wali jika ada
Section 4 - Data Pendidikan:
- Info sekolah
- Nilai rapor per semester dalam grid
Section 5 - Dokumen:
- Tampilkan semua dokumen yang diupload dengan preview
Section 6 - Ujian & Seleksi:
- Nomor peserta, ruangan
- Nilai-nilai ujian
- Final score, ranking, result
Response Claude (80%):
<?php
// app/Filament/Admin/Resources/RegistrationResource/Pages/ViewRegistration.php
namespace App\\Filament\\Admin\\Resources\\RegistrationResource\\Pages;
use App\\Filament\\Admin\\Resources\\RegistrationResource;
use App\\Models\\Registration;
use Filament\\Actions;
use Filament\\Infolists\\Components;
use Filament\\Infolists\\Infolist;
use Filament\\Resources\\Pages\\ViewRecord;
use Illuminate\\Support\\Facades\\Storage;
class ViewRegistration extends ViewRecord
{
protected static string $resource = RegistrationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\\Action::make('verify')
->label('Verifikasi Dokumen')
->icon('heroicon-o-check-circle')
->color('success')
->visible(fn (): bool => $this->record->status === 'paid')
->url(fn (): string => RegistrationResource::getUrl('verify', ['record' => $this->record])),
Actions\\Action::make('inputScore')
->label('Input Nilai')
->icon('heroicon-o-pencil-square')
->color('warning')
->visible(fn (): bool => in_array($this->record->status, ['exam_completed', 'selection']))
->url(fn (): string => RegistrationResource::getUrl('score', ['record' => $this->record])),
Actions\\Action::make('printExamCard')
->label('Cetak Kartu Ujian')
->icon('heroicon-o-printer')
->color('info')
->visible(fn (): bool => $this->record->canPrintExamCard())
->url(fn (): string => route('admin.exam-card.print', $this->record))
->openUrlInNewTab(),
];
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
// Section 1: Status & Registrasi
Components\\Section::make('Status Pendaftaran')
->schema([
Components\\Grid::make(4)
->schema([
Components\\TextEntry::make('registration_number')
->label('No. Registrasi')
->weight('bold')
->copyable(),
Components\\TextEntry::make('status')
->label('Status')
->badge()
->color(fn (Registration $record): string => $record->status_color)
->formatStateUsing(fn (Registration $record): string => $record->status_label),
Components\\TextEntry::make('payment_status')
->label('Pembayaran')
->badge()
->color(fn (string $state): string => match ($state) {
'paid' => 'success',
'pending' => 'warning',
default => 'danger',
})
->formatStateUsing(fn (Registration $record): string => $record->payment_status_label),
Components\\TextEntry::make('created_at')
->label('Tanggal Daftar')
->dateTime('d F Y H:i'),
]),
Components\\Grid::make(4)
->schema([
Components\\TextEntry::make('admissionWave.full_name')
->label('Gelombang'),
Components\\TextEntry::make('admissionPath.name')
->label('Jalur')
->badge()
->color('primary'),
Components\\TextEntry::make('firstChoice.full_name')
->label('Pilihan 1'),
Components\\TextEntry::make('secondChoice.full_name')
->label('Pilihan 2')
->placeholder('-'),
]),
]),
// Section 2: Data Pribadi
Components\\Section::make('Data Pribadi')
->schema([
Components\\Grid::make(3)
->schema([
Components\\ImageEntry::make('photo')
->label('Foto')
->circular()
->size(150)
->defaultImageUrl(asset('images/default-avatar.png')),
Components\\Group::make([
Components\\TextEntry::make('nik')
->label('NIK'),
Components\\TextEntry::make('full_name')
->label('Nama Lengkap')
->weight('bold'),
Components\\TextEntry::make('nickname')
->label('Nama Panggilan')
->placeholder('-'),
Components\\TextEntry::make('birth_info')
->label('Tempat, Tanggal Lahir'),
]),
Components\\Group::make([
Components\\TextEntry::make('gender_label')
->label('Jenis Kelamin'),
Components\\TextEntry::make('religion_label')
->label('Agama'),
Components\\TextEntry::make('blood_type')
->label('Golongan Darah')
->placeholder('-'),
Components\\TextEntry::make('phone')
->label('No. HP'),
]),
]),
Components\\TextEntry::make('full_address')
->label('Alamat Lengkap')
->columnSpanFull(),
]),
// Section 3: Data Orang Tua
Components\\Section::make('Data Orang Tua')
->schema([
Components\\Grid::make(2)
->schema([
Components\\Fieldset::make('Data Ayah')
->schema([
Components\\TextEntry::make('father_name')
->label('Nama'),
Components\\TextEntry::make('father_nik')
->label('NIK')
->placeholder('-'),
Components\\TextEntry::make('father_education')
->label('Pendidikan')
->formatStateUsing(fn ($state) => strtoupper($state ?? '-')),
Components\\TextEntry::make('father_occupation')
->label('Pekerjaan')
->placeholder('-'),
Components\\TextEntry::make('father_income')
->label('Penghasilan')
->formatStateUsing(fn ($state) => match ($state) {
'below_1m' => '< Rp 1.000.000',
'1m_to_3m' => 'Rp 1.000.000 - 3.000.000',
'3m_to_5m' => 'Rp 3.000.000 - 5.000.000',
'5m_to_10m' => 'Rp 5.000.000 - 10.000.000',
'above_10m' => '> Rp 10.000.000',
default => '-',
}),
Components\\TextEntry::make('father_phone')
->label('No. HP')
->placeholder('-'),
]),
Components\\Fieldset::make('Data Ibu')
->schema([
Components\\TextEntry::make('mother_name')
->label('Nama'),
Components\\TextEntry::make('mother_nik')
->label('NIK')
->placeholder('-'),
Components\\TextEntry::make('mother_education')
->label('Pendidikan')
->formatStateUsing(fn ($state) => strtoupper($state ?? '-')),
Components\\TextEntry::make('mother_occupation')
->label('Pekerjaan')
->placeholder('-'),
Components\\TextEntry::make('mother_income')
->label('Penghasilan')
->formatStateUsing(fn ($state) => match ($state) {
'below_1m' => '< Rp 1.000.000',
'1m_to_3m' => 'Rp 1.000.000 - 3.000.000',
'3m_to_5m' => 'Rp 3.000.000 - 5.000.000',
'5m_to_10m' => 'Rp 5.000.000 - 10.000.000',
'above_10m' => '> Rp 10.000.000',
default => '-',
}),
Components\\TextEntry::make('mother_phone')
->label('No. HP')
->placeholder('-'),
]),
]),
Components\\Fieldset::make('Data Wali')
->schema([
Components\\TextEntry::make('guardian_name')
->label('Nama Wali')
->placeholder('Tidak ada'),
Components\\TextEntry::make('guardian_relation')
->label('Hubungan')
->placeholder('-'),
Components\\TextEntry::make('guardian_phone')
->label('No. HP')
->placeholder('-'),
])
->columns(3)
->hidden(fn (Registration $record): bool => empty($record->guardian_name)),
])
->collapsible(),
// Section 4: Data Pendidikan
Components\\Section::make('Data Pendidikan')
->schema([
Components\\Grid::make(3)
->schema([
Components\\TextEntry::make('school_name')
->label('Nama Sekolah'),
Components\\TextEntry::make('school_type_label')
->label('Jenis'),
Components\\TextEntry::make('graduation_year')
->label('Tahun Lulus'),
]),
Components\\TextEntry::make('school_major')
->label('Jurusan (SMK)')
->placeholder('-')
->hidden(fn (Registration $record): bool => $record->school_type !== 'smk'),
Components\\Grid::make(6)
->schema([
Components\\TextEntry::make('semester_1_avg')
->label('Sem. 1')
->numeric(2),
Components\\TextEntry::make('semester_2_avg')
->label('Sem. 2')
->numeric(2),
Components\\TextEntry::make('semester_3_avg')
->label('Sem. 3')
->numeric(2),
Components\\TextEntry::make('semester_4_avg')
->label('Sem. 4')
->numeric(2),
Components\\TextEntry::make('semester_5_avg')
->label('Sem. 5')
->numeric(2),
Components\\TextEntry::make('average_score')
->label('Rata-rata')
->numeric(2)
->weight('bold')
->color('success'),
]),
])
->collapsible(),
// Section 5: Dokumen
Components\\Section::make('Dokumen')
->schema([
Components\\RepeatableEntry::make('document_list')
->label('')
->state(fn (Registration $record): array => $record->getDocumentUrls())
->schema([
Components\\TextEntry::make('label')
->label('Dokumen'),
Components\\ImageEntry::make('url')
->label('Preview')
->height(100),
])
->columns(2)
->grid(3),
])
->collapsible(),
// Section 6: Ujian & Seleksi
Components\\Section::make('Data Ujian & Seleksi')
->schema([
Components\\Grid::make(4)
->schema([
Components\\TextEntry::make('exam_number')
->label('No. Peserta')
->placeholder('Belum ditentukan')
->weight('bold'),
Components\\TextEntry::make('exam_room')
->label('Ruangan')
->placeholder('-'),
Components\\TextEntry::make('exam_seat')
->label('No. Kursi')
->placeholder('-'),
]),
Components\\Grid::make(5)
->schema([
Components\\TextEntry::make('written_exam_score')
->label('Ujian Tulis')
->numeric(2)
->placeholder('-'),
Components\\TextEntry::make('interview_score')
->label('Wawancara')
->numeric(2)
->placeholder('-'),
Components\\TextEntry::make('achievement_score')
->label('Prestasi')
->numeric(2)
->placeholder('-'),
Components\\TextEntry::make('final_score')
->label('Nilai Akhir')
->numeric(2)
->weight('bold')
->color('primary')
->placeholder('-'),
Components\\TextEntry::make('ranking')
->label('Ranking')
->placeholder('-'),
]),
Components\\Grid::make(3)
->schema([
Components\\TextEntry::make('result')
->label('Hasil Seleksi')
->badge()
->color(fn (Registration $record): string => $record->result_color)
->formatStateUsing(fn (Registration $record): ?string => $record->result_label)
->placeholder('Belum diumumkan'),
Components\\TextEntry::make('acceptedProgram.full_name')
->label('Diterima di')
->placeholder('-'),
Components\\TextEntry::make('announced_at')
->label('Tanggal Pengumuman')
->dateTime('d F Y')
->placeholder('-'),
]),
])
->collapsible()
->hidden(fn (Registration $record): bool =>
in_array($record->status, ['draft', 'submitted', 'paid', 'verification_failed'])
),
// Section 7: Verifikasi Info
Components\\Section::make('Informasi Verifikasi')
->schema([
Components\\TextEntry::make('verified_at')
->label('Tanggal Verifikasi')
->dateTime('d F Y H:i')
->placeholder('Belum diverifikasi'),
Components\\TextEntry::make('verifiedByUser.name')
->label('Diverifikasi Oleh')
->placeholder('-'),
Components\\TextEntry::make('verification_notes')
->label('Catatan Verifikasi')
->placeholder('Tidak ada catatan')
->columnSpanFull(),
])
->columns(2)
->collapsible()
->hidden(fn (Registration $record): bool => $record->verified_at === null),
]);
}
}
Manual Review (20%):
- ✅ Comprehensive infolist dengan semua data
- ✅ Collapsible sections
- ✅ Conditional visibility berdasarkan status
- ✅ Document preview
Prompt 3: VerifyRegistration Page
Buatkan custom page VerifyRegistration untuk verifikasi dokumen:
- Tampilkan semua dokumen pendaftar dengan preview besar
- Checklist untuk setiap dokumen (valid/invalid)
- Textarea untuk catatan verifikasi
- 2 tombol: Approve (status → verified) dan Reject (status → verification_failed)
- Setelah approve, generate nomor peserta ujian
Response Claude (80%):
<?php
// app/Filament/Admin/Resources/RegistrationResource/Pages/VerifyRegistration.php
namespace App\\Filament\\Admin\\Resources\\RegistrationResource\\Pages;
use App\\Filament\\Admin\\Resources\\RegistrationResource;
use App\\Models\\Registration;
use App\\Models\\ExamSchedule;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Forms\\Concerns\\InteractsWithForms;
use Filament\\Forms\\Contracts\\HasForms;
use Filament\\Notifications\\Notification;
use Filament\\Resources\\Pages\\Page;
use Illuminate\\Support\\Str;
class VerifyRegistration extends Page implements HasForms
{
use InteractsWithForms;
protected static string $resource = RegistrationResource::class;
protected static string $view = 'filament.admin.resources.registration-resource.pages.verify-registration';
public Registration $record;
public ?array $data = [];
public function mount(Registration $record): void
{
$this->record = $record;
if ($record->status !== 'paid') {
Notification::make()
->title('Pendaftar ini tidak dalam status menunggu verifikasi')
->danger()
->send();
$this->redirect(RegistrationResource::getUrl('view', ['record' => $record]));
return;
}
$this->form->fill([
'verification_notes' => $record->verification_notes,
]);
}
public function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Checklist Dokumen')
->schema([
Forms\\Components\\CheckboxList::make('document_checklist')
->label('')
->options($this->getDocumentOptions())
->descriptions($this->getDocumentDescriptions())
->columns(2)
->bulkToggleable(),
]),
Forms\\Components\\Section::make('Catatan Verifikasi')
->schema([
Forms\\Components\\Textarea::make('verification_notes')
->label('Catatan (opsional)')
->rows(3)
->placeholder('Tambahkan catatan jika ada dokumen yang kurang atau perlu diperbaiki...')
->columnSpanFull(),
]),
])
->statePath('data');
}
protected function getDocumentOptions(): array
{
$options = [];
$documents = [
'photo' => 'Pas Foto',
'ktp_scan' => 'KTP',
'family_card_scan' => 'Kartu Keluarga',
'birth_certificate_scan' => 'Akta Lahir',
'diploma_scan' => 'Ijazah/SKL',
'transcript_scan' => 'Transkrip Nilai',
'health_certificate_scan' => 'Surat Kesehatan',
];
foreach ($documents as $field => $label) {
if ($this->record->{$field}) {
$options[$field] = $label;
}
}
// Achievement certificates
if (!empty($this->record->achievement_certificates)) {
foreach ($this->record->achievement_certificates as $index => $cert) {
$options["achievement_{$index}"] = "Sertifikat Prestasi " . ($index + 1);
}
}
// KIP card for KIP path
if ($this->record->kip_card_scan) {
$options['kip_card_scan'] = 'Kartu KIP';
}
return $options;
}
protected function getDocumentDescriptions(): array
{
$descriptions = [];
// Could add preview URLs as descriptions
foreach ($this->getDocumentOptions() as $field => $label) {
$descriptions[$field] = 'Klik untuk preview';
}
return $descriptions;
}
public function approve(): void
{
$data = $this->form->getState();
// Generate exam number
$examNumber = $this->generateExamNumber();
// Assign exam room and seat
$examAssignment = $this->assignExamRoom();
$this->record->update([
'status' => 'verified',
'verified_at' => now(),
'verified_by' => auth()->id(),
'verification_notes' => $data['verification_notes'],
'exam_number' => $examNumber,
'exam_room' => $examAssignment['room'],
'exam_seat' => $examAssignment['seat'],
]);
Notification::make()
->title('Verifikasi Berhasil')
->body("Nomor peserta: {$examNumber}")
->success()
->send();
$this->redirect(RegistrationResource::getUrl('view', ['record' => $this->record]));
}
public function reject(): void
{
$data = $this->form->getState();
if (empty($data['verification_notes'])) {
Notification::make()
->title('Catatan wajib diisi')
->body('Jelaskan alasan penolakan dokumen.')
->danger()
->send();
return;
}
$this->record->update([
'status' => 'verification_failed',
'verification_notes' => $data['verification_notes'],
'verified_at' => now(),
'verified_by' => auth()->id(),
]);
Notification::make()
->title('Dokumen Ditolak')
->body('Pendaftar akan diberitahu untuk memperbaiki dokumen.')
->warning()
->send();
$this->redirect(RegistrationResource::getUrl('view', ['record' => $this->record]));
}
protected function generateExamNumber(): string
{
$wave = $this->record->admissionWave;
$path = $this->record->admissionPath;
$year = $wave->year;
$waveCode = Str::padLeft($wave->id, 2, '0');
$pathCode = $path->code;
// Get next sequence for this wave + path combination
$lastNumber = Registration::where('admission_wave_id', $wave->id)
->where('admission_path_id', $path->id)
->whereNotNull('exam_number')
->count();
$sequence = Str::padLeft($lastNumber + 1, 5, '0');
return "{$year}-{$waveCode}-{$pathCode}-{$sequence}";
}
protected function assignExamRoom(): array
{
$wave = $this->record->admissionWave;
// Get written exam schedule
$examSchedule = ExamSchedule::where('admission_wave_id', $wave->id)
->where('exam_type', 'written')
->first();
if (!$examSchedule || empty($examSchedule->rooms)) {
return ['room' => null, 'seat' => null];
}
// Count existing assignments per room
$rooms = $examSchedule->rooms;
foreach ($rooms as $roomData) {
$roomName = $roomData['room'];
$capacity = $roomData['capacity'];
$assigned = Registration::where('admission_wave_id', $wave->id)
->where('exam_room', $roomName)
->count();
if ($assigned < $capacity) {
return [
'room' => $roomName,
'seat' => $assigned + 1,
];
}
}
// All rooms full, assign to first room (overflow)
return [
'room' => $rooms[0]['room'] ?? 'TBA',
'seat' => null,
];
}
protected function getHeaderActions(): array
{
return [];
}
}
Blade View:
{{-- resources/views/filament/admin/resources/registration-resource/pages/verify-registration.blade.php --}}
<x-filament-panels::page>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Left: Document Preview --}}
<div class="space-y-4">
<h3 class="text-lg font-semibold">Preview Dokumen</h3>
@php $documents = $this->record->getDocumentUrls(); @endphp
@forelse($documents as $doc)
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<h4 class="font-medium mb-2">{{ $doc['label'] }}</h4>
<a href="{{ $doc['url'] }}" target="_blank" class="block">
<img src="{{ $doc['url'] }}" alt="{{ $doc['label'] }}"
class="max-h-64 object-contain rounded border">
</a>
</div>
@empty
<p class="text-gray-500">Tidak ada dokumen yang diupload.</p>
@endforelse
</div>
{{-- Right: Verification Form --}}
<div>
<h3 class="text-lg font-semibold mb-4">Data Pendaftar</h3>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-6">
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500">Nama</dt>
<dd class="font-medium">{{ $this->record->full_name }}</dd>
</div>
<div>
<dt class="text-gray-500">NIK</dt>
<dd class="font-medium">{{ $this->record->nik }}</dd>
</div>
<div>
<dt class="text-gray-500">Jalur</dt>
<dd class="font-medium">{{ $this->record->admissionPath->name }}</dd>
</div>
<div>
<dt class="text-gray-500">Pilihan 1</dt>
<dd class="font-medium">{{ $this->record->firstChoice->name }}</dd>
</div>
</dl>
</div>
<form wire:submit.prevent>
{{ $this->form }}
<div class="flex gap-4 mt-6">
<x-filament::button color="success" wire:click="approve" class="flex-1">
<x-heroicon-o-check-circle class="w-5 h-5 mr-2" />
Approve & Generate Nomor Peserta
</x-filament::button>
<x-filament::button color="danger" wire:click="reject" class="flex-1">
<x-heroicon-o-x-circle class="w-5 h-5 mr-2" />
Reject
</x-filament::button>
</div>
</form>
</div>
</div>
</x-filament-panels::page>
Manual Review (20%):
- ✅ Document preview
- ✅ Checklist validation
- ✅ Auto-generate exam number
- ✅ Auto-assign room & seat
- ⚠️ Perlu handle edge case jika semua ruangan penuh
Prompt 4: InputScore Page
Buatkan custom page InputScore untuk input nilai ujian:
- Tampilkan info pendaftar (nama, jalur, prodi)
- Form input: written_exam_score, interview_score, achievement_score
- Auto-calculate final_score berdasarkan bobot jalur
- Tampilkan preview final_score
- Tombol simpan
Response Claude (80%):
<?php
// app/Filament/Admin/Resources/RegistrationResource/Pages/InputScore.php
namespace App\\Filament\\Admin\\Resources\\RegistrationResource\\Pages;
use App\\Filament\\Admin\\Resources\\RegistrationResource;
use App\\Models\\Registration;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Forms\\Concerns\\InteractsWithForms;
use Filament\\Forms\\Contracts\\HasForms;
use Filament\\Notifications\\Notification;
use Filament\\Resources\\Pages\\Page;
class InputScore extends Page implements HasForms
{
use InteractsWithForms;
protected static string $resource = RegistrationResource::class;
protected static string $view = 'filament.admin.resources.registration-resource.pages.input-score';
public Registration $record;
public ?array $data = [];
public ?float $previewFinalScore = null;
public function mount(Registration $record): void
{
$this->record = $record;
if (!in_array($record->status, ['verified', 'exam_scheduled', 'exam_completed', 'selection'])) {
Notification::make()
->title('Tidak dapat input nilai untuk pendaftar ini')
->danger()
->send();
$this->redirect(RegistrationResource::getUrl('view', ['record' => $record]));
return;
}
$this->form->fill([
'written_exam_score' => $record->written_exam_score,
'interview_score' => $record->interview_score,
'achievement_score' => $record->achievement_score,
]);
$this->calculatePreview();
}
public function form(Form $form): Form
{
$weights = $this->record->admissionPath->exam_weights ?? [];
return $form
->schema([
Forms\\Components\\Section::make('Input Nilai Ujian')
->description($this->getWeightsDescription())
->schema([
Forms\\Components\\TextInput::make('written_exam_score')
->label('Nilai Ujian Tulis')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('/ 100')
->live(onBlur: true)
->afterStateUpdated(fn () => $this->calculatePreview())
->visible(fn () => isset($weights['written_exam'])),
Forms\\Components\\TextInput::make('interview_score')
->label('Nilai Wawancara')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('/ 100')
->live(onBlur: true)
->afterStateUpdated(fn () => $this->calculatePreview())
->visible(fn () => isset($weights['interview'])),
Forms\\Components\\TextInput::make('achievement_score')
->label('Nilai Prestasi')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('/ 100')
->live(onBlur: true)
->afterStateUpdated(fn () => $this->calculatePreview())
->visible(fn () => isset($weights['achievement'])),
])
->columns(3),
])
->statePath('data');
}
protected function getWeightsDescription(): string
{
$weights = $this->record->admissionPath->exam_weights ?? [];
$parts = [];
if (isset($weights['written_exam'])) {
$parts[] = "Ujian Tulis: {$weights['written_exam']}%";
}
if (isset($weights['interview'])) {
$parts[] = "Wawancara: {$weights['interview']}%";
}
if (isset($weights['achievement'])) {
$parts[] = "Prestasi: {$weights['achievement']}%";
}
if (isset($weights['report_card'])) {
$parts[] = "Nilai Rapor: {$weights['report_card']}% (otomatis dari data pendaftaran)";
}
return 'Bobot penilaian: ' . implode(', ', $parts);
}
public function calculatePreview(): void
{
$data = $this->form->getState();
$weights = $this->record->admissionPath->exam_weights ?? [];
$totalWeight = 0;
$weightedScore = 0;
if (isset($weights['written_exam']) && !empty($data['written_exam_score'])) {
$weightedScore += ($data['written_exam_score'] * $weights['written_exam'] / 100);
$totalWeight += $weights['written_exam'];
}
if (isset($weights['interview']) && !empty($data['interview_score'])) {
$weightedScore += ($data['interview_score'] * $weights['interview'] / 100);
$totalWeight += $weights['interview'];
}
if (isset($weights['achievement']) && !empty($data['achievement_score'])) {
$weightedScore += ($data['achievement_score'] * $weights['achievement'] / 100);
$totalWeight += $weights['achievement'];
}
if (isset($weights['report_card']) && $this->record->average_score) {
$weightedScore += ($this->record->average_score * $weights['report_card'] / 100);
$totalWeight += $weights['report_card'];
}
if ($totalWeight > 0) {
$this->previewFinalScore = round(($weightedScore / $totalWeight) * 100, 2);
} else {
$this->previewFinalScore = null;
}
}
public function save(): void
{
$data = $this->form->getState();
$this->record->update([
'written_exam_score' => $data['written_exam_score'] ?? null,
'interview_score' => $data['interview_score'] ?? null,
'achievement_score' => $data['achievement_score'] ?? null,
'status' => 'exam_completed',
]);
// Recalculate final score using model method
$this->record->calculateFinalScore();
$this->record->save();
Notification::make()
->title('Nilai Berhasil Disimpan')
->body("Nilai akhir: {$this->record->final_score}")
->success()
->send();
$this->redirect(RegistrationResource::getUrl('view', ['record' => $this->record]));
}
}
Blade View:
{{-- resources/views/filament/admin/resources/registration-resource/pages/input-score.blade.php --}}
<x-filament-panels::page>
{{-- Applicant Info --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<span class="text-sm text-gray-500">Nama</span>
<p class="font-semibold">{{ $this->record->full_name }}</p>
</div>
<div>
<span class="text-sm text-gray-500">No. Peserta</span>
<p class="font-semibold">{{ $this->record->exam_number ?? '-' }}</p>
</div>
<div>
<span class="text-sm text-gray-500">Jalur</span>
<p class="font-semibold">{{ $this->record->admissionPath->name }}</p>
</div>
<div>
<span class="text-sm text-gray-500">Pilihan 1</span>
<p class="font-semibold">{{ $this->record->firstChoice->name }}</p>
</div>
</div>
@if($this->record->average_score)
<div class="mt-4 pt-4 border-t">
<span class="text-sm text-gray-500">Nilai Rata-rata Rapor</span>
<p class="font-semibold text-lg text-primary-600">{{ number_format($this->record->average_score, 2) }}</p>
</div>
@endif
</div>
{{-- Score Form --}}
<form wire:submit.prevent="save">
{{ $this->form }}
{{-- Preview Final Score --}}
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 mt-6">
<h3 class="text-lg font-semibold mb-2">Preview Nilai Akhir</h3>
@if($this->previewFinalScore !== null)
<p class="text-4xl font-bold text-primary-600">
{{ number_format($this->previewFinalScore, 2) }}
</p>
<p class="text-sm text-gray-500 mt-1">
Nilai ini dihitung berdasarkan bobot jalur {{ $this->record->admissionPath->name }}
</p>
@else
<p class="text-gray-500">Masukkan nilai untuk melihat preview</p>
@endif
</div>
<div class="flex justify-end mt-6">
<x-filament::button type="submit" size="lg">
<x-heroicon-o-check class="w-5 h-5 mr-2" />
Simpan Nilai
</x-filament::button>
</div>
</form>
</x-filament-panels::page>
Manual Review (20%):
- ✅ Dynamic form berdasarkan exam_weights
- ✅ Live preview final score
- ✅ Include nilai rapor dalam kalkulasi
- ✅ Status update ke exam_completed
Prompt 5: Bulk Actions — Generate Ranking & Announcement
Buatkan bulk action di RegistrationResource untuk:
1. Generate ranking per program studi
2. Set result (passed/failed/waitlist) berdasarkan kuota
3. Bulk update status ke 'announced'
Ranking dihitung dari final_score tertinggi ke terendah.
Passed = ranking <= kuota program studi pilihan 1
Jika tidak lolos pilihan 1, cek pilihan 2
Waitlist = cadangan (misalnya 10% dari kuota)
Response Claude (80%):
// Tambahkan di RegistrationResource bulkActions
Tables\\Actions\\BulkAction::make('generateAnnouncement')
->label('Generate Pengumuman')
->icon('heroicon-o-megaphone')
->color('success')
->requiresConfirmation()
->modalHeading('Generate Pengumuman Kelulusan')
->modalDescription('Proses ini akan menghitung ranking dan menentukan hasil seleksi berdasarkan kuota program studi. Pastikan semua nilai sudah diinput.')
->form([
Forms\\Components\\Select::make('wave_id')
->label('Gelombang')
->options(fn () => AdmissionWave::where('is_active', true)->pluck('name', 'id'))
->required(),
])
->action(function (array $data) {
$waveId = $data['wave_id'];
// Get all completed registrations for this wave
$registrations = Registration::where('admission_wave_id', $waveId)
->whereIn('status', ['exam_completed', 'selection'])
->whereNotNull('final_score')
->orderByDesc('final_score')
->get();
// Group by first choice program
$byFirstChoice = $registrations->groupBy('first_choice_id');
foreach ($byFirstChoice as $programId => $regs) {
$program = StudyProgram::find($programId);
$quota = $program->quota;
$waitlistQuota = ceil($quota * 0.1); // 10% cadangan
$rank = 1;
foreach ($regs->sortByDesc('final_score') as $reg) {
$reg->ranking = $rank;
if ($rank <= $quota) {
$reg->result = 'passed';
$reg->accepted_program_id = $programId;
} elseif ($rank <= $quota + $waitlistQuota) {
$reg->result = 'waitlist';
} else {
// Check second choice
$secondResult = self::checkSecondChoice($reg, $waveId);
$reg->result = $secondResult['result'];
$reg->accepted_program_id = $secondResult['program_id'];
}
$reg->status = 'announced';
$reg->announced_at = now();
$reg->save();
$rank++;
}
}
Notification::make()
->title('Pengumuman Berhasil Di-generate')
->body('Total ' . $registrations->count() . ' pendaftar telah diproses.')
->success()
->send();
}),
Helper method (tambahkan di RegistrationResource atau Service class):
protected static function checkSecondChoice(Registration $registration, int $waveId): array
{
if (!$registration->second_choice_id) {
return ['result' => 'failed', 'program_id' => null];
}
$program = StudyProgram::find($registration->second_choice_id);
$quota = $program->quota;
// Count already accepted in second choice
$acceptedCount = Registration::where('admission_wave_id', $waveId)
->where('accepted_program_id', $registration->second_choice_id)
->where('result', 'passed')
->count();
if ($acceptedCount < $quota) {
return ['result' => 'passed', 'program_id' => $registration->second_choice_id];
}
// Check waitlist
$waitlistQuota = ceil($quota * 0.1);
$waitlistCount = Registration::where('admission_wave_id', $waveId)
->where('accepted_program_id', $registration->second_choice_id)
->where('result', 'waitlist')
->count();
if ($waitlistCount < $waitlistQuota) {
return ['result' => 'waitlist', 'program_id' => $registration->second_choice_id];
}
return ['result' => 'failed', 'program_id' => null];
}
Manual Review (20%):
- ✅ Ranking per program
- ✅ Quota-based selection
- ✅ Second choice fallback
- ✅ 10% waitlist
- ⚠️ Logic ini simplified — di production perlu pertimbangkan tie-breaker, passing grade, dll
💡 RECAP REGISTRATION MANAGEMENT:
Prompts: 5
Pages created:
- ListRegistrations (table with filters & bulk actions)
- ViewRegistration (comprehensive infolist)
- VerifyRegistration (document verification workflow)
- InputScore (score input with live preview)
Features:
- Navigation badge for pending verification
- Document preview with checklist
- Auto-generate exam number
- Auto-assign exam room & seat
- Live final score calculation
- Bulk ranking generation
- Quota-based selection logic
- Second choice fallback
Yang di-review manual:
- Exam number format
- Room assignment algorithm
- Score calculation formula
- Selection/ranking logic
- Tie-breaker rules
Di bagian selanjutnya, kita akan buat frontend untuk pendaftar — multi-step form wizard dengan 5 tahap untuk mengisi formulir pendaftaran lengkap.
Bagian 7: Frontend Pendaftar — Multi-Step Form Wizard
Sekarang kita buat form pendaftaran untuk calon mahasiswa. Form ini akan punya 5 tahap (wizard) yang harus diisi secara berurutan. Kita akan pakai Filament Wizard component di Applicant Panel.
Struktur 5 Tahap Pendaftaran
STEP 1: Pilih Gelombang, Jalur & Program Studi
↓
STEP 2: Data Pribadi (NIK, nama, TTL, alamat)
↓
STEP 3: Data Orang Tua/Wali
↓
STEP 4: Data Pendidikan & Nilai Rapor
↓
STEP 5: Upload Dokumen
↓
SUBMIT → Lanjut ke Pembayaran
Prompt 1: RegistrationForm Page — Setup
Buatkan Filament Page untuk Applicant Panel bernama
RegistrationForm dengan Wizard. Page ini untuk mengisi
formulir pendaftaran mahasiswa baru.
Setup dasar:
- Check apakah user sudah punya registration
- Jika sudah ada, load data existing (edit mode)
- Jika belum, create new (create mode)
- Auto-save draft setiap pindah step
Response Claude (80%):
<?php
// app/Filament/Applicant/Pages/RegistrationForm.php
namespace App\\Filament\\Applicant\\Pages;
use App\\Models\\Registration;
use App\\Models\\AdmissionWave;
use App\\Models\\AdmissionPath;
use App\\Models\\StudyProgram;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Forms\\Concerns\\InteractsWithForms;
use Filament\\Forms\\Contracts\\HasForms;
use Filament\\Notifications\\Notification;
use Filament\\Pages\\Page;
use Illuminate\\Support\\Facades\\Auth;
class RegistrationForm extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static ?string $navigationLabel = 'Formulir Pendaftaran';
protected static ?string $title = 'Formulir Pendaftaran';
protected static ?int $navigationSort = 1;
protected static string $view = 'filament.applicant.pages.registration-form';
public ?Registration $registration = null;
public ?array $data = [];
public function mount(): void
{
$user = Auth::user();
// Check existing registration
$this->registration = Registration::where('user_id', $user->id)
->whereNotIn('status', ['cancelled'])
->first();
if ($this->registration) {
// Check if already submitted
if (!$this->registration->canEditForm()) {
$this->redirect(route('filament.applicant.pages.dashboard'));
return;
}
// Load existing data
$this->form->fill($this->registration->toArray());
} else {
// Initialize with user data
$this->form->fill([
'user_id' => $user->id,
]);
}
}
public function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Wizard::make([
$this->getStep1(),
$this->getStep2(),
$this->getStep3(),
$this->getStep4(),
$this->getStep5(),
])
->submitAction(view('filament.applicant.components.submit-button'))
->persistStepInQueryString()
->afterStateUpdated(fn () => $this->saveDraft()),
])
->statePath('data');
}
protected function saveDraft(): void
{
if ($this->registration) {
$this->registration->update($this->form->getState());
} else {
$data = $this->form->getState();
$data['user_id'] = Auth::id();
$data['status'] = 'draft';
$this->registration = Registration::create($data);
}
}
public function submit(): void
{
$data = $this->form->getState();
// Final validation
$this->validate();
if ($this->registration) {
$this->registration->update(array_merge($data, [
'status' => 'submitted',
'submitted_at' => now(),
]));
} else {
$data['user_id'] = Auth::id();
$data['status'] = 'submitted';
$data['submitted_at'] = now();
$this->registration = Registration::create($data);
}
Notification::make()
->title('Formulir Berhasil Dikirim!')
->body('Silakan lanjutkan ke pembayaran.')
->success()
->send();
$this->redirect(route('filament.applicant.pages.payment'));
}
// Step definitions akan di-define di bawah...
}
Manual Review (20%):
- ✅ Check existing registration
- ✅ canEditForm() check
- ✅ Auto-save draft
- ✅ Persist step in query string
Prompt 2: Step 1 — Pilihan Program
Buatkan Step 1 wizard untuk pilih:
- Gelombang (hanya yang status 'open')
- Jalur pendaftaran (tampilkan biaya)
- Program studi pilihan 1
- Program studi pilihan 2 (exclude pilihan 1)
Dengan validasi dan dependent selects.
Response Claude (80%):
// Tambahkan method di RegistrationForm.php
protected function getStep1(): Forms\\Components\\Wizard\\Step
{
return Forms\\Components\\Wizard\\Step::make('Pilihan Program')
->icon('heroicon-o-academic-cap')
->description('Pilih gelombang, jalur, dan program studi')
->schema([
Forms\\Components\\Section::make()
->schema([
Forms\\Components\\Select::make('admission_wave_id')
->label('Gelombang Pendaftaran')
->options(function () {
return AdmissionWave::where('is_active', true)
->where('status', 'open')
->get()
->mapWithKeys(fn ($wave) => [
$wave->id => "{$wave->name} - {$wave->year} ({$wave->registration_period})"
]);
})
->required()
->searchable()
->live()
->helperText('Pilih gelombang pendaftaran yang sedang dibuka'),
Forms\\Components\\Select::make('admission_path_id')
->label('Jalur Pendaftaran')
->options(function () {
return AdmissionPath::where('is_active', true)
->get()
->mapWithKeys(fn ($path) => [
$path->id => "{$path->name} - {$path->formatted_fee}"
]);
})
->required()
->searchable()
->live()
->afterStateUpdated(function ($state, Forms\\Set $set) {
$path = AdmissionPath::find($state);
if ($path) {
$set('payment_amount', $path->registration_fee);
}
})
->helperText(function ($state) {
if (!$state) return null;
$path = AdmissionPath::find($state);
if (!$path) return null;
$requirements = $path->requirements ?? [];
if (empty($requirements)) return null;
return 'Syarat: ' . implode(', ', array_slice($requirements, 0, 2)) . '...';
}),
Forms\\Components\\Hidden::make('payment_amount'),
])
->columns(2),
Forms\\Components\\Section::make('Pilihan Program Studi')
->schema([
Forms\\Components\\Select::make('first_choice_id')
->label('Pilihan 1 (Prioritas Utama)')
->options(function () {
return StudyProgram::where('is_active', true)
->with('faculty')
->get()
->mapWithKeys(fn ($program) => [
$program->id => "[{$program->faculty->code}] {$program->degree} {$program->name} - {$program->accreditation}"
]);
})
->required()
->searchable()
->live()
->helperText('Program studi utama yang Anda inginkan'),
Forms\\Components\\Select::make('second_choice_id')
->label('Pilihan 2 (Alternatif)')
->options(function (Forms\\Get $get) {
$firstChoice = $get('first_choice_id');
return StudyProgram::where('is_active', true)
->when($firstChoice, fn ($q) => $q->where('id', '!=', $firstChoice))
->with('faculty')
->get()
->mapWithKeys(fn ($program) => [
$program->id => "[{$program->faculty->code}] {$program->degree} {$program->name}"
]);
})
->searchable()
->helperText('Opsional. Pilihan cadangan jika tidak diterima di pilihan 1'),
])
->columns(2),
Forms\\Components\\Placeholder::make('info')
->content(function (Forms\\Get $get) {
$firstId = $get('first_choice_id');
if (!$firstId) return '';
$program = StudyProgram::with('faculty')->find($firstId);
if (!$program) return '';
return new \\Illuminate\\Support\\HtmlString("
<div class='p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg'>
<h4 class='font-semibold text-blue-800 dark:text-blue-200'>Info Program Studi Pilihan 1</h4>
<p class='text-sm mt-2'>
<strong>Fakultas:</strong> {$program->faculty->name}<br>
<strong>Akreditasi:</strong> {$program->accreditation}<br>
<strong>Kuota:</strong> {$program->quota} mahasiswa<br>
<strong>UKT:</strong> {$program->formatted_tuition}/semester
</p>
</div>
");
})
->columnSpanFull(),
])
->afterValidation(fn () => $this->saveDraft());
}
Manual Review (20%):
- ✅ Only open waves
- ✅ Fee display di jalur
- ✅ Exclude first choice dari second choice
- ✅ Info box untuk program detail
Prompt 3: Step 2 — Data Pribadi
Buatkan Step 2 untuk data pribadi:
- NIK dengan validasi 16 digit dan format Indonesia
- Nama lengkap, nama panggilan
- Tempat & tanggal lahir
- Jenis kelamin (radio)
- Agama, golongan darah
- No HP
- Alamat lengkap dengan fields terpisah
Response Claude (80%):
protected function getStep2(): Forms\\Components\\Wizard\\Step
{
return Forms\\Components\\Wizard\\Step::make('Data Pribadi')
->icon('heroicon-o-user')
->description('Isi data diri Anda dengan lengkap')
->schema([
Forms\\Components\\Section::make('Identitas')
->schema([
Forms\\Components\\TextInput::make('nik')
->label('NIK (Nomor Induk Kependudukan)')
->required()
->length(16)
->numeric()
->unique(Registration::class, 'nik', ignoreRecord: true)
->validationMessages([
'length' => 'NIK harus 16 digit',
'unique' => 'NIK sudah terdaftar',
])
->helperText('Sesuai KTP/KK, 16 digit angka')
->placeholder('3201234567890001'),
Forms\\Components\\TextInput::make('full_name')
->label('Nama Lengkap')
->required()
->maxLength(255)
->placeholder('Sesuai ijazah/akta lahir'),
Forms\\Components\\TextInput::make('nickname')
->label('Nama Panggilan')
->maxLength(50)
->placeholder('Nama yang biasa dipanggil'),
])
->columns(3),
Forms\\Components\\Section::make('Kelahiran & Identitas Lainnya')
->schema([
Forms\\Components\\TextInput::make('birth_place')
->label('Tempat Lahir')
->required()
->maxLength(100)
->placeholder('Kota/Kabupaten'),
Forms\\Components\\DatePicker::make('birth_date')
->label('Tanggal Lahir')
->required()
->native(false)
->displayFormat('d F Y')
->maxDate(now()->subYears(15))
->minDate(now()->subYears(50)),
Forms\\Components\\Radio::make('gender')
->label('Jenis Kelamin')
->options([
'male' => 'Laki-laki',
'female' => 'Perempuan',
])
->required()
->inline(),
Forms\\Components\\Select::make('religion')
->label('Agama')
->options([
'islam' => 'Islam',
'kristen' => 'Kristen',
'katolik' => 'Katolik',
'hindu' => 'Hindu',
'buddha' => 'Buddha',
'konghucu' => 'Konghucu',
])
->required()
->searchable(),
Forms\\Components\\Select::make('blood_type')
->label('Golongan Darah')
->options([
'A' => 'A',
'B' => 'B',
'AB' => 'AB',
'O' => 'O',
])
->placeholder('Pilih jika diketahui'),
Forms\\Components\\TextInput::make('phone')
->label('Nomor HP (WhatsApp)')
->required()
->tel()
->prefix('+62')
->placeholder('81234567890')
->helperText('Nomor aktif untuk konfirmasi'),
])
->columns(3),
Forms\\Components\\Section::make('Alamat Tempat Tinggal')
->schema([
Forms\\Components\\Textarea::make('address')
->label('Alamat (Jalan, Nomor Rumah)')
->required()
->rows(2)
->placeholder('Jl. Merdeka No. 123')
->columnSpanFull(),
Forms\\Components\\Grid::make(4)
->schema([
Forms\\Components\\TextInput::make('rt')
->label('RT')
->maxLength(5)
->placeholder('001'),
Forms\\Components\\TextInput::make('rw')
->label('RW')
->maxLength(5)
->placeholder('002'),
Forms\\Components\\TextInput::make('postal_code')
->label('Kode Pos')
->maxLength(10)
->placeholder('12345'),
]),
Forms\\Components\\Grid::make(2)
->schema([
Forms\\Components\\TextInput::make('village')
->label('Kelurahan/Desa')
->required()
->maxLength(100),
Forms\\Components\\TextInput::make('district')
->label('Kecamatan')
->required()
->maxLength(100),
Forms\\Components\\TextInput::make('city')
->label('Kabupaten/Kota')
->required()
->maxLength(100),
Forms\\Components\\TextInput::make('province')
->label('Provinsi')
->required()
->maxLength(100),
]),
]),
])
->afterValidation(fn () => $this->saveDraft());
}
Manual Review (20%):
- ✅ NIK 16 digit validation
- ✅ Unique NIK check
- ✅ Age validation (15-50 tahun)
- ✅ Alamat fields lengkap
Prompt 4: Step 3 — Data Orang Tua
Buatkan Step 3 untuk data orang tua:
- Data Ayah: nama, NIK, pendidikan, pekerjaan, penghasilan, no HP
- Data Ibu: sama seperti ayah
- Data Wali (opsional dengan toggle)
- Penghasilan dalam range (dropdown)
Response Claude (80%):
protected function getStep3(): Forms\\Components\\Wizard\\Step
{
return Forms\\Components\\Wizard\\Step::make('Data Orang Tua')
->icon('heroicon-o-users')
->description('Isi data orang tua/wali Anda')
->schema([
Forms\\Components\\Section::make('Data Ayah')
->schema([
Forms\\Components\\TextInput::make('father_name')
->label('Nama Lengkap Ayah')
->required()
->maxLength(255),
Forms\\Components\\TextInput::make('father_nik')
->label('NIK Ayah')
->length(16)
->numeric()
->placeholder('Opsional'),
Forms\\Components\\Select::make('father_education')
->label('Pendidikan Terakhir')
->options([
'sd' => 'SD/Sederajat',
'smp' => 'SMP/Sederajat',
'sma' => 'SMA/Sederajat',
'd1' => 'D1',
'd2' => 'D2',
'd3' => 'D3',
'd4' => 'D4',
's1' => 'S1',
's2' => 'S2',
's3' => 'S3',
]),
Forms\\Components\\TextInput::make('father_occupation')
->label('Pekerjaan')
->maxLength(100)
->placeholder('Contoh: PNS, Wiraswasta, Petani'),
Forms\\Components\\Select::make('father_income')
->label('Penghasilan per Bulan')
->options([
'below_1m' => 'Kurang dari Rp 1.000.000',
'1m_to_3m' => 'Rp 1.000.000 - Rp 3.000.000',
'3m_to_5m' => 'Rp 3.000.000 - Rp 5.000.000',
'5m_to_10m' => 'Rp 5.000.000 - Rp 10.000.000',
'above_10m' => 'Lebih dari Rp 10.000.000',
]),
Forms\\Components\\TextInput::make('father_phone')
->label('Nomor HP')
->tel()
->placeholder('081234567890'),
])
->columns(3),
Forms\\Components\\Section::make('Data Ibu')
->schema([
Forms\\Components\\TextInput::make('mother_name')
->label('Nama Lengkap Ibu')
->required()
->maxLength(255),
Forms\\Components\\TextInput::make('mother_nik')
->label('NIK Ibu')
->length(16)
->numeric()
->placeholder('Opsional'),
Forms\\Components\\Select::make('mother_education')
->label('Pendidikan Terakhir')
->options([
'sd' => 'SD/Sederajat',
'smp' => 'SMP/Sederajat',
'sma' => 'SMA/Sederajat',
'd1' => 'D1',
'd2' => 'D2',
'd3' => 'D3',
'd4' => 'D4',
's1' => 'S1',
's2' => 'S2',
's3' => 'S3',
]),
Forms\\Components\\TextInput::make('mother_occupation')
->label('Pekerjaan')
->maxLength(100)
->placeholder('Contoh: Ibu Rumah Tangga, Guru'),
Forms\\Components\\Select::make('mother_income')
->label('Penghasilan per Bulan')
->options([
'below_1m' => 'Kurang dari Rp 1.000.000',
'1m_to_3m' => 'Rp 1.000.000 - Rp 3.000.000',
'3m_to_5m' => 'Rp 3.000.000 - Rp 5.000.000',
'5m_to_10m' => 'Rp 5.000.000 - Rp 10.000.000',
'above_10m' => 'Lebih dari Rp 10.000.000',
]),
Forms\\Components\\TextInput::make('mother_phone')
->label('Nomor HP')
->tel()
->placeholder('081234567890'),
])
->columns(3),
Forms\\Components\\Section::make('Data Wali')
->description('Isi jika berbeda dengan orang tua kandung')
->schema([
Forms\\Components\\Toggle::make('has_guardian')
->label('Saya memiliki wali selain orang tua kandung')
->live()
->dehydrated(false),
Forms\\Components\\Grid::make(3)
->schema([
Forms\\Components\\TextInput::make('guardian_name')
->label('Nama Wali')
->maxLength(255),
Forms\\Components\\TextInput::make('guardian_phone')
->label('Nomor HP Wali')
->tel(),
Forms\\Components\\TextInput::make('guardian_relation')
->label('Hubungan dengan Pendaftar')
->placeholder('Contoh: Paman, Kakak, Kakek')
->maxLength(50),
])
->visible(fn (Forms\\Get $get) => $get('has_guardian')),
])
->collapsible()
->collapsed(),
])
->afterValidation(fn () => $this->saveDraft());
}
Manual Review (20%):
- ✅ Penghasilan dalam range
- ✅ Pendidikan dropdown
- ✅ Wali dengan toggle
- ✅ Collapsible wali section
Prompt 5: Step 4 — Data Pendidikan
Buatkan Step 4 untuk data pendidikan:
- Nama sekolah, NPSN
- Jenis sekolah (SMA/SMK/MA/Paket C)
- Jurusan (jika SMK)
- Alamat sekolah
- Tahun lulus
- Nilai rata-rata rapor semester 1-5
Response Claude (80%):
protected function getStep4(): Forms\\Components\\Wizard\\Step
{
return Forms\\Components\\Wizard\\Step::make('Data Pendidikan')
->icon('heroicon-o-building-library')
->description('Isi data sekolah dan nilai rapor')
->schema([
Forms\\Components\\Section::make('Informasi Sekolah')
->schema([
Forms\\Components\\TextInput::make('school_name')
->label('Nama Sekolah')
->required()
->maxLength(255)
->placeholder('SMA Negeri 1 Jakarta'),
Forms\\Components\\TextInput::make('school_npsn')
->label('NPSN Sekolah')
->maxLength(20)
->placeholder('8 digit, contoh: 20100001')
->helperText('Nomor Pokok Sekolah Nasional'),
Forms\\Components\\Select::make('school_type')
->label('Jenis Sekolah')
->options([
'sma' => 'SMA (Sekolah Menengah Atas)',
'smk' => 'SMK (Sekolah Menengah Kejuruan)',
'ma' => 'MA (Madrasah Aliyah)',
'paket_c' => 'Paket C',
])
->required()
->live(),
Forms\\Components\\TextInput::make('school_major')
->label('Jurusan/Kompetensi Keahlian')
->maxLength(100)
->placeholder('Contoh: Teknik Komputer Jaringan')
->visible(fn (Forms\\Get $get) => $get('school_type') === 'smk'),
Forms\\Components\\TextInput::make('graduation_year')
->label('Tahun Lulus')
->required()
->numeric()
->minValue(2015)
->maxValue((int) date('Y') + 1)
->default((int) date('Y'))
->placeholder(date('Y')),
])
->columns(3),
Forms\\Components\\Section::make('Alamat Sekolah')
->schema([
Forms\\Components\\Textarea::make('school_address')
->label('Alamat Sekolah')
->rows(2)
->placeholder('Jalan, nomor, kelurahan')
->columnSpanFull(),
Forms\\Components\\Grid::make(2)
->schema([
Forms\\Components\\TextInput::make('school_city')
->label('Kota/Kabupaten')
->maxLength(100),
Forms\\Components\\TextInput::make('school_province')
->label('Provinsi')
->maxLength(100),
]),
])
->collapsible(),
Forms\\Components\\Section::make('Nilai Rapor')
->description('Masukkan rata-rata nilai rapor per semester (skala 0-100)')
->schema([
Forms\\Components\\Grid::make(5)
->schema([
Forms\\Components\\TextInput::make('semester_1_avg')
->label('Semester 1')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('/100')
->required()
->live(onBlur: true),
Forms\\Components\\TextInput::make('semester_2_avg')
->label('Semester 2')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('/100')
->required()
->live(onBlur: true),
Forms\\Components\\TextInput::make('semester_3_avg')
->label('Semester 3')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('/100')
->required()
->live(onBlur: true),
Forms\\Components\\TextInput::make('semester_4_avg')
->label('Semester 4')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('/100')
->required()
->live(onBlur: true),
Forms\\Components\\TextInput::make('semester_5_avg')
->label('Semester 5')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('/100')
->required()
->live(onBlur: true),
]),
Forms\\Components\\Placeholder::make('average_preview')
->label('Rata-rata Keseluruhan')
->content(function (Forms\\Get $get) {
$scores = array_filter([
$get('semester_1_avg'),
$get('semester_2_avg'),
$get('semester_3_avg'),
$get('semester_4_avg'),
$get('semester_5_avg'),
], fn ($v) => $v !== null && $v !== '');
if (count($scores) === 0) {
return 'Masukkan nilai untuk melihat rata-rata';
}
$avg = round(array_sum($scores) / count($scores), 2);
$color = $avg >= 80 ? 'text-green-600' : ($avg >= 70 ? 'text-blue-600' : 'text-orange-600');
return new \\Illuminate\\Support\\HtmlString(
"<span class='text-2xl font-bold {$color}'>{$avg}</span> / 100"
);
}),
]),
])
->afterValidation(fn () => $this->saveDraft());
}
Manual Review (20%):
- ✅ Jurusan hanya muncul untuk SMK
- ✅ Year validation
- ✅ Live preview rata-rata nilai
Prompt 6: Step 5 — Upload Dokumen
Buatkan Step 5 untuk upload dokumen:
- Pas foto (3x4, max 500KB)
- Scan KTP
- Scan Kartu Keluarga
- Scan Akta Lahir
- Scan Ijazah/SKL
- Scan Transkrip Nilai
- Surat Keterangan Sehat
- Sertifikat prestasi (multiple, untuk jalur prestasi)
- Kartu KIP (untuk jalur KIP)
Dengan validasi file type dan size.
Response Claude (80%):
protected function getStep5(): Forms\\Components\\Wizard\\Step
{
return Forms\\Components\\Wizard\\Step::make('Upload Dokumen')
->icon('heroicon-o-document-arrow-up')
->description('Upload dokumen persyaratan')
->schema([
Forms\\Components\\Section::make('Dokumen Wajib')
->description('Format: JPG, PNG, atau PDF. Maksimal 2MB per file.')
->schema([
Forms\\Components\\FileUpload::make('photo')
->label('Pas Foto (3x4)')
->image()
->imageEditor()
->imageCropAspectRatio('3:4')
->maxSize(512) // 500KB
->directory('registrations/photos')
->required()
->helperText('Foto formal dengan latar belakang merah/biru. Max 500KB.'),
Forms\\Components\\FileUpload::make('ktp_scan')
->label('Scan KTP')
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf'])
->maxSize(2048)
->directory('registrations/ktp')
->required()
->helperText('KTP pendaftar yang masih berlaku'),
Forms\\Components\\FileUpload::make('family_card_scan')
->label('Scan Kartu Keluarga')
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf'])
->maxSize(2048)
->directory('registrations/kk')
->required(),
Forms\\Components\\FileUpload::make('birth_certificate_scan')
->label('Scan Akta Kelahiran')
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf'])
->maxSize(2048)
->directory('registrations/akta')
->required(),
Forms\\Components\\FileUpload::make('diploma_scan')
->label('Scan Ijazah / Surat Keterangan Lulus')
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf'])
->maxSize(2048)
->directory('registrations/ijazah')
->required()
->helperText('Ijazah SMA/SMK/MA atau SKL jika belum ada ijazah'),
Forms\\Components\\FileUpload::make('transcript_scan')
->label('Scan Transkrip Nilai / Rapor')
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf'])
->maxSize(2048)
->directory('registrations/transkrip')
->required()
->helperText('Transkrip nilai atau rapor semester 1-5'),
])
->columns(2),
Forms\\Components\\Section::make('Dokumen Tambahan')
->schema([
Forms\\Components\\FileUpload::make('health_certificate_scan')
->label('Surat Keterangan Sehat')
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf'])
->maxSize(2048)
->directory('registrations/kesehatan')
->helperText('Dari dokter/puskesmas/rumah sakit'),
Forms\\Components\\FileUpload::make('achievement_certificates')
->label('Sertifikat Prestasi')
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf'])
->maxSize(2048)
->directory('registrations/prestasi')
->multiple()
->maxFiles(5)
->reorderable()
->helperText('Opsional. Sertifikat lomba, olimpiade, dll. Max 5 file.')
->visible(fn (Forms\\Get $get) =>
$this->isPrestasiPath($get('admission_path_id'))
),
Forms\\Components\\FileUpload::make('kip_card_scan')
->label('Kartu KIP / Surat Keterangan Tidak Mampu')
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf'])
->maxSize(2048)
->directory('registrations/kip')
->required(fn (Forms\\Get $get) =>
$this->isKipPath($get('admission_path_id'))
)
->visible(fn (Forms\\Get $get) =>
$this->isKipPath($get('admission_path_id'))
)
->helperText('Kartu KIP atau SKTM dari kelurahan'),
])
->columns(2),
Forms\\Components\\Section::make('Konfirmasi')
->schema([
Forms\\Components\\Checkbox::make('agreement')
->label('Saya menyatakan bahwa seluruh data yang saya isikan adalah benar dan dapat dipertanggungjawabkan. Saya bersedia menerima sanksi pembatalan jika ditemukan pemalsuan data.')
->required()
->accepted()
->validationMessages([
'accepted' => 'Anda harus menyetujui pernyataan ini',
]),
]),
]);
}
// Helper methods
protected function isPrestasiPath(?int $pathId): bool
{
if (!$pathId) return false;
$path = AdmissionPath::find($pathId);
return $path && str_contains(strtolower($path->code), 'prestasi');
}
protected function isKipPath(?int $pathId): bool
{
if (!$pathId) return false;
$path = AdmissionPath::find($pathId);
return $path && str_contains(strtolower($path->code), 'kip');
}
Manual Review (20%):
- ✅ Image editor untuk foto
- ✅ Conditional fields (prestasi, KIP)
- ✅ Multiple file upload untuk sertifikat
- ✅ Agreement checkbox
Blade View untuk Registration Form
{{-- resources/views/filament/applicant/pages/registration-form.blade.php --}}
<x-filament-panels::page>
@if($this->registration && $this->registration->status !== 'draft')
<div class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div class="flex items-center gap-3">
<x-heroicon-o-exclamation-triangle class="w-6 h-6 text-yellow-600" />
<div>
<h3 class="font-semibold text-yellow-800 dark:text-yellow-200">
Formulir sudah dikirim
</h3>
<p class="text-sm text-yellow-700 dark:text-yellow-300">
Status: {{ $this->registration->status_label }}.
Anda tidak dapat mengedit formulir yang sudah dikirim.
</p>
</div>
</div>
</div>
@endif
<form wire:submit="submit">
{{ $this->form }}
</form>
</x-filament-panels::page>
Submit Button Component
{{-- resources/views/filament/applicant/components/submit-button.blade.php --}}
<div class="flex items-center justify-between pt-6 border-t">
<div class="text-sm text-gray-500">
<x-heroicon-o-information-circle class="w-4 h-4 inline mr-1" />
Data tersimpan otomatis setiap pindah tahap
</div>
<x-filament::button type="submit" size="lg" color="success">
<x-heroicon-o-paper-airplane class="w-5 h-5 mr-2" />
Kirim Formulir & Lanjut Pembayaran
</x-filament::button>
</div>
Applicant Dashboard
<?php
// app/Filament/Applicant/Pages/Dashboard.php
namespace App\\Filament\\Applicant\\Pages;
use App\\Models\\Registration;
use Filament\\Pages\\Page;
use Illuminate\\Support\\Facades\\Auth;
class Dashboard extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-home';
protected static ?string $navigationLabel = 'Dashboard';
protected static ?string $title = 'Dashboard Pendaftaran';
protected static ?int $navigationSort = 0;
protected static string $view = 'filament.applicant.pages.dashboard';
public ?Registration $registration = null;
public function mount(): void
{
$this->registration = Registration::where('user_id', Auth::id())
->whereNotIn('status', ['cancelled'])
->first();
}
public function getStatusSteps(): array
{
return [
[
'key' => 'form',
'label' => 'Isi Formulir',
'completed' => $this->registration && !in_array($this->registration->status, ['draft']),
],
[
'key' => 'payment',
'label' => 'Pembayaran',
'completed' => $this->registration && $this->registration->payment_status === 'paid',
],
[
'key' => 'verification',
'label' => 'Verifikasi',
'completed' => $this->registration && in_array($this->registration->status, ['verified', 'exam_scheduled', 'exam_completed', 'selection', 'announced', 'accepted', 're_registered']),
],
[
'key' => 'exam',
'label' => 'Ujian',
'completed' => $this->registration && in_array($this->registration->status, ['exam_completed', 'selection', 'announced', 'accepted', 're_registered']),
],
[
'key' => 'result',
'label' => 'Pengumuman',
'completed' => $this->registration && in_array($this->registration->status, ['announced', 'accepted', 'rejected', 're_registered']),
],
];
}
}
Dashboard Blade View
{{-- resources/views/filament/applicant/pages/dashboard.blade.php --}}
<x-filament-panels::page>
{{-- Progress Steps --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
<h2 class="text-lg font-semibold mb-4">Progress Pendaftaran</h2>
<div class="flex items-center justify-between">
@foreach($this->getStatusSteps() as $index => $step)
<div class="flex items-center {{ $index < count($this->getStatusSteps()) - 1 ? 'flex-1' : '' }}">
<div class="flex flex-col items-center">
<div class="w-10 h-10 rounded-full flex items-center justify-center {{ $step['completed'] ? 'bg-green-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500' }}">
@if($step['completed'])
<x-heroicon-o-check class="w-5 h-5" />
@else
{{ $index + 1 }}
@endif
</div>
<span class="text-xs mt-2 text-center">{{ $step['label'] }}</span>
</div>
@if($index < count($this->getStatusSteps()) - 1)
<div class="flex-1 h-1 mx-2 {{ $step['completed'] ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700' }}"></div>
@endif
</div>
@endforeach
</div>
</div>
{{-- Status Card --}}
@if($this->registration)
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="text-lg font-semibold mb-4">Status Pendaftaran</h3>
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-gray-500">No. Registrasi</dt>
<dd class="font-medium">{{ $this->registration->registration_number ?? '-' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Status</dt>
<dd>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ $this->registration->status_color }}-100 text-{{ $this->registration->status_color }}-800">
{{ $this->registration->status_label }}
</span>
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Pembayaran</dt>
<dd>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $this->registration->payment_status === 'paid' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800' }}">
{{ $this->registration->payment_status_label }}
</span>
</dd>
</div>
</dl>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="text-lg font-semibold mb-4">Pilihan Program</h3>
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-gray-500">Gelombang</dt>
<dd class="font-medium">{{ $this->registration->admissionWave?->full_name ?? '-' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Jalur</dt>
<dd class="font-medium">{{ $this->registration->admissionPath?->name ?? '-' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Pilihan 1</dt>
<dd class="font-medium">{{ $this->registration->firstChoice?->name ?? '-' }}</dd>
</div>
</dl>
</div>
</div>
{{-- Action Cards --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{{-- Continue Form --}}
@if($this->registration->canEditForm())
<a href="{{ route('filament.applicant.pages.registration-form') }}"
class="block bg-blue-50 dark:bg-blue-900/20 rounded-lg p-6 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition">
<x-heroicon-o-pencil-square class="w-8 h-8 text-blue-600 mb-3" />
<h4 class="font-semibold text-blue-800 dark:text-blue-200">Lanjutkan Formulir</h4>
<p class="text-sm text-blue-600 dark:text-blue-300 mt-1">Lengkapi data pendaftaran Anda</p>
</a>
@endif
{{-- Payment --}}
@if($this->registration->canPay())
<a href="{{ route('filament.applicant.pages.payment') }}"
class="block bg-green-50 dark:bg-green-900/20 rounded-lg p-6 hover:bg-green-100 dark:hover:bg-green-900/40 transition">
<x-heroicon-o-banknotes class="w-8 h-8 text-green-600 mb-3" />
<h4 class="font-semibold text-green-800 dark:text-green-200">Bayar Pendaftaran</h4>
<p class="text-sm text-green-600 dark:text-green-300 mt-1">{{ $this->registration->formatted_payment_amount }}</p>
</a>
@endif
{{-- Print Exam Card --}}
@if($this->registration->canPrintExamCard())
<a href="{{ route('applicant.exam-card.print', $this->registration) }}"
target="_blank"
class="block bg-purple-50 dark:bg-purple-900/20 rounded-lg p-6 hover:bg-purple-100 dark:hover:bg-purple-900/40 transition">
<x-heroicon-o-printer class="w-8 h-8 text-purple-600 mb-3" />
<h4 class="font-semibold text-purple-800 dark:text-purple-200">Cetak Kartu Ujian</h4>
<p class="text-sm text-purple-600 dark:text-purple-300 mt-1">No. Peserta: {{ $this->registration->exam_number }}</p>
</a>
@endif
{{-- Re-registration --}}
@if($this->registration->canReRegister())
<a href="{{ route('filament.applicant.pages.re-registration') }}"
class="block bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-6 hover:bg-yellow-100 dark:hover:bg-yellow-900/40 transition">
<x-heroicon-o-academic-cap class="w-8 h-8 text-yellow-600 mb-3" />
<h4 class="font-semibold text-yellow-800 dark:text-yellow-200">Daftar Ulang</h4>
<p class="text-sm text-yellow-600 dark:text-yellow-300 mt-1">Selamat! Anda diterima</p>
</a>
@endif
</div>
@else
{{-- No Registration Yet --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-8 text-center">
<x-heroicon-o-document-plus class="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 class="text-xl font-semibold mb-2">Belum Ada Pendaftaran</h3>
<p class="text-gray-500 mb-6">Mulai pendaftaran Anda sekarang untuk menjadi mahasiswa baru.</p>
<a href="{{ route('filament.applicant.pages.registration-form') }}"
class="inline-flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition">
<x-heroicon-o-plus class="w-5 h-5 mr-2" />
Mulai Pendaftaran
</a>
</div>
@endif
</x-filament-panels::page>
💡 RECAP MULTI-STEP FORM WIZARD:
Prompts: 6
Steps created: 5
- Step 1: Pilihan Program (wave, path, prodi)
- Step 2: Data Pribadi (NIK, nama, alamat)
- Step 3: Data Orang Tua (ayah, ibu, wali)
- Step 4: Data Pendidikan (sekolah, nilai rapor)
- Step 5: Upload Dokumen (foto, KTP, dll)
Pages created:
- RegistrationForm (wizard)
- Dashboard (progress & actions)
Features:
- Auto-save draft setiap step
- Persist step di query string
- NIK 16 digit validation
- Live preview rata-rata nilai
- Conditional fields (SMK jurusan, KIP card)
- Multiple file upload (sertifikat)
- Image editor untuk foto
- Progress steps visual
- Action cards berdasarkan status
Yang di-review manual:
- NIK validation format
- File upload paths
- Agreement checkbox
- Status flow checks
Di bagian selanjutnya, kita akan buat integrasi pembayaran dengan Midtrans dan generate kartu ujian PDF.
Bagian 8: Payment Integration & Kartu Ujian PDF
Di bagian ini kita akan integrasikan pembayaran biaya pendaftaran dengan Midtrans dan buat generator kartu ujian PDF menggunakan DomPDF.
Prompt 1: Install Midtrans Package
Berikan langkah-langkah untuk:
1. Install Midtrans PHP SDK
2. Konfigurasi .env untuk Midtrans sandbox
3. Buat config file untuk Midtrans
Response Claude (80%):
# Install Midtrans SDK
composer require midtrans/midtrans-php
# .env - Midtrans Sandbox Credentials
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true
<?php
// config/midtrans.php
return [
'server_key' => env('MIDTRANS_SERVER_KEY'),
'client_key' => env('MIDTRANS_CLIENT_KEY'),
'is_production' => env('MIDTRANS_IS_PRODUCTION', false),
'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),
'is_3ds' => env('MIDTRANS_IS_3DS', true),
];
Manual Review (20%):
- ✅ Sandbox keys untuk development
- ⚠️ Ganti ke production keys saat deploy
Prompt 2: MidtransService
Buatkan MidtransService untuk handle:
1. Create Snap Token untuk pembayaran pendaftaran
2. Handle notification/webhook dari Midtrans
3. Verify signature dari callback
4. Update payment status di Registration
Response Claude (80%):
<?php
// app/Services/MidtransService.php
namespace App\\Services;
use App\\Models\\Registration;
use Midtrans\\Config;
use Midtrans\\Snap;
use Midtrans\\Notification;
use Illuminate\\Support\\Facades\\Log;
class MidtransService
{
public function __construct()
{
Config::$serverKey = config('midtrans.server_key');
Config::$clientKey = config('midtrans.client_key');
Config::$isProduction = config('midtrans.is_production');
Config::$isSanitized = config('midtrans.is_sanitized');
Config::$is3ds = config('midtrans.is_3ds');
}
public function createSnapToken(Registration $registration): string
{
$orderId = 'PMB-' . $registration->id . '-' . time();
$params = [
'transaction_details' => [
'order_id' => $orderId,
'gross_amount' => $registration->payment_amount,
],
'customer_details' => [
'first_name' => $registration->full_name,
'email' => $registration->user->email,
'phone' => $registration->phone,
],
'item_details' => [
[
'id' => 'REG-' . $registration->admission_path_id,
'price' => $registration->payment_amount,
'quantity' => 1,
'name' => 'Biaya Pendaftaran ' . $registration->admissionPath->name,
],
],
'callbacks' => [
'finish' => route('applicant.payment.finish'),
],
];
// Save order_id untuk tracking
$registration->update([
'midtrans_transaction_id' => $orderId,
'payment_status' => 'pending',
]);
return Snap::getSnapToken($params);
}
public function handleNotification(): array
{
$notification = new Notification();
$transactionStatus = $notification->transaction_status;
$orderId = $notification->order_id;
$fraudStatus = $notification->fraud_status ?? null;
$paymentType = $notification->payment_type;
Log::info('Midtrans Notification', [
'order_id' => $orderId,
'status' => $transactionStatus,
'fraud' => $fraudStatus,
'type' => $paymentType,
]);
// Find registration by order_id
$registration = Registration::where('midtrans_transaction_id', $orderId)->first();
if (!$registration) {
Log::error('Registration not found for order: ' . $orderId);
return ['status' => 'error', 'message' => 'Registration not found'];
}
// Determine payment status
$paymentStatus = $this->determinePaymentStatus($transactionStatus, $fraudStatus);
// Update registration
$updateData = [
'payment_status' => $paymentStatus,
'payment_type' => $paymentType,
];
if ($paymentStatus === 'paid') {
$updateData['paid_at'] = now();
$updateData['status'] = 'paid'; // Move to verification queue
}
$registration->update($updateData);
return [
'status' => 'success',
'payment_status' => $paymentStatus,
'registration_id' => $registration->id,
];
}
protected function determinePaymentStatus(string $transactionStatus, ?string $fraudStatus): string
{
if ($transactionStatus === 'capture') {
return $fraudStatus === 'accept' ? 'paid' : 'pending';
}
return match ($transactionStatus) {
'settlement' => 'paid',
'pending' => 'pending',
'deny', 'expire', 'cancel' => 'unpaid',
default => 'pending',
};
}
public function verifySignature(array $payload): bool
{
$orderId = $payload['order_id'];
$statusCode = $payload['status_code'];
$grossAmount = $payload['gross_amount'];
$serverKey = config('midtrans.server_key');
$signature = hash('sha512', $orderId . $statusCode . $grossAmount . $serverKey);
return $signature === $payload['signature_key'];
}
}
Manual Review (20%):
- ✅ Snap token generation
- ✅ Webhook handling
- ✅ Signature verification
- ✅ Status mapping
Prompt 3: Payment Page untuk Applicant
Buatkan Payment Page di Applicant Panel dengan:
1. Tampilkan summary pendaftaran
2. Tampilkan biaya yang harus dibayar
3. Tombol bayar yang trigger Midtrans Snap
4. Handle callback finish
Response Claude (80%):
<?php
// app/Filament/Applicant/Pages/Payment.php
namespace App\\Filament\\Applicant\\Pages;
use App\\Models\\Registration;
use App\\Services\\MidtransService;
use Filament\\Notifications\\Notification;
use Filament\\Pages\\Page;
use Illuminate\\Support\\Facades\\Auth;
class Payment extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-banknotes';
protected static ?string $navigationLabel = 'Pembayaran';
protected static ?string $title = 'Pembayaran Biaya Pendaftaran';
protected static ?int $navigationSort = 2;
protected static string $view = 'filament.applicant.pages.payment';
public ?Registration $registration = null;
public ?string $snapToken = null;
public string $clientKey;
public function mount(): void
{
$this->registration = Registration::where('user_id', Auth::id())
->whereIn('status', ['submitted', 'paid'])
->first();
if (!$this->registration) {
Notification::make()
->title('Tidak ada pendaftaran yang perlu dibayar')
->warning()
->send();
$this->redirect(route('filament.applicant.pages.dashboard'));
return;
}
// Already paid
if ($this->registration->payment_status === 'paid') {
$this->redirect(route('filament.applicant.pages.dashboard'));
return;
}
$this->clientKey = config('midtrans.client_key');
}
public function getSnapToken(): void
{
if ($this->registration->payment_status === 'paid') {
Notification::make()
->title('Pembayaran sudah berhasil')
->success()
->send();
return;
}
try {
$midtransService = new MidtransService();
$this->snapToken = $midtransService->createSnapToken($this->registration);
} catch (\\Exception $e) {
Notification::make()
->title('Gagal membuat pembayaran')
->body($e->getMessage())
->danger()
->send();
}
}
public static function shouldRegisterNavigation(): bool
{
$registration = Registration::where('user_id', Auth::id())
->where('status', 'submitted')
->where('payment_status', 'unpaid')
->first();
return $registration !== null;
}
}
Blade View untuk Payment:
{{-- resources/views/filament/applicant/pages/payment.blade.php --}}
<x-filament-panels::page>
@if($this->registration)
<div class="max-w-2xl mx-auto">
{{-- Summary Card --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden mb-6">
<div class="bg-gradient-to-r from-blue-600 to-blue-700 px-6 py-4">
<h2 class="text-xl font-bold text-white">Ringkasan Pendaftaran</h2>
</div>
<div class="p-6">
<dl class="space-y-4">
<div class="flex justify-between py-2 border-b">
<dt class="text-gray-500">No. Registrasi</dt>
<dd class="font-semibold">{{ $this->registration->registration_number }}</dd>
</div>
<div class="flex justify-between py-2 border-b">
<dt class="text-gray-500">Nama</dt>
<dd class="font-semibold">{{ $this->registration->full_name }}</dd>
</div>
<div class="flex justify-between py-2 border-b">
<dt class="text-gray-500">Gelombang</dt>
<dd>{{ $this->registration->admissionWave->full_name }}</dd>
</div>
<div class="flex justify-between py-2 border-b">
<dt class="text-gray-500">Jalur</dt>
<dd>{{ $this->registration->admissionPath->name }}</dd>
</div>
<div class="flex justify-between py-2 border-b">
<dt class="text-gray-500">Pilihan 1</dt>
<dd>{{ $this->registration->firstChoice->name }}</dd>
</div>
</dl>
</div>
</div>
{{-- Payment Card --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
<div class="p-6">
<div class="text-center mb-6">
<p class="text-gray-500 mb-2">Total Biaya Pendaftaran</p>
<p class="text-4xl font-bold text-primary-600">
{{ $this->registration->formatted_payment_amount }}
</p>
</div>
@if($this->registration->payment_status === 'pending')
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 mb-6">
<div class="flex items-center gap-3">
<x-heroicon-o-clock class="w-6 h-6 text-yellow-600" />
<div>
<p class="font-semibold text-yellow-800 dark:text-yellow-200">
Menunggu Konfirmasi Pembayaran
</p>
<p class="text-sm text-yellow-600">
Pembayaran Anda sedang diproses. Halaman akan refresh otomatis.
</p>
</div>
</div>
</div>
@endif
<button
wire:click="getSnapToken"
id="pay-button"
class="w-full py-4 px-6 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg transition flex items-center justify-center gap-2"
>
<x-heroicon-o-credit-card class="w-6 h-6" />
Bayar Sekarang
</button>
<p class="text-center text-sm text-gray-500 mt-4">
Pembayaran aman melalui Midtrans
</p>
{{-- Payment Methods --}}
<div class="mt-6 pt-6 border-t">
<p class="text-sm text-gray-500 text-center mb-4">Metode pembayaran tersedia:</p>
<div class="flex flex-wrap justify-center gap-4 text-xs text-gray-600">
<span>💳 Kartu Kredit/Debit</span>
<span>🏦 Transfer Bank</span>
<span>📱 GoPay</span>
<span>💰 OVO</span>
<span>🏪 Indomaret/Alfamart</span>
</div>
</div>
</div>
</div>
</div>
@endif
{{-- Midtrans Snap JS --}}
@if($this->snapToken)
<script src="<https://app.sandbox.midtrans.com/snap/snap.js>" data-client-key="{{ $this->clientKey }}"></script>
<script>
window.snap.pay('{{ $this->snapToken }}', {
onSuccess: function(result) {
window.location.href = '{{ route("applicant.payment.finish") }}?status=success';
},
onPending: function(result) {
window.location.href = '{{ route("applicant.payment.finish") }}?status=pending';
},
onError: function(result) {
window.location.href = '{{ route("applicant.payment.finish") }}?status=error';
},
onClose: function() {
// User closed popup without finishing
}
});
</script>
@endif
</x-filament-panels::page>
Manual Review (20%):
- ✅ Summary card
- ✅ Snap popup trigger
- ✅ Callback handlers
- ⚠️ Ganti sandbox URL ke production saat deploy
Prompt 4: Webhook Controller
Buatkan controller untuk handle Midtrans webhook notification.
Endpoint harus accessible tanpa auth (dari server Midtrans).
Response Claude (80%):
<?php
// app/Http/Controllers/MidtransWebhookController.php
namespace App\\Http\\Controllers;
use App\\Services\\MidtransService;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Support\\Facades\\Log;
class MidtransWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
try {
$midtransService = new MidtransService();
// Verify signature
$payload = $request->all();
if (!$midtransService->verifySignature($payload)) {
Log::warning('Invalid Midtrans signature', $payload);
return response()->json(['status' => 'error', 'message' => 'Invalid signature'], 403);
}
// Handle notification
$result = $midtransService->handleNotification();
return response()->json($result);
} catch (\\Exception $e) {
Log::error('Midtrans webhook error: ' . $e->getMessage());
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
}
}
}
// routes/web.php - tambahkan route
use App\\Http\\Controllers\\MidtransWebhookController;
Route::post('/midtrans/webhook', [MidtransWebhookController::class, 'handle'])
->name('midtrans.webhook')
->withoutMiddleware([\\App\\Http\\Middleware\\VerifyCsrfToken::class]);
// app/Http/Controllers/PaymentCallbackController.php
namespace App\\Http\\Controllers;
use App\\Models\\Registration;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;
class PaymentCallbackController extends Controller
{
public function finish(Request $request)
{
$status = $request->get('status', 'unknown');
$registration = Registration::where('user_id', Auth::id())->first();
if ($status === 'success' && $registration) {
// Refresh dari database
$registration->refresh();
}
return redirect()->route('filament.applicant.pages.dashboard')
->with('payment_status', $status);
}
}
// routes/web.php - tambahkan route callback
Route::get('/payment/finish', [PaymentCallbackController::class, 'finish'])
->name('applicant.payment.finish')
->middleware(['auth']);
Manual Review (20%):
- ✅ CSRF exemption untuk webhook
- ✅ Signature verification
- ✅ Callback redirect
Prompt 5: Install DomPDF
Install DomPDF untuk generate kartu ujian PDF.
Response Claude (80%):
composer require barryvdh/laravel-dompdf
// config/dompdf.php akan auto-publish, atau jalankan:
php artisan vendor:publish --provider="Barryvdh\\DomPDF\\ServiceProvider"
Manual Review (20%):
- ✅ Package standard untuk Laravel PDF
Prompt 6: ExamCard PDF Generator
Buatkan service untuk generate kartu ujian PDF dengan:
- Header: Logo kampus, judul "KARTU PESERTA UJIAN"
- Foto pendaftar
- Data: nomor peserta, nama, jalur, pilihan prodi
- Jadwal ujian: tanggal, waktu, ruangan, nomor kursi
- QR Code berisi nomor peserta
- Tanda tangan panitia
- Ukuran A5 landscape
Response Claude (80%):
<?php
// app/Services/ExamCardService.php
namespace App\\Services;
use App\\Models\\Registration;
use App\\Models\\ExamSchedule;
use Barryvdh\\DomPDF\\Facade\\Pdf;
use Illuminate\\Support\\Facades\\Storage;
use SimpleSoftwareIO\\QrCode\\Facades\\QrCode;
class ExamCardService
{
public function generate(Registration $registration)
{
// Get exam schedule
$examSchedule = ExamSchedule::where('admission_wave_id', $registration->admission_wave_id)
->where('exam_type', 'written')
->first();
// Generate QR Code (base64)
$qrCode = base64_encode(
QrCode::format('svg')
->size(100)
->generate($registration->exam_number)
);
// Get photo as base64
$photoBase64 = null;
if ($registration->photo) {
$photoPath = Storage::path($registration->photo);
if (file_exists($photoPath)) {
$photoBase64 = base64_encode(file_get_contents($photoPath));
$photoMime = mime_content_type($photoPath);
}
}
$data = [
'registration' => $registration,
'examSchedule' => $examSchedule,
'qrCode' => $qrCode,
'photoBase64' => $photoBase64,
'photoMime' => $photoMime ?? 'image/jpeg',
];
$pdf = Pdf::loadView('pdf.exam-card', $data);
$pdf->setPaper('a5', 'landscape');
return $pdf;
}
public function download(Registration $registration)
{
$pdf = $this->generate($registration);
$filename = "kartu-ujian-{$registration->exam_number}.pdf";
return $pdf->download($filename);
}
public function stream(Registration $registration)
{
$pdf = $this->generate($registration);
return $pdf->stream("kartu-ujian-{$registration->exam_number}.pdf");
}
}
Install QR Code package:
composer require simplesoftwareio/simple-qrcode
Blade Template untuk Kartu Ujian:
{{-- resources/views/pdf/exam-card.blade.php --}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Kartu Peserta Ujian - {{ $registration->exam_number }}</title>
<style>
@page {
margin: 0;
}
body {
font-family: 'Helvetica', sans-serif;
font-size: 11px;
margin: 0;
padding: 15px;
background: #fff;
}
.card {
border: 3px solid #1a365d;
border-radius: 8px;
padding: 15px;
height: calc(100% - 30px);
}
.header {
text-align: center;
border-bottom: 2px solid #1a365d;
padding-bottom: 10px;
margin-bottom: 15px;
}
.header h1 {
margin: 0;
font-size: 16px;
color: #1a365d;
text-transform: uppercase;
letter-spacing: 2px;
}
.header h2 {
margin: 5px 0 0;
font-size: 12px;
color: #4a5568;
font-weight: normal;
}
.content {
display: table;
width: 100%;
}
.left-section {
display: table-cell;
width: 25%;
vertical-align: top;
text-align: center;
padding-right: 15px;
}
.photo-frame {
border: 2px solid #cbd5e0;
padding: 3px;
background: #f7fafc;
margin-bottom: 10px;
}
.photo-frame img {
width: 90px;
height: 120px;
object-fit: cover;
}
.exam-number {
background: #1a365d;
color: white;
padding: 8px;
font-size: 14px;
font-weight: bold;
border-radius: 4px;
margin-top: 10px;
}
.right-section {
display: table-cell;
width: 75%;
vertical-align: top;
}
.info-grid {
width: 100%;
}
.info-row {
margin-bottom: 8px;
}
.info-label {
color: #718096;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-value {
font-weight: bold;
font-size: 12px;
color: #2d3748;
}
.schedule-box {
background: #ebf8ff;
border: 1px solid #90cdf4;
border-radius: 6px;
padding: 12px;
margin-top: 15px;
}
.schedule-box h3 {
margin: 0 0 10px;
font-size: 12px;
color: #2b6cb0;
text-transform: uppercase;
}
.schedule-grid {
display: table;
width: 100%;
}
.schedule-item {
display: table-cell;
text-align: center;
padding: 5px;
}
.schedule-item .label {
font-size: 9px;
color: #4a5568;
}
.schedule-item .value {
font-size: 13px;
font-weight: bold;
color: #2d3748;
}
.footer {
margin-top: 15px;
display: table;
width: 100%;
border-top: 1px dashed #cbd5e0;
padding-top: 10px;
}
.qr-section {
display: table-cell;
width: 20%;
vertical-align: middle;
}
.signature-section {
display: table-cell;
width: 40%;
text-align: center;
vertical-align: bottom;
}
.signature-line {
border-bottom: 1px solid #2d3748;
width: 150px;
margin: 30px auto 5px;
}
.notes-section {
display: table-cell;
width: 40%;
vertical-align: top;
font-size: 8px;
color: #718096;
}
.notes-section ul {
margin: 0;
padding-left: 15px;
}
.watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 60px;
color: rgba(0, 0, 0, 0.03);
text-transform: uppercase;
letter-spacing: 10px;
z-index: -1;
}
</style>
</head>
<body>
<div class="watermark">UNIVERSITAS NUSANTARA</div>
<div class="card">
{{-- Header --}}
<div class="header">
<h1>Kartu Peserta Ujian</h1>
<h2>Penerimaan Mahasiswa Baru {{ $registration->admissionWave->year }}</h2>
</div>
{{-- Content --}}
<div class="content">
{{-- Left: Photo & Exam Number --}}
<div class="left-section">
<div class="photo-frame">
@if($photoBase64)
<img src="data:{{ $photoMime }};base64,{{ $photoBase64 }}" alt="Foto">
@else
<div style="width:90px;height:120px;background:#e2e8f0;display:flex;align-items:center;justify-content:center;">
<span style="color:#a0aec0;">No Photo</span>
</div>
@endif
</div>
<div class="exam-number">
{{ $registration->exam_number }}
</div>
</div>
{{-- Right: Info --}}
<div class="right-section">
<div class="info-grid">
<div class="info-row">
<div class="info-label">Nama Lengkap</div>
<div class="info-value">{{ strtoupper($registration->full_name) }}</div>
</div>
<div style="display:table;width:100%;">
<div style="display:table-cell;width:50%;">
<div class="info-row">
<div class="info-label">Tempat, Tanggal Lahir</div>
<div class="info-value">{{ $registration->birth_info }}</div>
</div>
</div>
<div style="display:table-cell;width:50%;">
<div class="info-row">
<div class="info-label">Jenis Kelamin</div>
<div class="info-value">{{ $registration->gender_label }}</div>
</div>
</div>
</div>
<div class="info-row">
<div class="info-label">Jalur Pendaftaran</div>
<div class="info-value">{{ $registration->admissionPath->name }}</div>
</div>
<div style="display:table;width:100%;">
<div style="display:table-cell;width:50%;">
<div class="info-row">
<div class="info-label">Pilihan 1</div>
<div class="info-value">{{ $registration->firstChoice->name }}</div>
</div>
</div>
<div style="display:table-cell;width:50%;">
<div class="info-row">
<div class="info-label">Pilihan 2</div>
<div class="info-value">{{ $registration->secondChoice?->name ?? '-' }}</div>
</div>
</div>
</div>
</div>
{{-- Schedule Box --}}
@if($examSchedule)
<div class="schedule-box">
<h3>🗓 Jadwal Ujian</h3>
<div class="schedule-grid">
<div class="schedule-item">
<div class="label">Tanggal</div>
<div class="value">{{ $examSchedule->exam_date->format('d M Y') }}</div>
</div>
<div class="schedule-item">
<div class="label">Waktu</div>
<div class="value">{{ $examSchedule->time_range }} WIB</div>
</div>
<div class="schedule-item">
<div class="label">Ruangan</div>
<div class="value">{{ $registration->exam_room ?? 'TBA' }}</div>
</div>
<div class="schedule-item">
<div class="label">No. Kursi</div>
<div class="value">{{ $registration->exam_seat ?? '-' }}</div>
</div>
</div>
</div>
@endif
</div>
</div>
{{-- Footer --}}
<div class="footer">
<div class="qr-section">
<img src="data:image/svg+xml;base64,{{ $qrCode }}" width="70" height="70">
</div>
<div class="notes-section">
<strong>Ketentuan:</strong>
<ul>
<li>Kartu ini wajib dibawa saat ujian</li>
<li>Hadir 30 menit sebelum ujian dimulai</li>
<li>Membawa alat tulis sendiri</li>
<li>Tidak diperkenankan membawa HP ke ruang ujian</li>
</ul>
</div>
<div class="signature-section">
<div class="info-label">Ketua Panitia PMB</div>
<div class="signature-line"></div>
<div style="font-size:10px;">( ............................ )</div>
</div>
</div>
</div>
</body>
</html>
Manual Review (20%):
- ✅ Layout A5 landscape
- ✅ Photo embed base64
- ✅ QR Code dengan nomor peserta
- ✅ Jadwal ujian dinamis
- ⚠️ Logo kampus perlu ditambahkan
Prompt 7: Route & Controller untuk Print Kartu Ujian
Buatkan route dan controller untuk print/download kartu ujian.
Hanya bisa diakses oleh pendaftar yang sudah verified dan punya exam_number.
Response Claude (80%):
<?php
// app/Http/Controllers/ExamCardController.php
namespace App\\Http\\Controllers;
use App\\Models\\Registration;
use App\\Services\\ExamCardService;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;
class ExamCardController extends Controller
{
public function __construct(
protected ExamCardService $examCardService
) {}
public function print(Registration $registration)
{
// Authorization check
if ($registration->user_id !== Auth::id()) {
abort(403, 'Unauthorized');
}
// Check if can print
if (!$registration->canPrintExamCard()) {
abort(403, 'Kartu ujian belum tersedia');
}
return $this->examCardService->stream($registration);
}
public function download(Registration $registration)
{
// Authorization check
if ($registration->user_id !== Auth::id()) {
abort(403, 'Unauthorized');
}
// Check if can print
if (!$registration->canPrintExamCard()) {
abort(403, 'Kartu ujian belum tersedia');
}
return $this->examCardService->download($registration);
}
// Admin version
public function adminPrint(Registration $registration)
{
if (!$registration->canPrintExamCard()) {
abort(403, 'Kartu ujian belum tersedia');
}
return $this->examCardService->stream($registration);
}
}
// routes/web.php
use App\\Http\\Controllers\\ExamCardController;
// Applicant routes
Route::middleware(['auth'])->group(function () {
Route::get('/exam-card/{registration}/print', [ExamCardController::class, 'print'])
->name('applicant.exam-card.print');
Route::get('/exam-card/{registration}/download', [ExamCardController::class, 'download'])
->name('applicant.exam-card.download');
});
// Admin routes
Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/exam-card/{registration}/print', [ExamCardController::class, 'adminPrint'])
->name('exam-card.print');
});
Manual Review (20%):
- ✅ Authorization check
- ✅ canPrintExamCard validation
- ✅ Stream vs download options
💡 RECAP PAYMENT & EXAM CARD:
Prompts: 7
Services created:
- MidtransService (snap token, webhook, signature verify)
- ExamCardService (PDF generation)
Pages created:
- Payment (Midtrans Snap integration)
Controllers:
- MidtransWebhookController
- PaymentCallbackController
- ExamCardController
PDF Template:
- Kartu ujian A5 landscape
- Photo, QR code, jadwal ujian
- Signature area
Features:
- Snap popup payment
- Multiple payment methods
- Webhook notification handling
- Signature verification
- PDF dengan foto pendaftar
- QR code untuk verifikasi
Yang di-review manual:
- Midtrans credentials
- Webhook URL configuration
- Production vs sandbox
- Logo kampus di PDF
Di bagian selanjutnya, kita akan buat halaman pengumuman kelulusan dan flow daftar ulang.
Bagian 9: Pengumuman Kelulusan & Daftar Ulang
Di bagian ini kita akan buat halaman pengumuman kelulusan yang bisa diakses publik dan flow daftar ulang untuk pendaftar yang diterima.
Prompt 1: Public Announcement Page
Buatkan halaman pengumuman kelulusan yang bisa diakses
tanpa login dengan:
- Input nomor peserta + tanggal lahir (verifikasi)
- Tampilkan hasil: LULUS / TIDAK LULUS / CADANGAN
- Jika lulus, tampilkan program studi diterima dan instruksi daftar ulang
- Design yang celebratory untuk yang lulus
Response Claude (80%):
<?php
// app/Http/Controllers/AnnouncementController.php
namespace App\\Http\\Controllers;
use App\\Models\\Registration;
use App\\Models\\AdmissionWave;
use Illuminate\\Http\\Request;
class AnnouncementController extends Controller
{
public function index()
{
$activeWaves = AdmissionWave::where('is_active', true)
->whereIn('status', ['announced', 'completed'])
->orderByDesc('year')
->get();
return view('announcement.index', compact('activeWaves'));
}
public function check(Request $request)
{
$request->validate([
'exam_number' => 'required|string',
'birth_date' => 'required|date',
]);
$registration = Registration::where('exam_number', $request->exam_number)
->whereDate('birth_date', $request->birth_date)
->whereIn('status', ['announced', 'accepted', 'rejected', 're_registered'])
->first();
if (!$registration) {
return back()->withErrors([
'exam_number' => 'Data tidak ditemukan. Pastikan nomor peserta dan tanggal lahir benar.'
])->withInput();
}
return view('announcement.result', compact('registration'));
}
}
// routes/web.php
use App\\Http\\Controllers\\AnnouncementController;
Route::get('/pengumuman', [AnnouncementController::class, 'index'])
->name('announcement.index');
Route::post('/pengumuman/check', [AnnouncementController::class, 'check'])
->name('announcement.check');
Blade View — Form Pencarian:
{{-- resources/views/announcement/index.blade.php --}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pengumuman Kelulusan - PMB Universitas Nusantara</title>
<script src="<https://cdn.tailwindcss.com>"></script>
</head>
<body class="bg-gradient-to-br from-blue-900 via-blue-800 to-indigo-900 min-h-screen">
<div class="container mx-auto px-4 py-12">
{{-- Header --}}
<div class="text-center mb-12">
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
🎓 Pengumuman Kelulusan
</h1>
<p class="text-blue-200 text-lg">
Penerimaan Mahasiswa Baru Universitas Nusantara
</p>
</div>
{{-- Search Card --}}
<div class="max-w-md mx-auto">
<div class="bg-white rounded-2xl shadow-2xl p-8">
<h2 class="text-xl font-semibold text-gray-800 mb-6 text-center">
Cek Hasil Seleksi
</h2>
@if($errors->any())
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p class="text-red-600 text-sm">{{ $errors->first() }}</p>
</div>
@endif
<form action="{{ route('announcement.check') }}" method="POST">
@csrf
<div class="mb-4">
<label class="block text-gray-700 text-sm font-medium mb-2">
Nomor Peserta
</label>
<input
type="text"
name="exam_number"
value="{{ old('exam_number') }}"
placeholder="Contoh: 2026-01-REG-00001"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-medium mb-2">
Tanggal Lahir
</label>
<input
type="date"
name="birth_date"
value="{{ old('birth_date') }}"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>
</div>
<button
type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition duration-200"
>
Cek Hasil
</button>
</form>
<p class="text-center text-gray-500 text-sm mt-6">
Tanggal lahir digunakan untuk verifikasi identitas
</p>
</div>
{{-- Active Waves Info --}}
@if($activeWaves->isNotEmpty())
<div class="mt-8 bg-white/10 backdrop-blur rounded-xl p-6">
<h3 class="text-white font-semibold mb-4">Gelombang yang Sudah Diumumkan:</h3>
<ul class="space-y-2">
@foreach($activeWaves as $wave)
<li class="text-blue-200 flex items-center gap-2">
<span class="text-green-400">✓</span>
{{ $wave->full_name }} — Diumumkan {{ $wave->announcement_date?->format('d M Y') }}
</li>
@endforeach
</ul>
</div>
@endif
</div>
</div>
</body>
</html>
Blade View — Hasil Pengumuman:
{{-- resources/views/announcement/result.blade.php --}}
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hasil Seleksi - {{ $registration->full_name }}</title>
<script src="<https://cdn.tailwindcss.com>"></script>
@if($registration->result === 'passed')
<script src="<https://cdn.jsdelivr.net/npm/[email protected]/dist/confetti.browser.min.js>"></script>
@endif
</head>
<body class="min-h-screen {{ $registration->result === 'passed' ? 'bg-gradient-to-br from-green-600 via-emerald-600 to-teal-700' : ($registration->result === 'waitlist' ? 'bg-gradient-to-br from-yellow-600 via-amber-600 to-orange-600' : 'bg-gradient-to-br from-gray-700 via-gray-800 to-gray-900') }}">
<div class="container mx-auto px-4 py-12">
<div class="max-w-2xl mx-auto">
{{-- Result Card --}}
<div class="bg-white rounded-3xl shadow-2xl overflow-hidden">
{{-- Header --}}
<div class="p-8 text-center {{ $registration->result === 'passed' ? 'bg-green-50' : ($registration->result === 'waitlist' ? 'bg-yellow-50' : 'bg-gray-50') }}">
@if($registration->result === 'passed')
<div class="text-6xl mb-4">🎉</div>
<h1 class="text-3xl font-bold text-green-700 mb-2">SELAMAT!</h1>
<p class="text-green-600 text-lg">Anda dinyatakan</p>
<div class="inline-block bg-green-600 text-white text-2xl font-bold px-8 py-3 rounded-full mt-4">
LULUS
</div>
@elseif($registration->result === 'waitlist')
<div class="text-6xl mb-4">📋</div>
<h1 class="text-3xl font-bold text-yellow-700 mb-2">CADANGAN</h1>
<p class="text-yellow-600 text-lg">Anda masuk dalam daftar cadangan</p>
<div class="inline-block bg-yellow-600 text-white text-xl font-bold px-8 py-3 rounded-full mt-4">
DAFTAR CADANGAN
</div>
@else
<div class="text-6xl mb-4">📄</div>
<h1 class="text-3xl font-bold text-gray-700 mb-2">PENGUMUMAN</h1>
<p class="text-gray-600 text-lg">Hasil Seleksi PMB</p>
<div class="inline-block bg-gray-600 text-white text-xl font-bold px-8 py-3 rounded-full mt-4">
TIDAK LULUS
</div>
@endif
</div>
{{-- Details --}}
<div class="p-8">
<div class="grid grid-cols-2 gap-6 mb-8">
<div>
<p class="text-gray-500 text-sm">Nomor Peserta</p>
<p class="font-semibold text-gray-800">{{ $registration->exam_number }}</p>
</div>
<div>
<p class="text-gray-500 text-sm">Nama</p>
<p class="font-semibold text-gray-800">{{ $registration->full_name }}</p>
</div>
<div>
<p class="text-gray-500 text-sm">Jalur</p>
<p class="font-semibold text-gray-800">{{ $registration->admissionPath->name }}</p>
</div>
<div>
<p class="text-gray-500 text-sm">Nilai Akhir</p>
<p class="font-semibold text-gray-800">{{ $registration->formatted_final_score }}</p>
</div>
</div>
@if($registration->result === 'passed')
{{-- Accepted Program --}}
<div class="bg-green-50 border-2 border-green-200 rounded-xl p-6 mb-8">
<h3 class="text-green-800 font-semibold mb-2">Diterima di Program Studi:</h3>
<p class="text-2xl font-bold text-green-700">
{{ $registration->acceptedProgram->degree }} {{ $registration->acceptedProgram->name }}
</p>
<p class="text-green-600 mt-1">
{{ $registration->acceptedProgram->faculty->name }}
</p>
</div>
{{-- Re-registration Info --}}
<div class="bg-blue-50 border border-blue-200 rounded-xl p-6">
<h3 class="text-blue-800 font-semibold mb-4 flex items-center gap-2">
<span>📝</span> Langkah Selanjutnya: Daftar Ulang
</h3>
<div class="space-y-3 text-sm text-blue-700">
<p>
<strong>Batas Waktu:</strong>
{{ $registration->admissionWave->re_registration_deadline?->format('d F Y') }}
</p>
<p>
<strong>Biaya UKT:</strong>
{{ $registration->acceptedProgram->formatted_tuition }} / semester
</p>
</div>
<div class="mt-6">
<a
href="{{ route('filament.applicant.pages.dashboard') }}"
class="inline-block bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-8 rounded-lg transition"
>
Login untuk Daftar Ulang →
</a>
</div>
</div>
@elseif($registration->result === 'waitlist')
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-6">
<h3 class="text-yellow-800 font-semibold mb-2">Informasi Cadangan</h3>
<p class="text-yellow-700 text-sm">
Anda berada di posisi cadangan. Jika ada peserta yang lulus tidak melakukan
daftar ulang hingga batas waktu, Anda berkesempatan untuk dipanggil.
Pantau terus email dan pengumuman di website.
</p>
</div>
@else
<div class="bg-gray-50 border border-gray-200 rounded-xl p-6">
<h3 class="text-gray-800 font-semibold mb-2">Jangan Menyerah!</h3>
<p class="text-gray-600 text-sm">
Terima kasih telah berpartisipasi dalam seleksi PMB Universitas Nusantara.
Anda dapat mencoba kembali di gelombang berikutnya atau tahun depan.
Tetap semangat!
</p>
</div>
@endif
</div>
{{-- Footer --}}
<div class="bg-gray-50 px-8 py-4 border-t">
<a href="{{ route('announcement.index') }}" class="text-blue-600 hover:underline text-sm">
← Cek peserta lain
</a>
</div>
</div>
</div>
</div>
@if($registration->result === 'passed')
<script>
// Confetti celebration!
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 }
});
setTimeout(() => {
confetti({
particleCount: 50,
angle: 60,
spread: 55,
origin: { x: 0 }
});
confetti({
particleCount: 50,
angle: 120,
spread: 55,
origin: { x: 1 }
});
}, 500);
</script>
@endif
</body>
</html>
Manual Review (20%):
- ✅ Verifikasi dengan tanggal lahir
- ✅ Confetti untuk yang lulus
- ✅ Different styling per result
- ✅ Re-registration info untuk yang lulus
Prompt 2: Re-Registration Page
Buatkan halaman daftar ulang di Applicant Panel dengan:
- Tampilkan info kelulusan dan program diterima
- Upload dokumen tambahan jika diperlukan
- Pembayaran UKT via Midtrans
- Generate NIM setelah daftar ulang complete
Response Claude (80%):
<?php
// app/Filament/Applicant/Pages/ReRegistration.php
namespace App\\Filament\\Applicant\\Pages;
use App\\Models\\Registration;
use App\\Services\\MidtransService;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Forms\\Concerns\\InteractsWithForms;
use Filament\\Forms\\Contracts\\HasForms;
use Filament\\Notifications\\Notification;
use Filament\\Pages\\Page;
use Illuminate\\Support\\Facades\\Auth;
use Illuminate\\Support\\Str;
class ReRegistration extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-academic-cap';
protected static ?string $navigationLabel = 'Daftar Ulang';
protected static ?string $title = 'Daftar Ulang Mahasiswa Baru';
protected static ?int $navigationSort = 3;
protected static string $view = 'filament.applicant.pages.re-registration';
public ?Registration $registration = null;
public ?array $data = [];
public ?string $snapToken = null;
public string $clientKey;
public function mount(): void
{
$this->registration = Registration::where('user_id', Auth::id())
->where('status', 'accepted')
->where('result', 'passed')
->first();
if (!$this->registration) {
Notification::make()
->title('Anda tidak memiliki akses ke halaman ini')
->warning()
->send();
$this->redirect(route('filament.applicant.pages.dashboard'));
return;
}
// Already completed
if ($this->registration->re_registration_status === 'completed') {
$this->redirect(route('filament.applicant.pages.dashboard'));
return;
}
$this->clientKey = config('midtrans.client_key');
// Set UKT amount from accepted program
if (!$this->registration->ukt_amount) {
$this->registration->update([
'ukt_amount' => $this->registration->acceptedProgram->tuition_fee,
're_registration_status' => 'pending',
]);
}
}
public function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Dokumen Tambahan')
->description('Upload dokumen tambahan jika diperlukan')
->schema([
Forms\\Components\\FileUpload::make('additional_documents')
->label('Dokumen Tambahan')
->multiple()
->maxFiles(5)
->acceptedFileTypes(['image/jpeg', 'image/png', 'application/pdf'])
->maxSize(2048)
->directory('registrations/additional')
->helperText('Opsional. Upload jika ada dokumen yang diminta panitia.'),
]),
])
->statePath('data');
}
public function payUkt(): void
{
if ($this->registration->ukt_paid_at) {
Notification::make()
->title('UKT sudah dibayar')
->warning()
->send();
return;
}
try {
$midtransService = new MidtransService();
$this->snapToken = $midtransService->createUktSnapToken($this->registration);
} catch (\\Exception $e) {
Notification::make()
->title('Gagal membuat pembayaran')
->body($e->getMessage())
->danger()
->send();
}
}
public function completeReRegistration(): void
{
if (!$this->registration->ukt_paid_at) {
Notification::make()
->title('UKT belum dibayar')
->danger()
->send();
return;
}
// Generate NIM
$nim = $this->generateNim();
$this->registration->update([
'nim' => $nim,
'status' => 're_registered',
're_registration_status' => 'completed',
're_registration_completed_at' => now(),
]);
Notification::make()
->title('Selamat! Daftar Ulang Berhasil')
->body("NIM Anda: {$nim}")
->success()
->send();
$this->redirect(route('filament.applicant.pages.dashboard'));
}
protected function generateNim(): string
{
$year = date('Y');
$facultyCode = $this->registration->acceptedProgram->faculty->code;
$programCode = $this->registration->acceptedProgram->code;
// Get next sequence
$lastNim = Registration::where('nim', 'like', "{$year}{$programCode}%")
->orderByDesc('nim')
->value('nim');
if ($lastNim) {
$lastSeq = (int) substr($lastNim, -4);
$nextSeq = $lastSeq + 1;
} else {
$nextSeq = 1;
}
return $year . $programCode . Str::padLeft($nextSeq, 4, '0');
}
public static function shouldRegisterNavigation(): bool
{
$registration = Registration::where('user_id', Auth::id())
->where('status', 'accepted')
->where('result', 'passed')
->where(function ($q) {
$q->whereNull('re_registration_status')
->orWhere('re_registration_status', 'pending');
})
->first();
return $registration !== null;
}
}
Tambahan method di MidtransService untuk UKT:
// app/Services/MidtransService.php - tambahkan method
public function createUktSnapToken(Registration $registration): string
{
$orderId = 'UKT-' . $registration->id . '-' . time();
$params = [
'transaction_details' => [
'order_id' => $orderId,
'gross_amount' => $registration->ukt_amount,
],
'customer_details' => [
'first_name' => $registration->full_name,
'email' => $registration->user->email,
'phone' => $registration->phone,
],
'item_details' => [
[
'id' => 'UKT-' . $registration->accepted_program_id,
'price' => $registration->ukt_amount,
'quantity' => 1,
'name' => 'UKT Semester 1 - ' . $registration->acceptedProgram->name,
],
],
'callbacks' => [
'finish' => route('applicant.ukt.finish'),
],
];
return Snap::getSnapToken($params);
}
public function handleUktNotification(): array
{
$notification = new Notification();
$transactionStatus = $notification->transaction_status;
$orderId = $notification->order_id;
$paymentType = $notification->payment_type;
// Check if UKT payment
if (!str_starts_with($orderId, 'UKT-')) {
return $this->handleNotification(); // Regular payment
}
$registrationId = explode('-', $orderId)[1];
$registration = Registration::find($registrationId);
if (!$registration) {
return ['status' => 'error', 'message' => 'Registration not found'];
}
if (in_array($transactionStatus, ['settlement', 'capture'])) {
$registration->update([
'ukt_paid_at' => now(),
]);
}
return [
'status' => 'success',
'type' => 'ukt',
'registration_id' => $registration->id,
];
}
Blade View untuk Re-Registration:
{{-- resources/views/filament/applicant/pages/re-registration.blade.php --}}
<x-filament-panels::page>
@if($this->registration)
<div class="max-w-3xl mx-auto space-y-6">
{{-- Congratulations Banner --}}
<div class="bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl p-8 text-white text-center">
<div class="text-5xl mb-4">🎓</div>
<h2 class="text-2xl font-bold mb-2">Selamat, Anda Diterima!</h2>
<p class="text-green-100">
{{ $this->registration->acceptedProgram->degree }}
{{ $this->registration->acceptedProgram->name }}
</p>
</div>
{{-- Status Progress --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow p-6">
<h3 class="font-semibold mb-4">Progress Daftar Ulang</h3>
<div class="flex items-center gap-4">
{{-- Step 1: Lulus --}}
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center">
<x-heroicon-o-check class="w-5 h-5" />
</div>
<span class="text-sm">Lulus Seleksi</span>
</div>
<div class="flex-1 h-1 bg-green-500"></div>
{{-- Step 2: Bayar UKT --}}
<div class="flex items-center gap-2">
<div class="w-8 h-8 {{ $this->registration->ukt_paid_at ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500' }} rounded-full flex items-center justify-center">
@if($this->registration->ukt_paid_at)
<x-heroicon-o-check class="w-5 h-5" />
@else
2
@endif
</div>
<span class="text-sm">Bayar UKT</span>
</div>
<div class="flex-1 h-1 {{ $this->registration->ukt_paid_at ? 'bg-green-500' : 'bg-gray-200' }}"></div>
{{-- Step 3: NIM --}}
<div class="flex items-center gap-2">
<div class="w-8 h-8 {{ $this->registration->nim ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500' }} rounded-full flex items-center justify-center">
@if($this->registration->nim)
<x-heroicon-o-check class="w-5 h-5" />
@else
3
@endif
</div>
<span class="text-sm">Terima NIM</span>
</div>
</div>
</div>
{{-- UKT Payment --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow p-6">
<h3 class="font-semibold mb-4">Pembayaran UKT Semester 1</h3>
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mb-4">
<div>
<p class="text-sm text-gray-500">Total UKT</p>
<p class="text-2xl font-bold text-primary-600">
{{ $this->registration->formatted_ukt_amount }}
</p>
</div>
@if($this->registration->ukt_paid_at)
<span class="inline-flex items-center gap-2 bg-green-100 text-green-800 px-4 py-2 rounded-full">
<x-heroicon-o-check-circle class="w-5 h-5" />
Sudah Dibayar
</span>
@else
<button
wire:click="payUkt"
class="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg transition"
>
Bayar UKT
</button>
@endif
</div>
<p class="text-sm text-gray-500">
Batas waktu: {{ $this->registration->admissionWave->re_registration_deadline?->format('d F Y') }}
</p>
</div>
{{-- Additional Documents Form --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow p-6">
<form wire:submit.prevent>
{{ $this->form }}
</form>
</div>
{{-- Complete Button --}}
@if($this->registration->ukt_paid_at && !$this->registration->nim)
<div class="text-center">
<button
wire:click="completeReRegistration"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-4 px-8 rounded-lg transition text-lg"
>
<x-heroicon-o-academic-cap class="w-6 h-6 inline mr-2" />
Selesaikan Daftar Ulang & Dapatkan NIM
</button>
</div>
@endif
{{-- NIM Card (if completed) --}}
@if($this->registration->nim)
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-2xl p-8 text-white text-center">
<p class="text-blue-100 mb-2">Nomor Induk Mahasiswa Anda</p>
<p class="text-4xl font-bold tracking-wider mb-4">{{ $this->registration->nim }}</p>
<p class="text-blue-100 text-sm">
Simpan nomor ini dengan baik. NIM akan digunakan selama masa studi Anda.
</p>
</div>
@endif
</div>
@endif
{{-- Midtrans Snap JS for UKT --}}
@if($this->snapToken)
<script src="<https://app.sandbox.midtrans.com/snap/snap.js>" data-client-key="{{ $this->clientKey }}"></script>
<script>
window.snap.pay('{{ $this->snapToken }}', {
onSuccess: function(result) {
window.location.href = '{{ route("applicant.ukt.finish") }}?status=success';
},
onPending: function(result) {
window.location.href = '{{ route("applicant.ukt.finish") }}?status=pending';
},
onError: function(result) {
window.location.href = '{{ route("applicant.ukt.finish") }}?status=error';
},
onClose: function() {}
});
</script>
@endif
</x-filament-panels::page>
Route untuk UKT callback:
// routes/web.php
Route::get('/ukt/finish', function (Request $request) {
return redirect()->route('filament.applicant.pages.re-registration')
->with('ukt_status', $request->get('status'));
})->name('applicant.ukt.finish')->middleware(['auth']);
Manual Review (20%):
- ✅ Progress steps visual
- ✅ UKT payment via Midtrans
- ✅ NIM generation
- ✅ Conditional navigation
💡 RECAP PENGUMUMAN & DAFTAR ULANG:
Prompts: 2
Pages created:
- Public announcement (tanpa login)
- Re-Registration (Applicant Panel)
Features:
- Verifikasi dengan tanggal lahir
- Confetti celebration untuk yang lulus
- Different UI per result (passed/failed/waitlist)
- Progress steps untuk daftar ulang
- UKT payment via Midtrans
- NIM auto-generation
- Conditional navigation
Controllers:
- AnnouncementController (public)
Yang di-review manual:
- NIM format sesuai kampus
- Deadline validation
- Waitlist handling
Di bagian terakhir, kita akan buat penutup dengan recap perjalanan, statistik vibe coding, dan rekomendasi kelas BuildWithAngga.
Bagian 10: Penutup & Rekomendasi BuildWithAngga
Selamat! Kamu sudah berhasil membangun sistem Penerimaan Mahasiswa Baru (PMB) yang lengkap dengan pendekatan vibe coding. Mari kita recap perjalanan kita dan lihat apa yang bisa dipelajari selanjutnya.
Recap Perjalanan Building Sistem PMB
SISTEM PMB ONLINE - COMPLETE!
📁 Project Structure:
├── Admin Panel (/admin)
│ ├── Dashboard (statistik real-time)
│ ├── Master Data
│ │ ├── Fakultas
│ │ ├── Program Studi
│ │ ├── Gelombang Pendaftaran
│ │ └── Jalur Pendaftaran
│ ├── Pendaftaran
│ │ ├── Data Pendaftar (view, filter)
│ │ ├── Verifikasi Dokumen
│ │ ├── Input Nilai Ujian
│ │ └── Generate Pengumuman
│ └── Laporan
│ └── Export Excel
│
├── Applicant Panel (/pendaftaran)
│ ├── Dashboard (progress tracking)
│ ├── Form Pendaftaran (5-step wizard)
│ ├── Pembayaran (Midtrans)
│ ├── Cetak Kartu Ujian (PDF)
│ └── Daftar Ulang
│
├── Public Pages
│ └── Pengumuman Kelulusan
│
├── Services
│ ├── MidtransService
│ └── ExamCardService
│
└── Database
├── 7 Tables
├── 7 Models
└── Seeders
Statistik Vibe Coding
Berdasarkan tutorial ini, berikut breakdown antara prompt ke Claude vs manual coding:
| Komponen | Dari Prompt | Manual Edit | Total Lines |
|---|---|---|---|
| Migrations | 95% | 5% | ~400 |
| Models | 90% | 10% | ~600 |
| Filament Resources | 85% | 15% | ~800 |
| Custom Pages | 80% | 20% | ~500 |
| Services | 85% | 15% | ~300 |
| Blade Views | 75% | 25% | ~600 |
| Controllers | 90% | 10% | ~200 |
| Total | ~85% | ~15% | ~3.400 |
Yang paling banyak di-edit manual:
- Validation rules spesifik Indonesia (NIK, format alamat)
- Business logic seleksi dan ranking
- UI/UX tweaks untuk Blade views
- Edge cases handling
Yang hampir 100% dari prompt:
- Database migrations
- Eloquent relationships
- Basic CRUD operations
- Midtrans integration boilerplate
Apa yang Sudah Kamu Pelajari
✓ Setup Filament 4 dengan Multiple Panels
└── Admin dan Applicant panel terpisah tapi satu codebase
✓ Database Design untuk Sistem Akademik
└── Relasi kompleks, JSON fields, status management
✓ Multi-Step Form Wizard
└── 5 tahap dengan validation, auto-save, file upload
✓ Payment Gateway Integration
└── Midtrans Snap API untuk biaya daftar dan UKT
✓ PDF Generation
└── Kartu ujian dengan foto, QR code, dan styling
✓ Public Pages dengan Laravel
└── Pengumuman tanpa authentication
✓ Status Workflow Management
└── 13 status dengan transisi yang jelas
✓ Vibe Coding Workflow
└── 85% prompt, 15% manual review dan edit
Fitur yang Bisa Ditambahkan
Sistem PMB ini sudah functional, tapi masih banyak yang bisa dikembangkan:
ENHANCEMENT IDEAS:
🔔 Notification System
├── Email notification setiap status change
├── WhatsApp notification (via Fonnte/WaBlas)
└── Push notification ke mobile
📊 Advanced Analytics
├── Conversion funnel (register → submit → pay → pass)
├── Demographics analysis per wilayah
├── Trend comparison year-over-year
└── Program studi demand analysis
🔗 Integration
├── LTMPT/SNPMB API untuk sinkronisasi data
├── PDDIKTI integration untuk reporting
├── SSO dengan sistem kampus existing
└── LMS integration untuk yang diterima
📝 Advanced Features
├── Online exam/CBT module
├── Interview scheduling dengan calendar
├── Document OCR untuk auto-fill data
├── Chatbot FAQ untuk calon mahasiswa
├── Mobile app untuk monitoring
🛡️ Security & Audit
├── Complete audit trail
├── Two-factor authentication
├── Rate limiting untuk form submission
└── Data encryption untuk dokumen sensitif
Rekomendasi Kelas Gratis BuildWithAngga
Sebelum lanjut ke project yang lebih kompleks, pastikan fundamental kamu kuat. Kelas-kelas gratis ini akan membantu:
| Kelas | Apa yang Dipelajari |
|---|---|
| Laravel Fundamental | MVC pattern, routing, Eloquent ORM, Blade templating — fondasi untuk semua project Laravel |
| PHP untuk Pemula | Syntax PHP, OOP, functions, error handling — wajib sebelum Laravel |
| JavaScript Fundamental | DOM manipulation, events, async/await — untuk interaksi frontend |
| Tailwind CSS | Utility-first CSS, responsive design — styling modern tanpa ribet |
| SQL for Beginners | Query dasar, JOIN, indexing — optimasi database |
| Git untuk Pemula | Version control, branching, collaboration — wajib untuk tim |
Semua kelas di atas GRATIS dan bisa diakses kapan saja di BuildWithAngga.
Rekomendasi Kelas Premium BuildWithAngga
Untuk menguasai skill yang dipakai di tutorial ini secara mendalam:
| Kelas | Cocok Untuk |
|---|---|
| Full-Stack Laravel + Vue.js | Build SPA dengan Laravel backend dan Vue frontend — lebih modern dari Blade |
| Laravel Filament Mastery | Deep dive Filament: custom fields, actions, plugins, themes — jadi expert admin panel |
| Laravel API Development | RESTful API, authentication, rate limiting — kalau mau bikin mobile app untuk PMB |
| DevOps untuk Developer | Docker, CI/CD, monitoring — deploy sistem PMB ke production dengan proper |
| Build SaaS dengan Laravel | Multi-tenant, subscription, billing — kalau mau jual sistem PMB ke banyak kampus |
| Laravel Payment Integration | Midtrans, Xendit, Stripe deep dive — payment untuk berbagai use case |
Benefit Kelas Premium BuildWithAngga
┌─────────────────────────────────────────────────────────┐
│ │
│ 🎯 BENEFIT KELAS PREMIUM BUILDWITHANGGA │
│ │
├─────────────────────────────────────────────────────────┤
│ │
│ ✅ Project Portfolio-Ready │
│ Setiap kelas menghasilkan project yang bisa │
│ langsung masuk portfolio untuk melamar kerja │
│ │
│ ✅ Akses Seumur Hidup │
│ Sekali bayar, akses forever. Update materi │
│ juga gratis selamanya │
│ │
│ ✅ Konsultasi dengan Mentor │
│ Stuck di project? Tanya langsung ke mentor │
│ yang berpengalaman di industri │
│ │
│ ✅ Sertifikat Resmi │
│ Sertifikat completion yang bisa di-share │
│ di LinkedIn dan CV │
│ │
│ ✅ Komunitas 900.000+ Students │
│ Network dengan sesama developer Indonesia, │
│ sharing job info, dan collaboration │
│ │
│ ✅ Always Up-to-Date │
│ Materi selalu diupdate mengikuti versi │
│ terbaru framework dan best practices │
│ │
└─────────────────────────────────────────────────────────┘
Tips Melanjutkan Project Ini
Kalau kamu mau develop sistem PMB ini lebih lanjut untuk kampus nyata:
- Diskusi dengan Stakeholder
- Interview panitia PMB yang ada
- Pahami SOP dan regulasi kampus
- Identifikasi pain points spesifik
- Customize Sesuai Kebutuhan
- Sesuaikan field dengan form resmi kampus
- Tambahkan jalur khusus (afirmasi, atlet, dll)
- Integrasi dengan sistem existing
- Testing Menyeluruh
- Unit test untuk business logic
- Feature test untuk flow utama
- UAT dengan sample users
- Deployment Proper
- Server dengan spek yang cukup
- SSL certificate
- Backup otomatis
- Monitoring dan alerting
- Dokumentasi
- User manual untuk admin
- FAQ untuk calon mahasiswa
- Technical docs untuk maintenance
Closing Statement
Sistem PMB yang kita bangun di tutorial ini adalah contoh bagaimana vibe coding bisa mempercepat development tanpa mengorbankan kualitas. Dengan 85% kode dari prompt dan 15% review manual, kita berhasil build sistem yang:
- Complete dari pendaftaran sampai daftar ulang
- User-friendly untuk admin dan pendaftar
- Terintegrasi dengan payment gateway
- Generate dokumen PDF
- Responsive dan modern
Ini bukan magic — ini adalah skill yang bisa dipelajari. Semakin sering kamu practice vibe coding, semakin bagus prompt yang kamu tulis, dan semakin efisien workflow-mu.
Yang penting diingat: AI adalah partner, bukan pengganti. Kamu tetap perlu understand kode yang di-generate, review untuk security dan edge cases, dan own hasilnya.
Selamat coding, dan sampai ketemu di tutorial selanjutnya!