Membuat Website Laundry Management System SaaS dengan Laravel 12 – Bagian 3

Membuat Website Laundry System SaaS dengan Laravel 12 Bagian 3

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.

Baca Juga  Cara Menggunakan Kode Promo HostNesia untuk Hemat Layanan Hosting

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

HostNesia

🚨 PENGUMUMAN PENTING

PENGALIHAN MANAJEMEN & REKENING PEMBAYARAN

Yth. Seluruh Klien HostNesia,

Sejalan dengan peningkatan skala infrastruktur server ke HostNesia 4.0, kami menginformasikan bahwa per bulan ini, seluruh operasional, layanan, dan manajemen penagihan HostNesia telah resmi beralih di bawah naungan entitas hukum yang baru, yakni PT Deta Digdaya Digital.

Sehubungan dengan restrukturisasi manajemen tersebut, mohon perhatikan kebijakan transaksi berikut:

  • Penutupan Rekening Lama: Seluruh pembayaran tagihan invoice tidak lagi menggunakan rekening atas nama CV sebelumnya. Transaksi ke rekening CV tersebut sudah tidak dapat diproses atau diakui oleh sistem kami.

  • Pembaruan Rekening: Seluruh pembayaran wajib ditujukan ke rekening perusahaan yang baru atas nama PT Deta Digdaya Digital sesuai dengan instruksi pada invoice.

  • Sistem Satu Pintu (Otomatisasi): Manajemen baru menerapkan kebijakan otomatisasi penuh. Aktivasi dan perpanjangan layanan tidak dapat diproses secara manual tanpa adanya pelunasan invoice di portal klien.

Kami menyarankan pada klien korporat untuk menggunakan fitur Deposit Saldo (HostPay) untuk kemudahan upgrade layanan Anda. Terima kasih atas dukungan Anda selama masa transisi ini.

GROWTH