Oke teman-teman, sekarang kita masuk ke bagian yang lebih exciting – membuat Filament Resources untuk semua entitas utama dalam sistem SaaS Laundry Management kita. Di section ini, kita akan create comprehensive CRUD operations dengan forms yang sophisticated, table dengan advanced filtering, dan custom actions yang akan make admin dashboard kita super powerful untuk daily operations.
Membuat Filament CRUD Resources untuk SaaS Laundry Management
Sebelum kita dive into coding, penting untuk understand bagaimana Filament Resource architecture bekerja. Setiap Resource terdiri dari beberapa components utama: Form schema untuk create/edit operations, Table configuration untuk listing dan bulk actions, Pages untuk handling different views, dan Actions untuk custom operations yang bisa di-trigger dari UI.
Yang bikin Filament powerful adalah kemampuannya untuk automatically generate UI berdasarkan configuration yang kita define. Tapi jangan salah, flexibility-nya juga luar biasa – kita bisa customize hampir semua aspects dari UI dan behavior sesuai business requirements yang specific.
Creating LaundryService Resource
Mari kita mulai dengan LaundryService resource yang akan handle pricing dan service types. First, create the model dan migration:
php artisan make:model LaundryService -m
Edit migration file:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('laundry_services', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('code')->unique();
$table->text('description')->nullable();
$table->decimal('price_per_kg', 8, 2);
$table->decimal('express_multiplier', 3, 2)->default(1.5);
$table->integer('standard_duration_hours')->default(24);
$table->integer('express_duration_hours')->default(6);
$table->boolean('is_active')->default(true);
$table->json('available_branches')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('laundry_services');
}
};
Sekarang create Filament Resource:
php artisan make:filament-resource LaundryService
Edit app/Filament/Resources/LaundryServiceResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\LaundryServiceResource\\Pages;
use App\\Models\\LaundryService;
use App\\Models\\Branch;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Tables\\Filters\\Filter;
use Illuminate\\Database\\Eloquent\\Builder;
class LaundryServiceResource extends Resource
{
protected static ?string $model = LaundryService::class;
protected static ?string $navigationIcon = 'heroicon-o-sparkles';
protected static ?string $navigationGroup = 'Service Management';
protected static ?string $navigationLabel = 'Laundry Services';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Basic Information')
->schema([
Forms\\Components\\TextInput::make('name')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn (string $context, $state, callable $set) =>
$context === 'create' ? $set('code', str($state)->slug()->upper()) : null
),
Forms\\Components\\TextInput::make('code')
->required()
->unique(ignoreRecord: true)
->maxLength(20)
->uppercase()
->alphaDash(),
Forms\\Components\\Textarea::make('description')
->rows(3)
->maxLength(500),
Forms\\Components\\Toggle::make('is_active')
->default(true)
->helperText('Inactive services will not be available for new orders'),
])
->columns(2),
Forms\\Components\\Section::make('Pricing & Duration')
->schema([
Forms\\Components\\TextInput::make('price_per_kg')
->required()
->numeric()
->prefix('IDR')
->minValue(0)
->step(500)
->helperText('Base price per kilogram'),
Forms\\Components\\TextInput::make('express_multiplier')
->required()
->numeric()
->minValue(1)
->maxValue(5)
->step(0.1)
->default(1.5)
->helperText('Multiplier for express service pricing'),
Forms\\Components\\TextInput::make('standard_duration_hours')
->required()
->numeric()
->minValue(1)
->maxValue(168)
->default(24)
->suffix('hours')
->helperText('Standard completion time'),
Forms\\Components\\TextInput::make('express_duration_hours')
->required()
->numeric()
->minValue(1)
->maxValue(72)
->default(6)
->suffix('hours')
->helperText('Express completion time'),
])
->columns(2),
Forms\\Components\\Section::make('Branch Availability')
->schema([
Forms\\Components\\CheckboxList::make('available_branches')
->options(fn () => Branch::where('is_active', true)->pluck('name', 'id'))
->columns(3)
->helperText('Select branches where this service is available. Leave empty for all branches.'),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('name')
->searchable()
->sortable()
->weight('medium'),
Tables\\Columns\\TextColumn::make('code')
->badge()
->color('gray')
->searchable(),
Tables\\Columns\\TextColumn::make('price_per_kg')
->money('IDR')
->sortable()
->color('success'),
Tables\\Columns\\TextColumn::make('express_multiplier')
->suffix('x')
->color('warning'),
Tables\\Columns\\TextColumn::make('standard_duration_hours')
->suffix('h')
->label('Duration'),
Tables\\Columns\\IconColumn::make('is_active')
->boolean()
->trueColor('success')
->falseColor('danger'),
Tables\\Columns\\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\TernaryFilter::make('is_active')
->placeholder('All services')
->trueLabel('Active only')
->falseLabel('Inactive only'),
Filter::make('price_range')
->form([
Forms\\Components\\TextInput::make('price_from')
->numeric()
->prefix('IDR'),
Forms\\Components\\TextInput::make('price_to')
->numeric()
->prefix('IDR'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['price_from'],
fn (Builder $query, $price): Builder => $query->where('price_per_kg', '>=', $price),
)
->when(
$data['price_to'],
fn (Builder $query, $price): Builder => $query->where('price_per_kg', '<=', $price),
);
}),
])
->actions([
Tables\\Actions\\Action::make('toggle_status')
->icon(fn (LaundryService $record) => $record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play')
->color(fn (LaundryService $record) => $record->is_active ? 'warning' : 'success')
->action(fn (LaundryService $record) => $record->update(['is_active' => !$record->is_active]))
->requiresConfirmation(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
Tables\\Actions\\BulkAction::make('activate')
->icon('heroicon-o-check-circle')
->color('success')
->action(fn ($records) => $records->each->update(['is_active' => true]))
->deselectRecordsAfterCompletion(),
Tables\\Actions\\BulkAction::make('deactivate')
->icon('heroicon-o-x-circle')
->color('danger')
->action(fn ($records) => $records->each->update(['is_active' => false]))
->deselectRecordsAfterCompletion(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListLaundryServices::route('/'),
'create' => Pages\\CreateLaundryService::route('/create'),
'edit' => Pages\\EditLaundryService::route('/{record}/edit'),
];
}
}
Creating Employee Resource
Next, kita buat Employee resource untuk manage staff. Create model dan migration:
php artisan make:model Employee -m
Migration file:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('employees', function (Blueprint $table) {
$table->id();
$table->string('employee_id')->unique();
$table->string('name');
$table->string('email')->unique()->nullable();
$table->string('phone');
$table->text('address');
$table->enum('position', ['manager', 'supervisor', 'operator', 'driver', 'customer_service']);
$table->foreignId('branch_id')->constrained()->cascadeOnDelete();
$table->decimal('basic_salary', 10, 2);
$table->decimal('commission_rate', 5, 2)->default(0);
$table->date('hire_date');
$table->date('termination_date')->nullable();
$table->enum('status', ['active', 'inactive', 'terminated']);
$table->json('work_schedule')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('employees');
}
};
Create Filament Resource:
php artisan make:filament-resource Employee
Edit EmployeeResource:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\EmployeeResource\\Pages;
use App\\Models\\Employee;
use App\\Models\\Branch;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class EmployeeResource extends Resource
{
protected static ?string $model = Employee::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationGroup = 'HR Management';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Personal Information')
->schema([
Forms\\Components\\TextInput::make('employee_id')
->required()
->unique(ignoreRecord: true)
->maxLength(20)
->default(fn () => 'EMP' . str_pad(random_int(1, 9999), 4, '0', STR_PAD_LEFT)),
Forms\\Components\\TextInput::make('name')
->required()
->maxLength(255),
Forms\\Components\\TextInput::make('email')
->email()
->unique(ignoreRecord: true)
->maxLength(255),
Forms\\Components\\TextInput::make('phone')
->tel()
->required()
->maxLength(20),
Forms\\Components\\Textarea::make('address')
->required()
->rows(3),
])
->columns(2),
Forms\\Components\\Section::make('Employment Details')
->schema([
Forms\\Components\\Select::make('branch_id')
->relationship('branch', 'name')
->required()
->searchable()
->preload(),
Forms\\Components\\Select::make('position')
->options([
'manager' => 'Manager',
'supervisor' => 'Supervisor',
'operator' => 'Operator',
'driver' => 'Driver',
'customer_service' => 'Customer Service',
])
->required(),
Forms\\Components\\DatePicker::make('hire_date')
->required()
->default(now()),
Forms\\Components\\DatePicker::make('termination_date')
->nullable(),
Forms\\Components\\Select::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
'terminated' => 'Terminated',
])
->required()
->default('active'),
])
->columns(3),
Forms\\Components\\Section::make('Compensation')
->schema([
Forms\\Components\\TextInput::make('basic_salary')
->required()
->numeric()
->prefix('IDR')
->minValue(0),
Forms\\Components\\TextInput::make('commission_rate')
->numeric()
->suffix('%')
->minValue(0)
->maxValue(100)
->default(0)
->helperText('Commission percentage on orders'),
])
->columns(2),
Forms\\Components\\Section::make('Work Schedule')
->schema([
Forms\\Components\\Repeater::make('work_schedule')
->schema([
Forms\\Components\\Select::make('day')
->options([
'monday' => 'Monday',
'tuesday' => 'Tuesday',
'wednesday' => 'Wednesday',
'thursday' => 'Thursday',
'friday' => 'Friday',
'saturday' => 'Saturday',
'sunday' => 'Sunday',
])
->required(),
Forms\\Components\\TimePicker::make('start_time')
->required(),
Forms\\Components\\TimePicker::make('end_time')
->required(),
])
->columns(3)
->defaultItems(0)
->collapsible(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('employee_id')
->badge()
->searchable(),
Tables\\Columns\\TextColumn::make('name')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('position')
->badge()
->color(fn (string $state): string => match ($state) {
'manager' => 'success',
'supervisor' => 'warning',
'operator' => 'info',
'driver' => 'gray',
'customer_service' => 'primary',
}),
Tables\\Columns\\TextColumn::make('branch.name')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('basic_salary')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'active' => 'success',
'inactive' => 'warning',
'terminated' => 'danger',
}),
Tables\\Columns\\TextColumn::make('hire_date')
->date()
->sortable(),
])
->filters([
Tables\\Filters\\SelectFilter::make('position')
->options([
'manager' => 'Manager',
'supervisor' => 'Supervisor',
'operator' => 'Operator',
'driver' => 'Driver',
'customer_service' => 'Customer Service',
]),
Tables\\Filters\\SelectFilter::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
'terminated' => 'Terminated',
]),
Tables\\Filters\\SelectFilter::make('branch')
->relationship('branch', 'name')
->searchable()
->preload(),
])
->actions([
Tables\\Actions\\Action::make('change_status')
->icon('heroicon-o-arrow-path')
->form([
Forms\\Components\\Select::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
'terminated' => 'Terminated',
])
->required(),
Forms\\Components\\DatePicker::make('termination_date')
->visible(fn (Forms\\Get $get) => $get('status') === 'terminated'),
])
->action(function (Employee $record, array $data) {
$record->update([
'status' => $data['status'],
'termination_date' => $data['status'] === 'terminated' ? $data['termination_date'] : null,
]);
}),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListEmployees::route('/'),
'create' => Pages\\CreateEmployee::route('/create'),
'edit' => Pages\\EditEmployee::route('/{record}/edit'),
];
}
}
Creating Order Resource dengan Advanced Features
Sekarang kita create OrderResource yang complex dengan status management. First, update LaundryOrder model dengan relationships:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class LaundryOrder extends Model
{
use HasFactory;
protected $fillable = [
'order_number',
'customer_id',
'branch_id',
'service_type',
'weight',
'price_per_kg',
'total_amount',
'status',
'pickup_date',
'delivery_date',
'special_instructions',
'items_detail',
];
protected $casts = [
'pickup_date' => 'datetime',
'delivery_date' => 'datetime',
'items_detail' => 'array',
'weight' => 'decimal:2',
'price_per_kg' => 'decimal:2',
'total_amount' => 'decimal:2',
];
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class);
}
public function laundryService(): BelongsTo
{
return $this->belongsTo(LaundryService::class, 'service_type', 'code');
}
}
Create OrderResource:
php artisan make:filament-resource LaundryOrder --generate
Edit LaundryOrderResource:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\LaundryOrderResource\\Pages;
use App\\Models\\LaundryOrder;
use App\\Models\\Customer;
use App\\Models\\Branch;
use App\\Models\\LaundryService;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Notifications\\Notification;
class LaundryOrderResource extends Resource
{
protected static ?string $model = LaundryOrder::class;
protected static ?string $navigationIcon = 'heroicon-o-shopping-bag';
protected static ?string $navigationGroup = 'Order Management';
protected static ?string $navigationLabel = 'Orders';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Order Information')
->schema([
Forms\\Components\\TextInput::make('order_number')
->required()
->unique(ignoreRecord: true)
->default(fn () => 'ORD-' . date('Ymd') . '-' . str_pad(random_int(1, 9999), 4, '0', STR_PAD_LEFT))
->maxLength(50),
Forms\\Components\\Select::make('customer_id')
->relationship('customer', 'name')
->searchable()
->preload()
->required()
->createOptionForm([
Forms\\Components\\TextInput::make('name')->required(),
Forms\\Components\\TextInput::make('phone')->required(),
Forms\\Components\\TextInput::make('email')->email(),
Forms\\Components\\Textarea::make('address')->required(),
]),
Forms\\Components\\Select::make('branch_id')
->relationship('branch', 'name')
->required()
->searchable()
->preload(),
Forms\\Components\\Select::make('status')
->options([
'pending' => 'Pending',
'processing' => 'Processing',
'ready' => 'Ready',
'delivered' => 'Delivered',
'cancelled' => 'Cancelled',
])
->required()
->default('pending'),
])
->columns(2),
Forms\\Components\\Section::make('Service Details')
->schema([
Forms\\Components\\Select::make('service_type')
->options(fn () => LaundryService::where('is_active', true)->pluck('name', 'code'))
->required()
->reactive()
->afterStateUpdated(function ($state, callable $set) {
if ($state) {
$service = LaundryService::where('code', $state)->first();
if ($service) {
$set('price_per_kg', $service->price_per_kg);
}
}
}),
Forms\\Components\\TextInput::make('weight')
->required()
->numeric()
->suffix('kg')
->minValue(0.1)
->step(0.1)
->reactive()
->afterStateUpdated(function ($state, callable $set, callable $get) {
$weight = floatval($state);
$pricePerKg = floatval($get('price_per_kg'));
$set('total_amount', $weight * $pricePerKg);
}),
Forms\\Components\\TextInput::make('price_per_kg')
->required()
->numeric()
->prefix('IDR')
->reactive()
->afterStateUpdated(function ($state, callable $set, callable $get) {
$weight = floatval($get('weight'));
$pricePerKg = floatval($state);
$set('total_amount', $weight * $pricePerKg);
}),
Forms\\Components\\TextInput::make('total_amount')
->required()
->numeric()
->prefix('IDR')
->readOnly(),
])
->columns(2),
Forms\\Components\\Section::make('Schedule & Notes')
->schema([
Forms\\Components\\DateTimePicker::make('pickup_date')
->required()
->default(now()),
Forms\\Components\\DateTimePicker::make('delivery_date')
->nullable(),
Forms\\Components\\Textarea::make('special_instructions')
->rows(3)
->maxLength(500),
Forms\\Components\\Repeater::make('items_detail')
->schema([
Forms\\Components\\TextInput::make('item_type')
->required()
->placeholder('e.g., Shirt, Pants, Dress'),
Forms\\Components\\TextInput::make('quantity')
->required()
->numeric()
->minValue(1),
Forms\\Components\\TextInput::make('condition')
->placeholder('e.g., Stained, Normal, Torn'),
])
->columns(3)
->defaultItems(1)
->collapsible(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('order_number')
->searchable()
->sortable()
->copyable(),
Tables\\Columns\\TextColumn::make('customer.name')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('branch.name')
->searchable(),
Tables\\Columns\\TextColumn::make('service_type')
->badge(),
Tables\\Columns\\TextColumn::make('weight')
->suffix(' kg')
->sortable(),
Tables\\Columns\\TextColumn::make('total_amount')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'pending' => 'warning',
'processing' => 'info',
'ready' => 'success',
'delivered' => 'primary',
'cancelled' => 'danger',
}),
Tables\\Columns\\TextColumn::make('pickup_date')
->dateTime()
->sortable(),
Tables\\Columns\\TextColumn::make('delivery_date')
->dateTime()
->sortable()
->placeholder('Not set'),
])
->filters([
Tables\\Filters\\SelectFilter::make('status')
->options([
'pending' => 'Pending',
'processing' => 'Processing',
'ready' => 'Ready',
'delivered' => 'Delivered',
'cancelled' => 'Cancelled',
]),
Tables\\Filters\\SelectFilter::make('branch')
->relationship('branch', 'name'),
Tables\\Filters\\Filter::make('pickup_date')
->form([
Forms\\Components\\DatePicker::make('pickup_from'),
Forms\\Components\\DatePicker::make('pickup_until'),
])
->query(function ($query, array $data) {
return $query
->when($data['pickup_from'], fn ($q) => $q->whereDate('pickup_date', '>=', $data['pickup_from']))
->when($data['pickup_until'], fn ($q) => $q->whereDate('pickup_date', '<=', $data['pickup_until']));
}),
])
->actions([
Tables\\Actions\\Action::make('update_status')
->icon('heroicon-o-arrow-path')
->color('primary')
->form([
Forms\\Components\\Select::make('status')
->options([
'pending' => 'Pending',
'processing' => 'Processing',
'ready' => 'Ready',
'delivered' => 'Delivered',
'cancelled' => 'Cancelled',
])
->required(),
Forms\\Components\\DateTimePicker::make('delivery_date')
->visible(fn (Forms\\Get $get) => in_array($get('status'), ['ready', 'delivered'])),
])
->action(function (LaundryOrder $record, array $data) {
$record->update([
'status' => $data['status'],
'delivery_date' => $data['delivery_date'] ?? $record->delivery_date,
]);
Notification::make()
->title('Status Updated')
->body("Order {$record->order_number} status changed to {$data['status']}")
->success()
->send();
}),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
Tables\\Actions\\BulkAction::make('mark_as_processing')
->icon('heroicon-o-cog-6-tooth')
->color('info')
->action(fn ($records) => $records->each->update(['status' => 'processing']))
->deselectRecordsAfterCompletion(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListLaundryOrders::route('/'),
'create' => Pages\\CreateLaundryOrder::route('/create'),
'edit' => Pages\\EditLaundryOrder::route('/{record}/edit'),
];
}
}
Creating SaaS Plan dan Subscription Resources
Sekarang kita create resources untuk SaaS functionality. Create Plan model:
php artisan make:model Plan -m
Migration:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description');
$table->decimal('monthly_price', 10, 2);
$table->decimal('yearly_price', 10, 2);
$table->integer('max_branches');
$table->integer('max_employees');
$table->integer('max_orders_per_month');
$table->json('features');
$table->boolean('is_popular')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('plans');
}
};
Create PlanResource:
php artisan make:filament-resource Plan
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\PlanResource\\Pages;
use App\\Models\\Plan;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class PlanResource extends Resource
{
protected static ?string $model = Plan::class;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $navigationGroup = 'SaaS Management';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Plan Details')
->schema([
Forms\\Components\\TextInput::make('name')
->required()
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) =>
$set('slug', str($state)->slug())
),
Forms\\Components\\TextInput::make('slug')
->required()
->unique(ignoreRecord: true),
Forms\\Components\\Textarea::make('description')
->required()
->rows(3),
Forms\\Components\\Toggle::make('is_popular')
->helperText('Mark as popular plan (featured in pricing)'),
Forms\\Components\\Toggle::make('is_active')
->default(true),
])
->columns(2),
Forms\\Components\\Section::make('Pricing')
->schema([
Forms\\Components\\TextInput::make('monthly_price')
->required()
->numeric()
->prefix('IDR'),
Forms\\Components\\TextInput::make('yearly_price')
->required()
->numeric()
->prefix('IDR')
->helperText('Usually 10-20% discount from 12x monthly price'),
])
->columns(2),
Forms\\Components\\Section::make('Limits')
->schema([
Forms\\Components\\TextInput::make('max_branches')
->required()
->numeric()
->minValue(1),
Forms\\Components\\TextInput::make('max_employees')
->required()
->numeric()
->minValue(1),
Forms\\Components\\TextInput::make('max_orders_per_month')
->required()
->numeric()
->minValue(1),
])
->columns(3),
Forms\\Components\\Section::make('Features')
->schema([
Forms\\Components\\CheckboxList::make('features')
->options([
'advanced_reporting' => 'Advanced Reporting',
'api_access' => 'API Access',
'white_labeling' => 'White Labeling',
'priority_support' => 'Priority Support',
'custom_integrations' => 'Custom Integrations',
'multi_currency' => 'Multi Currency',
'inventory_management' => 'Inventory Management',
'loyalty_program' => 'Loyalty Program',
])
->columns(2),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('name')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('monthly_price')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('yearly_price')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('max_branches')
->suffix(' branches'),
Tables\\Columns\\IconColumn::make('is_popular')
->boolean()
->trueIcon('heroicon-o-star')
->falseIcon('heroicon-o-star')
->trueColor('warning'),
Tables\\Columns\\IconColumn::make('is_active')
->boolean(),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\\ListPlans::route('/'),
'create' => Pages\\CreatePlan::route('/create'),
'edit' => Pages\\EditPlan::route('/{record}/edit'),
];
}
}
Dengan setup resources yang comprehensive ini, admin dashboard kita sudah punya foundation yang solid untuk manage semua aspects dari SaaS Laundry business. Setiap resource dilengkapi dengan advanced filtering, custom actions, dan user experience yang intuitive untuk daily operations.
Sampai sini apakah project teman-teman masih aman? Atau ada kendala?
Komen ya jika ada kendala di project teman-teman.
Jika sudah lancar semua, yuk lanjut ke BAGIAN 4





