Ini adalah website personal Selamat M. Harjono

Kata-Kata Hari Ini

"ALAH BISA KARENA BIASA"

209
Des 17
Bootcamp HMMI dengan Tema "Personal Blog & Portfolio Management System" - Fase III

Bootcamp HMMI dengan Tema "Personal Blog & Portfolio Management System" - Fase III

Rabu, 17 Desember 2025 | oleh Selamat Muliyadi Harjono | Materi


FASE 3 – FILAMENTPHP & ADMIN PANEL (Pertemuan 10–12)

🔹 STRATEGI PEMBELAJARAN FASE 3

  • "Admin Panel First": Bangun admin panel sebelum frontend refinement
  • "Rapid Development": Manfaatkan generator Filament untuk produktivitas
  • "Customization Pro": Dari default ke custom sesuai kebutuhan
  • "Database Integration": Hubungkan Filament dengan struktur database yang sudah ada

📚 PERTEMUAN 10 – FILAMENTPHP FUNDAMENTAL

Durasi: 3 jam (Teori: 1 jam, Praktik: 2 jam)

🎯 TUJUAN PEMBELAJARAN

  1. Memahami konsep dan keuntungan FilamentPHP
  2. Mampu setup panel admin dengan Filament
  3. Membuat CRUD resources otomatis
  4. Implementasi role-based access control

📖 MATERI DETAIL


A. Opening & Review Fase 2 (30 menit)

  • Demo aplikasi blog dari Fase 2
  • Diskusi pain points dalam manajemen konten
  • Introduction: "Bagaimana jika kita punya admin panel seperti WordPress?"


B. Kenapa FilamentPHP? (30 menit)

Perbandingan Admin Panel Solutions:

SolutionKelebihanKekuranganCocok untukFilamentPHP | Built on Laravel, Rapid development, Beautiful UI, Extensible | Learning curve, Less documentation | Laravel projects, Internal tools
Laravel Nova | Official, Feature-rich, Great support | Expensive ($199/site), Less customizable | Enterprise, Budget available
Backpack for Laravel | Easy for beginners, Many addons | Performance issues, Complex setup | Quick prototypes, Small projects
Custom Build | Full control, Tailored to needs | Time-consuming, Maintenance heavy | Unique requirements, Large teams
WordPress | User-friendly, Huge ecosystem | Not Laravel, Performance issues | Non-technical users, Content-focused

Keunggulan Filament untuk Blog System:

  1. Developer Experience: Sintaks deklaratif, cepat development
  2. TALL Stack: Tailwind, Alpine, Livewire, Laravel - stack yang konsisten
  3. Customizable: Dari basic CRUD ke complex workflows
  4. Community: Growing fast, banyak packages
  5. Cost: Open source dan gratis

Arsitektur Filament:

text

Laravel Application
├── Filament Panel (Admin Area)
│   ├── Resources (CRUD interfaces)
│   ├── Pages (Custom pages)
│   ├── Widgets (Dashboard components)
│   └── Plugins (Extensions)
└── Public Frontend (Blog untuk visitor)




C. Instalasi & Setup (45 menit - PRAKTIKUM)

Langkah 1: Install Filament:

bash

# Di project Laravel yang sudah ada
composer require filament/filament:"^3.0" -W

# Publish assets
php artisan filament:install --panels

# Install plugin untuk roles/permissions (opsional tapi recommended)
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate



Langkah 2: Setup User Model:

php

// app/Models/User.php
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable implements FilamentUser
{
    use HasFactory, Notifiable, HasRoles;
    
    /**
     * Determine if the user can access the Filament admin panel.
     */
    public function canAccessPanel(Panel $panel): bool
    {
        // Hanya user dengan role admin atau author yang bisa akses
        return $this->hasAnyRole(['admin', 'author']);
    }
    
    // Tambahkan ke $fillable
    protected $fillable = [
        'name',
        'email',
        'password',
        'role',
        'bio',
        'avatar',
    ];
}



Langkah 3: Konfigurasi Panel:

bash

# Buat provider untuk panel
php artisan make:provider/Filament/AdminPanelProvider



php

<?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::Indigo,
                'danger' => Color::Rose,
                'success' => Color::Emerald,
                'warning' => Color::Orange,
            ])
            ->font('Inter')
            ->favicon(asset('images/favicon.ico'))
            ->brandLogo(asset('images/logo.svg'))
            ->brandLogoHeight('2rem')
            ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
            ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
            ->pages([
                Pages\Dashboard::class,
            ])
            ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
            ->widgets([
                Widgets\AccountWidget::class,
                Widgets\FilamentInfoWidget::class,
            ])
            ->middleware([
                EncryptCookies::class,
                AddQueuedCookiesToResponse::class,
                StartSession::class,
                AuthenticateSession::class,
                ShareErrorsFromSession::class,
                VerifyCsrfToken::class,
                SubstituteBindings::class,
                DisableBladeIconComponents::class,
                DispatchServingFilamentEvent::class,
            ])
            ->authMiddleware([
                Authenticate::class,
            ])
            ->plugins([
                \BezhanSalleh\FilamentShield\FilamentShieldPlugin::make(),
            ]);
    }
}



Langkah 4: Register Provider:

php

// config/app.php
'providers' => [
    // ...
    App\Providers\Filament\AdminPanelProvider::class,
],



Langkah 5: Create Admin User:

bash

# Buat user admin via tinker
php artisan tinker

>>> $user = App\Models\User::create([
...     'name' => 'Admin',
...     'email' => 'admin@blog.com',
...     'password' => bcrypt('password123'),
...     'role' => 'admin'
... ]);
>>> $user->assignRole('admin');



Langkah 6: Test Panel:

bash

php artisan serve
# Buka http://localhost:8000/admin
# Login dengan admin@blog.com / password123




D. Struktur Panel Filament (30 menit)

Directory Structure:

text

app/
├── Filament/
│   ├── Resources/           # CRUD resources
│   │   ├── PostResource.php
│   │   ├── CategoryResource.php
│   │   └── ...
│   ├── Pages/              # Custom pages
│   │   ├── Dashboard.php
│   │   ├── Settings.php
│   │   └── ...
│   └── Widgets/            # Dashboard widgets
│       ├── BlogStats.php
│       ├── RecentPosts.php
│       └── ...



Navigation Structure:

php

// Di dalam resource atau panel config
public static function getNavigationItems(): array
{
    return [
        NavigationItem::make('Blog')
            ->icon('heroicon-o-book-open')
            ->sort(1)
            ->children([
                NavigationItem::make('Posts')
                    ->url(route('filament.admin.resources.posts.index'))
                    ->icon('heroicon-o-document-text')
                    ->sort(1),
                NavigationItem::make('Categories')
                    ->url(route('filament.admin.resources.categories.index'))
                    ->icon('heroicon-o-tag')
                    ->sort(2),
                NavigationItem::make('Tags')
                    ->url(route('filament.admin.resources.tags.index'))
                    ->icon('heroicon-o-hashtag')
                    ->sort(3),
                NavigationItem::make('Comments')
                    ->url(route('filament.admin.resources.comments.index'))
                    ->icon('heroicon-o-chat-bubble-left-ellipsis')
                    ->sort(4),
            ]),
        NavigationItem::make('Portfolio')
            ->icon('heroicon-o-briefcase')
            ->sort(2)
            ->url(route('filament.admin.resources.portfolios.index')),
        NavigationItem::make('Users')
            ->icon('heroicon-o-users')
            ->sort(3)
            ->url(route('filament.admin.resources.users.index')),
    ];
}




E. Resource & CRUD Otomatis (60 menit - PRAKTIKUM)

1. Membuat Resource untuk Post:

bash

php artisan make:filament-resource Post --generate
# Flag --generate akan membuat semua file: Resource, Pages, Relation Managers



2. Post Resource Structure:

php

<?php
// app/Filament/Resources/PostResource.php

namespace App\Filament\Resources;

use App\Filament\Resources\PostResource\Pages;
use App\Filament\Resources\PostResource\RelationManagers;
use App\Models\Post;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;

class PostResource extends Resource
{
    protected static ?string $model = Post::class;

    protected static ?string $navigationIcon = 'heroicon-o-document-text';
    
    protected static ?string $navigationGroup = 'Blog';
    
    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\Section::make('Post Content')
                    ->schema([
                        Forms\Components\TextInput::make('title')
                            ->required()
                            ->maxLength(200)
                            ->live(onBlur: true)
                            ->afterStateUpdated(function ($state, $set) {
                                if (empty($state)) return;
                                $set('slug', \Str::slug($state));
                            }),
                        
                        Forms\Components\TextInput::make('slug')
                            ->required()
                            ->maxLength(220)
                            ->unique(ignorable: fn ($record) => $record),
                        
                        Forms\Components\Select::make('user_id')
                            ->relationship('user', 'name')
                            ->default(auth()->id())
                            ->required()
                            ->searchable()
                            ->preload(),
                        
                        Forms\Components\Select::make('category_id')
                            ->relationship('category', 'name')
                            ->searchable()
                            ->preload()
                            ->createOptionForm([
                                Forms\Components\TextInput::make('name')
                                    ->required()
                                    ->maxLength(50),
                                Forms\Components\TextInput::make('slug')
                                    ->required()
                                    ->maxLength(60),
                            ]),
                        
                        Forms\Components\RichEditor::make('content')
                            ->required()
                            ->columnSpanFull()
                            ->fileAttachmentsDisk('public')
                            ->fileAttachmentsDirectory('posts')
                            ->fileAttachmentsVisibility('public'),
                        
                        Forms\Components\Textarea::make('excerpt')
                            ->rows(3)
                            ->maxLength(500),
                    ])
                    ->columns(2),
                
                Forms\Components\Section::make('Featured Image')
                    ->schema([
                        Forms\Components\FileUpload::make('featured_image')
                            ->disk('public')
                            ->directory('posts')
                            ->image()
                            ->maxSize(5120)
                            ->imageResizeMode('cover')
                            ->imageCropAspectRatio('16:9')
                            ->imageResizeTargetWidth('1200')
                            ->imageResizeTargetHeight('630'),
                    ])
                    ->collapsible(),
                
                Forms\Components\Section::make('Publishing')
                    ->schema([
                        Forms\Components\Select::make('status')
                            ->options([
                                'draft' => 'Draft',
                                'published' => 'Published',
                                'archived' => 'Archived',
                            ])
                            ->default('draft')
                            ->required(),
                        
                        Forms\Components\DateTimePicker::make('published_at')
                            ->hidden(fn ($get) => $get('status') !== 'published')
                            ->required(fn ($get) => $get('status') === 'published'),
                        
                        Forms\Components\Toggle::make('is_featured')
                            ->label('Featured Post')
                            ->default(false),
                    ])
                    ->columns(2),
                
                Forms\Components\Section::make('SEO Settings')
                    ->schema([
                        Forms\Components\TextInput::make('meta_title')
                            ->maxLength(200),
                        
                        Forms\Components\Textarea::make('meta_description')
                            ->rows(2)
                            ->maxLength(500),
                    ])
                    ->collapsible(),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\ImageColumn::make('featured_image')
                    ->circular()
                    ->defaultImageUrl(url('/images/default-post.jpg')),
                
                Tables\Columns\TextColumn::make('title')
                    ->searchable()
                    ->sortable()
                    ->limit(50)
                    ->tooltip(fn ($record) => $record->title),
                
                Tables\Columns\TextColumn::make('user.name')
                    ->label('Author')
                    ->sortable()
                    ->searchable(),
                
                Tables\Columns\TextColumn::make('category.name')
                    ->sortable()
                    ->searchable()
                    ->badge()
                    ->color('info'),
                
                Tables\Columns\SelectColumn::make('status')
                    ->options([
                        'draft' => 'Draft',
                        'published' => 'Published',
                        'archived' => 'Archived',
                    ])
                    ->selectablePlaceholder(false)
                    ->sortable(),
                
                Tables\Columns\TextColumn::make('published_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: false),
                
                Tables\Columns\TextColumn::make('view_count')
                    ->numeric()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
                
                Tables\Columns\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\Filters\SelectFilter::make('status')
                    ->options([
                        'draft' => 'Draft',
                        'published' => 'Published',
                        'archived' => 'Archived',
                    ]),
                
                Tables\Filters\SelectFilter::make('category')
                    ->relationship('category', 'name')
                    ->searchable()
                    ->preload(),
                
                Tables\Filters\SelectFilter::make('author')
                    ->relationship('user', 'name')
                    ->searchable()
                    ->preload(),
                
                Tables\Filters\Filter::make('published_at')
                    ->form([
                        Forms\Components\DatePicker::make('published_from'),
                        Forms\Components\DatePicker::make('published_until'),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when(
                                $data['published_from'],
                                fn (Builder $query, $date): Builder => $query->whereDate('published_at', '>=', $date),
                            )
                            ->when(
                                $data['published_until'],
                                fn (Builder $query, $date): Builder => $query->whereDate('published_at', '<=', $date),
                            );
                    }),
                
                Tables\Filters\TrashedFilter::make(),
            ])
            ->actions([
                Tables\Actions\ViewAction::make(),
                Tables\Actions\EditAction::make(),
                Tables\Actions\Action::make('publish')
                    ->action(function (Post $record) {
                        $record->update([
                            'status' => 'published',
                            'published_at' => now(),
                        ]);
                    })
                    ->requiresConfirmation()
                    ->modalHeading('Publish Post')
                    ->modalDescription('Are you sure you want to publish this post?')
                    ->modalSubmitActionLabel('Yes, publish it')
                    ->color('success')
                    ->icon('heroicon-o-check-circle')
                    ->hidden(fn (Post $record): bool => $record->status === 'published'),
                
                Tables\Actions\DeleteAction::make(),
                Tables\Actions\ForceDeleteAction::make(),
                Tables\Actions\RestoreAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DeleteBulkAction::make(),
                    Tables\Actions\ForceDeleteBulkAction::make(),
                    Tables\Actions\RestoreBulkAction::make(),
                    Tables\Actions\BulkAction::make('publish')
                        ->action(function ($records) {
                            $records->each->update([
                                'status' => 'published',
                                'published_at' => now(),
                            ]);
                        })
                        ->requiresConfirmation()
                        ->deselectRecordsAfterCompletion()
                        ->color('success')
                        ->icon('heroicon-o-check-circle'),
                ]),
            ])
            ->defaultSort('published_at', 'desc')
            ->persistSortInSession()
            ->persistFiltersInSession();
    }

    public static function getRelations(): array
    {
        return [
            RelationManagers\TagsRelationManager::class,
            RelationManagers\CommentsRelationManager::class,
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\ListPosts::route('/'),
            'create' => Pages\CreatePost::route('/create'),
            'view' => Pages\ViewPost::route('/{record}'),
            'edit' => Pages\EditPost::route('/{record}/edit'),
        ];
    }
    
    public static function getEloquentQuery(): Builder
    {
        return parent::getEloquentQuery()
            ->withoutGlobalScopes([
                SoftDeletingScope::class,
            ]);
    }
    
    public static function getGloballySearchableAttributes(): array
    {
        return ['title', 'content', 'slug'];
    }
}



3. Generate Resources Lainnya:

bash

# Generate semua resources sekaligus
php artisan make:filament-resource Category --generate
php artisan make:filament-resource Tag --generate
php artisan make:filament-resource Comment --generate
php artisan make:filament-resource Portfolio --generate
php artisan make:filament-resource User --generate




F. Role & Permission dengan Filament Shield (45 menit)

1. Install & Configure Shield:

bash

# Sudah diinstall sebelumnya, sekarang setup
php artisan shield:install --fresh

# Generate permissions untuk resources
php artisan shield:generate



2. Custom Permission untuk Blog:

php

// app/Providers/Filament/AdminPanelProvider.php
->plugins([
    \BezhanSalleh\FilamentShield\FilamentShieldPlugin::make()
        ->gridColumns([
            'default' => 1,
            'sm' => 2,
            'lg' => 3
        ])
        ->sectionColumnSpan(1)
        ->checkboxListColumns([
            'default' => 1,
            'sm' => 2,
            'lg' => 3,
        ])
        ->resourceCheckboxListColumns([
            'default' => 1,
            'sm' => 2,
        ]),
])



3. Role-based Access di Resource:

php

// app/Filament/Resources/PostResource.php
class PostResource extends Resource
{
    // ...
    
    public static function canViewAny(): bool
    {
        return auth()->user()->can('view_post');
    }
    
    public static function canCreate(): bool
    {
        return auth()->user()->can('create_post');
    }
    
    // Atau gunakan middleware di panel
    public static function getNavigationItems(): array
    {
        return [
            NavigationItem::make('Posts')
                ->visible(fn (): bool => auth()->user()->can('view_post'))
                // ...
        ];
    }
}



4. Resource Permission Policies:

php

<?php
// app/Policies/PostPolicy.php
// Pastikan sudah dibuat di Fase 2

// app/Providers/AuthServiceProvider.php
protected $policies = [
    Post::class => PostPolicy::class,
    // ...
];



5. Custom Role untuk Author:

php

// Buat role 'author' dengan permissions terbatas
$authorPermissions = [
    'view_post',
    'create_post',
    'update_post', // hanya post milik sendiri
    'delete_post', // hanya post milik sendiri
    'view_category',
    'view_tag',
    'view_comment',
    'update_comment', // untuk moderate
];

$role = Role::create(['name' => 'author']);
$role->givePermissionTo($authorPermissions);

// Assign ke user
$user->assignRole('author');




G. Praktikum: Setup Admin Panel Lengkap (60 menit)

Tugas: Setup Filament panel dengan semua resources

Langkah-langkah:

  1. Install Filament dan Shield
  2. Generate semua resources (Post, Category, Tag, Comment, Portfolio, User)
  3. Customize setiap resource:
    • Form schema sesuai field database
    • Table columns yang relevan
    • Filters dan actions
  4. Setup permissions dengan Shield
  5. Test dengan dua user:
    • Admin (full access)
    • Author (limited access)
  6. Verify:
    • CRUD operations work
    • Permissions enforced
    • UI responsive

🎯 TUGAS PERTEMUAN 10

  1. Install FilamentPHP di project blog
  2. Buat semua resources (Post, Category, Tag, Comment, Portfolio, User)
  3. Implementasi role-based permissions dengan Filament Shield
  4. Customize navigation sidebar
  5. Test dengan minimal 3 user roles:
    • Admin (full access)
    • Author (can manage own posts, moderate comments)
    • Editor (can manage all posts but not users)
  6. Screenshot dan dokumentasi setup

📚 PERTEMUAN 11 – FILAMENT ADVANCED

Durasi: 3 jam (Teori: 1 jam, Praktik: 2 jam)

🎯 TUJUAN PEMBELAJARAN

  1. Menguasai form schema yang kompleks
  2. Customize table dengan advanced features
  3. Implement relation managers
  4. Buat custom actions dan widgets

📖 MATERI DETAIL


A. Review & Q&A (30 menit)

  • Diskusi challenges Filament setup
  • Best practices resource organization
  • Performance tips untuk large datasets


B. Advanced Form Schema (60 menit)

1. Conditional Fields & Logic:

php

Forms\Components\Section::make('Publishing Options')
    ->schema([
        Forms\Components\Toggle::make('is_scheduled')
            ->label('Schedule Publication')
            ->reactive(),
        
        Forms\Components\DateTimePicker::make('published_at')
            ->hidden(fn ($get) => !$get('is_scheduled'))
            ->required(fn ($get) => $get('is_scheduled'))
            ->helperText(fn ($get) => $get('is_scheduled') 
                ? 'Post will be published at this time' 
                : null),
        
        Forms\Components\Toggle::make('is_pinned')
            ->label('Pin to top')
            ->hidden(fn ($get) => $get('is_scheduled')),
    ]),



2. Wizard Form untuk Post Creation:

php

// app/Filament/Resources/PostResource/Pages/CreatePost.php
protected function getFormSchema(): array
{
    return [
        Forms\Components\Wizard::make([
            Forms\Components\Wizard\Step::make('Basic Info')
                ->icon('heroicon-o-information-circle')
                ->schema([
                    Forms\Components\TextInput::make('title')
                        ->required()
                        ->maxLength(200)
                        ->live(onBlur: true)
                        ->afterStateUpdated(function ($state, $set, $get) {
                            if (!$get('is_slug_customized')) {
                                $set('slug', \Str::slug($state));
                            }
                        }),
                    
                    Forms\Components\TextInput::make('slug')
                        ->required()
                        ->maxLength(220)
                        ->unique(ignorable: fn ($record) => $record)
                        ->suffixAction(
                            Forms\Components\Actions\Action::make('generate')
                                ->icon('heroicon-o-arrow-path')
                                ->action(function ($set, $get) {
                                    $set('slug', \Str::slug($get('title')));
                                })
                        ),
                    
                    Forms\Components\Toggle::make('is_slug_customized')
                        ->label('Customize slug manually')
                        ->default(false)
                        ->inline(false),
                ]),
            
            Forms\Components\Wizard\Step::make('Content')
                ->icon('heroicon-o-document-text')
                ->schema([
                    Forms\Components\Select::make('category_id')
                        ->relationship('category', 'name')
                        ->searchable()
                        ->preload()
                        ->createOptionForm([
                            Forms\Components\TextInput::make('name')
                                ->required()
                                ->maxLength(50),
                            Forms\Components\TextInput::make('slug')
                                ->required()
                                ->maxLength(60),
                        ]),
                    
                    Forms\Components\RichEditor::make('content')
                        ->required()
                        ->fileAttachmentsDisk('public')
                        ->fileAttachmentsDirectory('posts')
                        ->toolbarButtons([
                            'blockquote',
                            'bold',
                            'bulletList',
                            'codeBlock',
                            'h2',
                            'h3',
                            'italic',
                            'link',
                            'orderedList',
                            'redo',
                            'strike',
                            'undo',
                        ]),
                    
                    Forms\Components\Textarea::make('excerpt')
                        ->rows(3)
                        ->maxLength(500)
                        ->helperText('If empty, will be auto-generated from content'),
                ]),
            
            Forms\Components\Wizard\Step::make('Media & SEO')
                ->icon('heroicon-o-photo')
                ->schema([
                    Forms\Components\FileUpload::make('featured_image')
                        ->disk('public')
                        ->directory('posts')
                        ->image()
                        ->maxSize(5120)
                        ->imageEditor()
                        ->imageEditorAspectRatios([
                            null,
                            '16:9',
                            '4:3',
                            '1:1',
                        ])
                        ->panelAspectRatio('2:1'),
                    
                    Forms\Components\SpatieMediaLibraryFileUpload::make('gallery')
                        ->multiple()
                        ->disk('public')
                        ->directory('posts/gallery')
                        ->maxFiles(10)
                        ->image()
                        ->reorderable(),
                    
                    Forms\Components\Section::make('SEO Settings')
                        ->schema([
                            Forms\Components\TextInput::make('meta_title')
                                ->maxLength(200)
                                ->helperText('If empty, will use post title'),
                            
                            Forms\Components\Textarea::make('meta_description')
                                ->rows(2)
                                ->maxLength(500)
                                ->helperText('If empty, will be auto-generated from excerpt'),
                        ]),
                ]),
            
            Forms\Components\Wizard\Step::make('Publishing')
                ->icon('heroicon-o-check-circle')
                ->schema([
                    Forms\Components\Select::make('status')
                        ->options([
                            'draft' => 'Draft',
                            'published' => 'Published',
                            'archived' => 'Archived',
                        ])
                        ->default('draft')
                        ->required()
                        ->reactive(),
                    
                    Forms\Components\DateTimePicker::make('published_at')
                        ->hidden(fn ($get) => $get('status') !== 'published')
                        ->required(fn ($get) => $get('status') === 'published')
                        ->default(now()),
                    
                    Forms\Components\Toggle::make('is_featured')
                        ->label('Featured Post')
                        ->default(false),
                    
                    Forms\Components\Toggle::make('allow_comments')
                        ->label('Allow Comments')
                        ->default(true),
                    
                    Forms\Components\Toggle::make('is_pinned')
                        ->label('Pin to top of blog')
                        ->default(false),
                ]),
        ])
        ->submitAction(view('filament.components.custom-submit-button'))
        ->skippable()
        ->persistStepInQueryString(),
    ];
}



3. Custom Form Components:

php

// app/Forms/Components/ReadingTime.php
namespace App\Forms\Components;

use Filament\Forms\Components\Field;

class ReadingTime extends Field
{
    protected string $view = 'forms.components.reading-time';
    
    public function calculateReadingTime(string $content): int
    {
        $wordCount = str_word_count(strip_tags($content));
        return ceil($wordCount / 200); // 200 words per minute
    }
}

// resources/views/forms/components/reading-time.blade.php
<div>
    <label class="filament-forms-field-wrapper-label" for="{{ $getId() }}">
        {{ $getLabel() }}
    </label>
    
    <div class="p-3 bg-gray-50 rounded-lg">
        <div class="text-sm text-gray-600">
            Estimated reading time: 
            <span class="font-semibold">
                {{ $calculateReadingTime($getState()) }} minutes
            </span>
        </div>
    </div>
</div>

// Penggunaan di form
ReadingTime::make('reading_time')
    ->label('Reading Time Estimate')
    ->dehydrated(false) // Tidak disimpan ke database
    ->content(fn ($get) => $get('content')),



4. Repeater untuk Meta Fields:

php

Forms\Components\Repeater::make('meta_fields')
    ->schema([
        Forms\Components\TextInput::make('key')
            ->required()
            ->placeholder('e.g., og:image'),
        
        Forms\Components\TextInput::make('value')
            ->required()
            ->placeholder('e.g., https://example.com/image.jpg'),
    ])
    ->defaultItems(0)
    ->collapsible()
    ->itemLabel(fn (array $state): ?string => $state['key'] ?? null)
    ->columnSpanFull(),




C. Advanced Table Customization (45 menit)

1. Custom Table Columns:

php

Tables\Columns\TextColumn::make('title')
    ->searchable()
    ->sortable()
    ->limit(50)
    ->tooltip(fn ($record): string => $record->title)
    ->description(fn ($record): string => $record->excerpt ? Str::limit($record->excerpt, 100) : '')
    ->extraAttributes(['class' => 'font-medium'])
    ->url(fn ($record): string => route('posts.show', $record))
    ->openUrlInNewTab(),

Tables\Columns\IconColumn::make('is_featured')
    ->boolean()
    ->trueIcon('heroicon-o-star')
    ->falseIcon('heroicon-o-star')
    ->trueColor('warning')
    ->falseColor('gray')
    ->sortable(),

Tables\Columns\BadgeColumn::make('status')
    ->colors([
        'success' => 'published',
        'warning' => 'draft',
        'danger' => 'archived',
    ])
    ->icons([
        'heroicon-o-check-circle' => 'published',
        'heroicon-o-pencil' => 'draft',
        'heroicon-o-archive-box' => 'archived',
    ])
    ->sortable(),

Tables\Columns\ViewColumn::make('preview')
    ->view('tables.columns.post-preview')
    ->label('Preview'),

// resources/views/tables/columns/post-preview.blade.php
<div class="flex items-center space-x-3">
    <img 
        src="{{ $getRecord()->featured_image ? asset('storage/' . $getRecord()->featured_image) : '/images/default-post.jpg' }}" 
        alt="{{ $getRecord()->title }}" 
        class="w-12 h-12 rounded object-cover"
    >
    <div>
        <div class="font-medium text-sm">{{ Str::limit($getRecord()->title, 40) }}</div>
        <div class="text-xs text-gray-500">
            {{ $getRecord()->category->name ?? 'Uncategorized' }}
            • {{ $getRecord()->published_at?->format('M d, Y') ?? 'Draft' }}
        </div>
    </div>
</div>



2. Table Filters dengan Custom Logic:

php

Tables\Filters\Filter::make('has_comments')
    ->label('Posts with Comments')
    ->query(fn (Builder $query): Builder => $query->has('comments')),

Tables\Filters\Filter::make('no_featured_image')
    ->label('Missing Featured Image')
    ->query(fn (Builder $query): Builder => $query->whereNull('featured_image')),

Tables\Filters\TernaryFilter::make('is_featured')
    ->label('Featured Posts')
    ->nullable(),

Tables\Filters\SelectFilter::make('tags')
    ->relationship('tags', 'name')
    ->multiple()
    ->preload()
    ->searchable(),

Tables\Filters\Filter::make('created_range')
    ->form([
        Forms\Components\DatePicker::make('created_from')
            ->placeholder('From date'),
        Forms\Components\DatePicker::make('created_until')
            ->placeholder('Until date'),
    ])
    ->query(function (Builder $query, array $data): Builder {
        return $query
            ->when(
                $data['created_from'],
                fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date),
            )
            ->when(
                $data['created_until'],
                fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date),
            );
    })
    ->indicateUsing(function (array $data): array {
        $indicators = [];
        
        if ($data['created_from']) {
            $indicators['created_from'] = 'Created from ' . Carbon::parse($data['created_from'])->toFormattedDateString();
        }
        
        if ($data['created_until']) {
            $indicators['created_until'] = 'Created until ' . Carbon::parse($data['created_until'])->toFormattedDateString();
        }
        
        return $indicators;
    }),



3. Table Actions dengan Custom Logic:

php

Tables\Actions\Action::make('view_on_site')
    ->label('View')
    ->url(fn (Post $record): string => route('posts.show', $record))
    ->openUrlInNewTab()
    ->icon('heroicon-o-eye')
    ->color('gray'),

Tables\Actions\Action::make('duplicate')
    ->label('Duplicate')
    ->icon('heroicon-o-document-duplicate')
    ->action(function (Post $record) {
        $newPost = $record->replicate();
        $newPost->title = $record->title . ' (Copy)';
        $newPost->slug = $record->slug . '-copy';
        $newPost->status = 'draft';
        $newPost->published_at = null;
        $newPost->view_count = 0;
        $newPost->save();
        
        // Duplicate tags
        $newPost->tags()->sync($record->tags->pluck('id'));
        
        Notification::make()
            ->title('Post duplicated successfully')
            ->success()
            ->send();
    })
    ->requiresConfirmation()
    ->modalHeading('Duplicate Post')
    ->modalDescription('Are you sure you want to duplicate this post?')
    ->modalSubmitActionLabel('Yes, duplicate it'),

Tables\Actions\Action::make('export')
    ->label('Export')
    ->icon('heroicon-o-arrow-down-tray')
    ->color('success')
    ->action(function (Post $record) {
        return response()->streamDownload(function () use ($record) {
            echo json_encode([
                'title' => $record->title,
                'content' => $record->content,
                'author' => $record->user->name,
                'published_at' => $record->published_at,
                'tags' => $record->tags->pluck('name'),
            ], JSON_PRETTY_PRINT);
        }, "post-{$record->slug}.json");
    }),




D. Relation Managers (45 menit)

1. Tags Relation Manager:

php

<?php
// app/Filament/Resources/PostResource/RelationManagers/TagsRelationManager.php

namespace App\Filament\Resources\PostResource\RelationManagers;

use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;

class TagsRelationManager extends RelationManager
{
    protected static string $relationship = 'tags';

    protected static ?string $recordTitleAttribute = 'name';

    public function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\TextInput::make('name')
                    ->required()
                    ->maxLength(50)
                    ->unique(ignorable: fn ($record) => $record),
                
                Forms\Components\TextInput::make('slug')
                    ->required()
                    ->maxLength(60)
                    ->unique(ignorable: fn ($record) => $record),
                
                Forms\Components\ColorPicker::make('color')
                    ->label('Tag Color')
                    ->default('#6b7280'),
                
                Forms\Components\Textarea::make('description')
                    ->rows(2)
                    ->maxLength(255),
            ]);
    }

    public function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),
                
                Tables\Columns\ColorColumn::make('color')
                    ->sortable(),
                
                Tables\Columns\TextColumn::make('posts_count')
                    ->label('Usage')
                    ->counts('posts')
                    ->sortable(),
                
                Tables\Columns\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\Filters\Filter::make('unused')
                    ->label('Unused Tags')
                    ->query(fn ($query) => $query->doesntHave('posts')),
            ])
            ->headerActions([
                Tables\Actions\CreateAction::make(),
                Tables\Actions\AttachAction::make()
                    ->preloadRecordSelect()
                    ->form(fn (Tables\Actions\AttachAction $action): array => [
                        $action->getRecordSelect()
                            ->searchable()
                            ->preload(),
                        
                        Forms\Components\Select::make('type')
                            ->options([
                                'primary' => 'Primary Tag',
                                'secondary' => 'Secondary Tag',
                            ])
                            ->default('primary'),
                    ]),
            ])
            ->actions([
                Tables\Actions\EditAction::make(),
                Tables\Actions\DetachAction::make(),
                Tables\Actions\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DetachBulkAction::make(),
                    Tables\Actions\DeleteBulkAction::make(),
                ]),
            ])
            ->defaultSort('name');
    }
}



2. Comments Relation Manager:

php

<?php
// app/Filament/Resources/PostResource/RelationManagers/CommentsRelationManager.php

namespace App\Filament\Resources\PostResource\RelationManagers;

use App\Models\Comment;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;

class CommentsRelationManager extends RelationManager
{
    protected static string $relationship = 'comments';

    protected static ?string $recordTitleAttribute = 'content';

    public function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\Select::make('user_id')
                    ->relationship('user', 'name')
                    ->searchable()
                    ->preload()
                    ->createOptionForm([
                        Forms\Components\TextInput::make('name')
                            ->required()
                            ->maxLength(255),
                        Forms\Components\TextInput::make('email')
                            ->required()
                            ->email()
                            ->maxLength(255),
                    ]),
                
                Forms\Components\Textarea::make('content')
                    ->required()
                    ->rows(3)
                    ->maxLength(1000),
                
                Forms\Components\Select::make('status')
                    ->options([
                        'pending' => 'Pending',
                        'approved' => 'Approved',
                        'spam' => 'Spam',
                    ])
                    ->default('pending')
                    ->required(),
                
                Forms\Components\Toggle::make('is_featured')
                    ->label('Featured Comment'),
            ]);
    }

    public function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('user.name')
                    ->label('Author')
                    ->searchable()
                    ->sortable()
                    ->formatStateUsing(fn ($state, $record) => 
                        $record->user_id ? $state : ($record->author_name ?: 'Anonymous')
                    ),
                
                Tables\Columns\TextColumn::make('content')
                    ->limit(100)
                    ->searchable(),
                
                Tables\Columns\BadgeColumn::make('status')
                    ->colors([
                        'warning' => 'pending',
                        'success' => 'approved',
                        'danger' => 'spam',
                    ])
                    ->sortable(),
                
                Tables\Columns\IconColumn::make('is_featured')
                    ->boolean()
                    ->sortable(),
                
                Tables\Columns\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable(),
            ])
            ->filters([
                Tables\Filters\SelectFilter::make('status')
                    ->options([
                        'pending' => 'Pending',
                        'approved' => 'Approved',
                        'spam' => 'Spam',
                    ]),
                
                Tables\Filters\TernaryFilter::make('is_featured'),
                
                Tables\Filters\Filter::make('has_replies')
                    ->label('Has Replies')
                    ->query(fn ($query) => $query->has('replies')),
            ])
            ->headerActions([
                Tables\Actions\CreateAction::make(),
            ])
            ->actions([
                Tables\Actions\Action::make('approve')
                    ->action(fn (Comment $record) => $record->update(['status' => 'approved']))
                    ->requiresConfirmation()
                    ->color('success')
                    ->icon('heroicon-o-check-circle')
                    ->hidden(fn (Comment $record): bool => $record->status === 'approved'),
                
                Tables\Actions\Action::make('reject')
                    ->action(fn (Comment $record) => $record->update(['status' => 'spam']))
                    ->requiresConfirmation()
                    ->color('danger')
                    ->icon('heroicon-o-x-circle')
                    ->hidden(fn (Comment $record): bool => $record->status === 'spam'),
                
                Tables\Actions\EditAction::make(),
                Tables\Actions\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\BulkAction::make('approve')
                        ->action(fn ($records) => $records->each->update(['status' => 'approved']))
                        ->requiresConfirmation()
                        ->deselectRecordsAfterCompletion()
                        ->color('success')
                        ->icon('heroicon-o-check-circle'),
                    
                    Tables\Actions\BulkAction::make('mark_as_spam')
                        ->action(fn ($records) => $records->each->update(['status' => 'spam']))
                        ->requiresConfirmation()
                        ->deselectRecordsAfterCompletion()
                        ->color('danger')
                        ->icon('heroicon-o-x-circle'),
                    
                    Tables\Actions\DeleteBulkAction::make(),
                ]),
            ])
            ->defaultSort('created_at', 'desc');
    }
}




E. Custom Widgets untuk Dashboard (45 menit)

1. Blog Statistics Widget:

php

<?php
// app/Filament/Widgets/BlogStats.php

namespace App\Filament\Widgets;

use App\Models\Post;
use App\Models\Comment;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class BlogStats extends BaseWidget
{
    protected static ?string $pollingInterval = '30s';
    
    protected static ?int $sort = 1;

    protected function getStats(): array
    {
        return [
            Stat::make('Total Posts', Post::count())
                ->description('All posts in the system')
                ->descriptionIcon('heroicon-o-document-text')
                ->color('primary')
                ->chart($this->getPostsChartData())
                ->extraAttributes(['class' => 'cursor-pointer'])
                ->url(route('filament.admin.resources.posts.index')),
            
            Stat::make('Published Posts', Post::where('status', 'published')->count())
                ->description('Visible to public')
                ->descriptionIcon('heroicon-o-check-circle')
                ->color('success')
                ->chart($this->getPublishedPostsChartData()),
            
            Stat::make('Draft Posts', Post::where('status', 'draft')->count())
                ->description('Waiting to be published')
                ->descriptionIcon('heroicon-o-pencil')
                ->color('warning'),
            
            Stat::make('Total Comments', Comment::count())
                ->description('All comments')
                ->descriptionIcon('heroicon-o-chat-bubble-left-ellipsis')
                ->color('info')
                ->url(route('filament.admin.resources.comments.index')),
            
            Stat::make('Pending Comments', Comment::where('status', 'pending')->count())
                ->description('Need moderation')
                ->descriptionIcon('heroicon-o-clock')
                ->color('danger')
                ->url(route('filament.admin.resources.comments.index', ['tableFilters[status][value]' => 'pending'])),
            
            Stat::make('Total Users', User::count())
                ->description('Registered users')
                ->descriptionIcon('heroicon-o-users')
                ->color('gray')
                ->url(route('filament.admin.resources.users.index')),
        ];
    }
    
    private function getPostsChartData(): array
    {
        $data = [];
        for ($i = 6; $i >= 0; $i--) {
            $date = now()->subDays($i);
            $data[] = Post::whereDate('created_at', $date)->count();
        }
        return $data;
    }
    
    private function getPublishedPostsChartData(): array
    {
        $data = [];
        for ($i = 6; $i >= 0; $i--) {
            $date = now()->subDays($i);
            $data[] = Post::where('status', 'published')
                ->whereDate('published_at', $date)
                ->count();
        }
        return $data;
    }
}



2. Recent Posts Widget:

php

<?php
// app/Filament/Widgets/RecentPosts.php

namespace App\Filament\Widgets;

use App\Models\Post;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Database\Eloquent\Builder;

class RecentPosts extends BaseWidget
{
    protected int | string | array $columnSpan = 'full';
    
    protected static ?int $sort = 2;

    public function table(Table $table): Table
    {
        return $table
            ->query(
                Post::query()
                    ->with(['user', 'category'])
                    ->latest()
                    ->limit(10)
            )
            ->columns([
                Tables\Columns\ImageColumn::make('featured_image')
                    ->label('')
                    ->square()
                    ->size(40),
                
                Tables\Columns\TextColumn::make('title')
                    ->searchable()
                    ->sortable()
                    ->limit(40),
                
                Tables\Columns\TextColumn::make('user.name')
                    ->label('Author')
                    ->sortable()
                    ->searchable(),
                
                Tables\Columns\TextColumn::make('category.name')
                    ->badge()
                    ->color('gray'),
                
                Tables\Columns\BadgeColumn::make('status')
                    ->colors([
                        'success' => 'published',
                        'warning' => 'draft',
                        'danger' => 'archived',
                    ]),
                
                Tables\Columns\TextColumn::make('published_at')
                    ->dateTime()
                    ->sortable(),
                
                Tables\Columns\TextColumn::make('view_count')
                    ->numeric()
                    ->sortable()
                    ->alignRight(),
            ])
            ->actions([
                Tables\Actions\Action::make('view')
                    ->url(fn (Post $record): string => route('filament.admin.resources.posts.view', $record))
                    ->icon('heroicon-o-eye'),
                
                Tables\Actions\Action::make('edit')
                    ->url(fn (Post $record): string => route('filament.admin.resources.posts.edit', $record))
                    ->icon('heroicon-o-pencil'),
            ])
            ->recordUrl(fn (Post $record): string => route('filament.admin.resources.posts.view', $record));
    }
}



3. Activity Feed Widget:

php

<?php
// app/Filament/Widgets/ActivityFeed.php

namespace App\Filament\Widgets;

use App\Models\AuditLog;
use Filament\Widgets\Widget;
use Illuminate\Database\Eloquent\Builder;

class ActivityFeed extends Widget
{
    protected static string $view = 'filament.widgets.activity-feed';
    
    protected static ?int $sort = 3;
    
    protected int | string | array $columnSpan = 2;

    public function getActivities()
    {
        return AuditLog::with(['user', 'subject'])
            ->latest()
            ->limit(20)
            ->get();
    }
    
    public function getIcon(string $action): string
    {
        return match($action) {
            'created' => 'heroicon-o-plus-circle',
            'updated' => 'heroicon-o-pencil',
            'deleted' => 'heroicon-o-trash',
            default => 'heroicon-o-information-circle',
        };
    }
    
    public function getColor(string $action): string
    {
        return match($action) {
            'created' => 'success',
            'updated' => 'warning',
            'deleted' => 'danger',
            default => 'gray',
        };
    }
}



blade

{{-- resources/views/filament/widgets/activity-feed.blade.php --}}
<x-filament-widgets::widget>
    <x-filament::section heading="Recent Activity" icon="heroicon-o-clock">
        <div class="space-y-4">
            @foreach($this->getActivities() as $activity)
                <div class="flex items-start space-x-3 p-2 rounded-lg hover:bg-gray-50">
                    <x-filament::icon 
                        :icon="$this->getIcon($activity->action)" 
                        class="h-5 w-5 text-{{ $this->getColor($activity->action) }}-500 mt-0.5"
                    />
                    
                    <div class="flex-1 min-w-0">
                        <div class="text-sm text-gray-900">
                            <span class="font-medium">{{ $activity->user?->name ?? 'System' }}</span>
                            {{ $activity->action }} 
                            
                            @if($activity->subject)
                                <a 
                                    href="{{ $this->getSubjectUrl($activity) }}" 
                                    class="font-medium text-primary-600 hover:text-primary-500"
                                >
                                    {{ $this->getSubjectName($activity) }}
                                </a>
                            @else
                                <span class="font-medium">{{ $activity->model_type }}</span>
                            @endif
                        </div>
                        
                        <div class="text-xs text-gray-500 mt-1">
                            {{ $activity->created_at->diffForHumans() }}
                            
                            @if($activity->ip_address)
                                • {{ $activity->ip_address }}
                            @endif
                        </div>
                        
                        @if($activity->action === 'updated' && !empty($activity->changes))
                            <div class="mt-2 text-xs text-gray-600">
                                @foreach($activity->changes as $field => $values)
                                    <div>
                                        <span class="font-medium">{{ $field }}:</span>
                                        {{ $values['old'] ?? 'null' }} → {{ $values['new'] ?? 'null' }}
                                    </div>
                                @endforeach
                            </div>
                        @endif
                    </div>
                </div>
            @endforeach
            
            @if($this->getActivities()->isEmpty())
                <div class="text-center py-8 text-gray-500">
                    <x-filament::icon icon="heroicon-o-inbox" class="h-12 w-12 mx-auto mb-2" />
                    <p>No activity yet</p>
                </div>
            @endif
        </div>
    </x-filament::section>
</x-filament-widgets::widget>




F. Praktikum: Advanced Admin Panel (60 menit)

Tugas: Enhance admin panel dengan advanced features

Langkah-langkah:

  1. Upgrade Post Resource dengan:
    • Wizard form untuk create/edit
    • Custom form components (reading time calculator)
    • Advanced table filters
    • Custom actions (duplicate, export, publish)
  2. Implement Relation Managers:
    • Tags relation manager dengan attach/detach
    • Comments relation manager dengan moderation
  3. Create Custom Widgets:
    • Blog statistics widget
    • Recent posts widget
    • Activity feed widget
  4. Test semua features:
    • Form validation
    • Bulk actions
    • Real-time statistics
    • Responsive design

🎯 TUGAS PERTEMUAN 11

  1. Implementasi wizard form untuk Post creation
  2. Buat 3 custom widgets untuk dashboard:
    • Blog statistics
    • Recent activity
    • Popular posts
  3. Tambahkan advanced features ke semua resources:
    • Bulk actions
    • Custom filters
    • Export functionality
  4. Test dengan berbagai scenarios:
    • Bulk publish posts
    • Export data
    • Filter complex queries
  5. Screenshot dan dokumentasi features

📚 PERTEMUAN 12 – FILAMENT + DATABASE ADVANCED

Durasi: 3 jam (Teori: 1 jam, Praktik: 2 jam)

🎯 TUJUAN PEMBELAJARAN

  1. Handle complex many-to-many relationships
  2. Implementasi tag input dengan select2-like interface
  3. Integrasi database triggers dengan Laravel
  4. Membuat publishing workflow yang kompleks

📖 MATERI DETAIL


A. Review & Advanced Challenges (30 menit)

  • Diskusi complex relationships handling
  • Performance issues dengan large datasets
  • Database vs application logic


B. Handling Many-to-Many dengan Tag Input (60 menit)

1. Select2-like Tag Input:

php

use Filament\Forms\Components\Select;

Forms\Components\Select::make('tags')
    ->relationship('tags', 'name')
    ->multiple()
    ->preload()
    ->searchable()
    ->createOptionForm([
        Forms\Components\TextInput::make('name')
            ->required()
            ->maxLength(50)
            ->live(onBlur: true)
            ->afterStateUpdated(function ($state, $set) {
                $set('slug', \Str::slug($state));
            }),
        
        Forms\Components\TextInput::make('slug')
            ->required()
            ->maxLength(60),
        
        Forms\Components\ColorPicker::make('color')
            ->default('#6b7280'),
    ])
    ->createOptionUsing(function (array $data) {
        $tag = \App\Models\Tag::create($data);
        return $tag->id;
    })
    ->suggestions(
        \App\Models\Tag::query()
            ->whereNotIn('id', $this->getRecord()?->tags->pluck('id') ?? [])
            ->limit(50)
            ->pluck('name')
            ->toArray()
    ),



2. Custom Tags Input Component:

php

<?php
// app/Forms/Components/TagsInput.php

namespace App\Forms\Components;

use Closure;
use Filament\Forms\Components\Field;
use Illuminate\Database\Eloquent\Model;

class TagsInput extends Field
{
    protected string $view = 'forms.components.tags-input';
    
    protected string | Closure | null $relationship = null;
    
    protected function setUp(): void
    {
        parent::setUp();
        
        $this->afterStateHydrated(function (TagsInput $component, $state) {
            if (!$component->getRelationship()) {
                return;
            }
            
            $tags = $component->getRelationship()->get();
            $component->state($tags->pluck('id')->toArray());
        });
        
        $this->dehydrated(false);
        
        $this->afterStateUpdated(function (TagsInput $component, $state) {
            if (!$component->getRelationship()) {
                return;
            }
            
            $component->getRelationship()->sync($state);
        });
    }
    
    public function relationship(string | Closure $relationship): static
    {
        $this->relationship = $relationship;
        
        return $this;
    }
    
    public function getRelationship(): ?Model
    {
        $model = $this->getModel();
        
        if (!$model) {
            return null;
        }
        
        return $model->{$this->evaluate($this->relationship)}();
    }
    
    public function getSuggestions(): array
    {
        return \App\Models\Tag::query()
            ->orderBy('name')
            ->pluck('name')
            ->toArray();
    }
}



blade

{{-- resources/views/forms/components/tags-input.blade.php --}}
<div x-data="{
    tags: @if($getState()) {{ json_encode($getState()) }} @else [] @endif,
    suggestions: {{ json_encode($getSuggestions()) }},
    newTag: '',
    showSuggestions: false,
    filteredSuggestions: [],
    
    init() {
        this.filteredSuggestions = this.suggestions;
        
        this.$watch('newTag', (value) => {
            if (!value) {
                this.filteredSuggestions = this.suggestions;
                this.showSuggestions = false;
                return;
            }
            
            this.filteredSuggestions = this.suggestions.filter(
                suggestion => suggestion.toLowerCase().includes(value.toLowerCase())
            );
            this.showSuggestions = this.filteredSuggestions.length > 0;
        });
    },
    
    addTag(tag = null) {
        const tagToAdd = tag || this.newTag.trim();
        
        if (!tagToAdd || this.tags.includes(tagToAdd)) {
            this.newTag = '';
            return;
        }
        
        this.tags.push(tagToAdd);
        this.newTag = '';
        this.showSuggestions = false;
        this.$dispatch('input', this.tags);
    },
    
    removeTag(index) {
        this.tags.splice(index, 1);
        this.$dispatch('input', this.tags);
    }
}">
    <label class="filament-forms-field-wrapper-label" for="{{ $getId() }}">
        {{ $getLabel() }}
    </label>
    
    <div class="mt-1">
        {{-- Selected Tags --}}
        <div class="flex flex-wrap gap-2 mb-2">
            <template x-for="(tag, index) in tags" :key="index">
                <span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800">
                    <span x-text="tag"></span>
                    <button 
                        type="button" 
                        @click="removeTag(index)"
                        class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full hover:bg-primary-200"
                    >
                        &times;
                    </button>
                </span>
            </template>
        </div>
        
        {{-- Input & Suggestions --}}
        <div class="relative">
            <input 
                type="text" 
                x-model="newTag"
                @keydown.enter.prevent="addTag()"
                @blur="setTimeout(() => showSuggestions = false, 200)"
                @focus="showSuggestions = true"
                placeholder="Type a tag and press Enter..."
                class="w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
            />
            
            {{-- Suggestions Dropdown --}}
            <div 
                x-show="showSuggestions && filteredSuggestions.length"
                x-transition
                class="absolute z-10 w-full mt-1 bg-white rounded-lg shadow-lg border border-gray-200 max-h-60 overflow-auto"
            >
                <template x-for="suggestion in filteredSuggestions" :key="suggestion">
                    <button 
                        type="button"
                        @click="addTag(suggestion)"
                        @mousedown.prevent
                        class="w-full text-left px-4 py-2 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
                    >
                        <span x-text="suggestion"></span>
                    </button>
                </template>
            </div>
        </div>
        
        <p class="mt-1 text-sm text-gray-500">
            Type and press Enter to add tags. Click on a tag to remove it.
        </p>
    </div>
    
    {{-- Hidden input for form submission --}}
    <input type="hidden" name="{{ $getName() }}" :value="JSON.stringify(tags)" />
</div>



3. Tags Management Resource:

php

<?php
// app/Filament/Resources/TagResource.php

namespace App\Filament\Resources;

use App\Filament\Resources\TagResource\Pages;
use App\Models\Tag;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;

class TagResource extends Resource
{
    protected static ?string $model = Tag::class;

    protected static ?string $navigationIcon = 'heroicon-o-hashtag';
    
    protected static ?string $navigationGroup = 'Blog';
    
    protected static ?int $navigationSort = 3;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\Section::make()
                    ->schema([
                        Forms\Components\TextInput::make('name')
                            ->required()
                            ->maxLength(50)
                            ->unique(ignorable: fn ($record) => $record)
                            ->live(onBlur: true)
                            ->afterStateUpdated(function ($state, $set, $get) {
                                if (!$get('is_slug_customized')) {
                                    $set('slug', \Str::slug($state));
                                }
                            }),
                        
                        Forms\Components\TextInput::make('slug')
                            ->required()
                            ->maxLength(60)
                            ->unique(ignorable: fn ($record) => $record)
                            ->suffixAction(
                                Forms\Components\Actions\Action::make('regenerate')
                                    ->icon('heroicon-o-arrow-path')
                                    ->action(function ($set, $get) {
                                        $set('slug', \Str::slug($get('name')));
                                    })
                            ),
                        
                        Forms\Components\Toggle::make('is_slug_customized')
                            ->label('Customize slug manually')
                            ->default(false)
                            ->inline(false),
                        
                        Forms\Components\ColorPicker::make('color')
                            ->default('#6b7280'),
                        
                        Forms\Components\Textarea::make('description')
                            ->rows(2)
                            ->maxLength(255),
                    ])
                    ->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\ColorColumn::make('color')
                    ->size(30),
                
                Tables\Columns\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),
                
                Tables\Columns\TextColumn::make('slug')
                    ->searchable()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
                
                Tables\Columns\TextColumn::make('posts_count')
                    ->label('Posts')
                    ->counts('posts')
                    ->sortable()
                    ->color('gray'),
                
                Tables\Columns\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\Filters\Filter::make('unused')
                    ->label('Unused Tags')
                    ->query(fn ($query) => $query->doesntHave('posts')),
                
                Tables\Filters\Filter::make('popular')
                    ->label('Popular Tags (10+ posts)')
                    ->query(fn ($query) => $query->has('posts', '>=', 10)),
            ])
            ->actions([
                Tables\Actions\Action::make('merge')
                    ->icon('heroicon-o-arrow-path')
                    ->form([
                        Forms\Components\Select::make('target_tag_id')
                            ->label('Merge into tag')
                            ->options(Tag::query()->pluck('name', 'id'))
                            ->searchable()
                            ->required()
                            ->disabled(fn ($record) => $record->id),
                    ])
                    ->action(function (Tag $record, array $data) {
                        $targetTag = Tag::find($data['target_tag_id']);
                        
                        // Transfer all posts to target tag
                        $record->posts()->each(function ($post) use ($targetTag) {
                            $post->tags()->syncWithoutDetaching([$targetTag->id]);
                        });
                        
                        // Detach from all posts
                        $record->posts()->detach();
                        
                        // Delete the tag
                        $record->delete();
                        
                        Notification::make()
                            ->title('Tag merged successfully')
                            ->success()
                            ->send();
                    })
                    ->requiresConfirmation()
                    ->modalHeading('Merge Tag')
                    ->modalDescription('This will transfer all posts from this tag to the target tag, then delete this tag.')
                    ->modalSubmitActionLabel('Merge'),
                
                Tables\Actions\EditAction::make(),
                Tables\Actions\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DeleteBulkAction::make(),
                    Tables\Actions\BulkAction::make('merge_selected')
                        ->icon('heroicon-o-arrow-path')
                        ->form([
                            Forms\Components\Select::make('target_tag_id')
                                ->label('Merge into tag')
                                ->options(Tag::query()->pluck('name', 'id'))
                                ->searchable()
                                ->required(),
                        ])
                        ->action(function ($records, array $data) {
                            $targetTag = Tag::find($data['target_tag_id']);
                            
                            $records->each(function ($tag) use ($targetTag) {
                                if ($tag->id === $targetTag->id) return;
                                
                                $tag->posts()->each(function ($post) use ($targetTag) {
                                    $post->tags()->syncWithoutDetaching([$targetTag->id]);
                                });
                                
                                $tag->posts()->detach();
                                $tag->delete();
                            });
                            
                            Notification::make()
                                ->title('Tags merged successfully')
                                ->success()
                                ->send();
                        })
                        ->requiresConfirmation()
                        ->deselectRecordsAfterCompletion(),
                ]),
            ])
            ->defaultSort('name');
    }

    public static function getRelations(): array
    {
        return [
            //
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\ListTags::route('/'),
            'create' => Pages\CreateTag::route('/create'),
            'edit' => Pages\EditTag::route('/{record}/edit'),
        ];
    }
}




C. Auto-slug & Observers (45 menit)

1. Observer untuk Auto-slug:

php

<?php
// app/Observers/PostObserver.php

namespace App\Observers;

use App\Models\Post;
use Illuminate\Support\Str;

class PostObserver
{
    /**
     * Handle the Post "creating" event.
     */
    public function creating(Post $post): void
    {
        if (empty($post->slug)) {
            $post->slug = $this->generateUniqueSlug($post->title);
        }
        
        // Set default excerpt if empty
        if (empty($post->excerpt)) {
            $post->excerpt = $this->generateExcerpt($post->content);
        }
        
        // Set default meta fields if empty
        if (empty($post->meta_title)) {
            $post->meta_title = $post->title;
        }
        
        if (empty($post->meta_description)) {
            $post->meta_description = $post->excerpt;
        }
    }
    
    /**
     * Handle the Post "updating" event.
     */
    public function updating(Post $post): void
    {
        // Regenerate slug if title changed
        if ($post->isDirty('title') && !$post->isDirty('slug')) {
            $post->slug = $this->generateUniqueSlug($post->title, $post->id);
        }
        
        // Regenerate excerpt if content changed and excerpt is empty or auto-generated
        if ($post->isDirty('content') && (empty($post->excerpt) || $post->excerpt === $this->generateExcerpt($post->getOriginal('content')))) {
            $post->excerpt = $this->generateExcerpt($post->content);
        }
        
        // Update published_at based on status
        if ($post->isDirty('status')) {
            if ($post->status === 'published' && empty($post->published_at)) {
                $post->published_at = now();
            } elseif ($post->status === 'draft') {
                $post->published_at = null;
            }
        }
    }
    
    /**
     * Handle the Post "saving" event.
     */
    public function saving(Post $post): void
    {
        // Ensure slug is unique
        if ($post->isDirty('slug')) {
            $post->slug = $this->makeSlugUnique($post->slug, $post->id);
        }
    }
    
    /**
     * Generate a unique slug.
     */
    private function generateUniqueSlug(string $title, ?int $ignoreId = null): string
    {
        $slug = Str::slug($title);
        return $this->makeSlugUnique($slug, $ignoreId);
    }
    
    /**
     * Make slug unique by appending number if needed.
     */
    private function makeSlugUnique(string $slug, ?int $ignoreId = null): string
    {
        $originalSlug = $slug;
        $counter = 1;
        
        while (Post::where('slug', $slug)
            ->when($ignoreId, fn($q) => $q->where('id', '!=', $ignoreId))
            ->exists()) {
            $slug = $originalSlug . '-' . $counter;
            $counter++;
        }
        
        return $slug;
    }
    
    /**
     * Generate excerpt from content.
     */
    private function generateExcerpt(string $content, int $length = 150): string
    {
        $excerpt = strip_tags($content);
        $excerpt = str_replace(["\r", "\n"], ' ', $excerpt);
        
        return Str::limit($excerpt, $length);
    }
}



2. Observer untuk Category & Tag:

php

<?php
// app/Observers/CategoryObserver.php

namespace App\Observers;

use App\Models\Category;
use Illuminate\Support\Str;

class CategoryObserver
{
    public function creating(Category $category): void
    {
        if (empty($category->slug)) {
            $category->slug = $this->generateUniqueSlug($category->name);
        }
    }
    
    public function updating(Category $category): void
    {
        if ($category->isDirty('name') && !$category->isDirty('slug')) {
            $category->slug = $this->generateUniqueSlug($category->name, $category->id);
        }
    }
    
    private function generateUniqueSlug(string $name, ?int $ignoreId = null): string
    {
        $slug = Str::slug($name);
        $originalSlug = $slug;
        $counter = 1;
        
        while (Category::where('slug', $slug)
            ->when($ignoreId, fn($q) => $q->where('id', '!=', $ignoreId))
            ->exists()) {
            $slug = $originalSlug . '-' . $counter;
            $counter++;
        }
        
        return $slug;
    }
}



3. Register Observers:

php

// app/Providers/EventServiceProvider.php
protected $observers = [
    Post::class => [PostObserver::class],
    Category::class => [CategoryObserver::class],
    Tag::class => [TagObserver::class],
];




D. Integrasi Database Triggers (45 menit)

1. Migration untuk Database Triggers:

php

<?php
// database/migrations/xxxx_add_triggers.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    public function up(): void
    {
        // Trigger untuk update updated_at otomatis
        DB::unprepared('
            CREATE TRIGGER posts_update_timestamp 
            BEFORE UPDATE ON posts 
            FOR EACH ROW 
            SET NEW.updated_at = CURRENT_TIMESTAMP;
        ');
        
        // Trigger untuk log perubahan
        DB::unprepared('
            CREATE TRIGGER posts_log_changes 
            AFTER UPDATE ON posts 
            FOR EACH ROW 
            BEGIN
                INSERT INTO post_audit_logs 
                (post_id, field_name, old_value, new_value, changed_by, changed_at)
                VALUES 
                (NEW.id, "title", OLD.title, NEW.title, @user_id, NOW()),
                (NEW.id, "status", OLD.status, NEW.status, @user_id, NOW()),
                (NEW.id, "content", LEFT(OLD.content, 1000), LEFT(NEW.content, 1000), @user_id, NOW());
            END;
        ');
        
        // Trigger untuk update view_count
        DB::unprepared('
            CREATE TRIGGER posts_increment_view_count 
            AFTER INSERT ON post_views 
            FOR EACH ROW 
            UPDATE posts SET view_count = view_count + 1 WHERE id = NEW.post_id;
        ');
    }

    public function down(): void
    {
        DB::unprepared('DROP TRIGGER IF EXISTS posts_update_timestamp');
        DB::unprepared('DROP TRIGGER IF EXISTS posts_log_changes');
        DB::unprepared('DROP TRIGGER IF EXISTS posts_increment_view_count');
    }
};



2. Eloquent dengan Database Triggers:

php

// app/Models/Post.php
class Post extends Model
{
    // ...
    
    /**
     * Method untuk set user_id sebelum trigger
     */
    public static function boot()
    {
        parent::boot();
        
        static::updating(function ($model) {
            // Set session variable untuk trigger
            if (auth()->check()) {
                DB::statement('SET @user_id = ?', [auth()->id()]);
            }
        });
    }
    
    /**
     * Increment view count via database trigger
     */
    public function incrementViewCount()
    {
        DB::table('post_views')->insert([
            'post_id' => $this->id,
            'user_id' => auth()->id(),
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
            'viewed_at' => now(),
        ]);
        
        // Trigger akan otomatis increment view_count
    }
}



3. Audit Log dengan Triggers:

php

// Migration untuk audit log table
Schema::create('post_audit_logs', function (Blueprint $table) {
    $table->id();
    $table->foreignId('post_id')->constrained()->onDelete('cascade');
    $table->string('field_name');
    $table->text('old_value')->nullable();
    $table->text('new_value')->nullable();
    $table->foreignId('changed_by')->nullable()->constrained('users')->onDelete('set null');
    $table->timestamp('changed_at');
    
    $table->index('post_id');
    $table->index('changed_at');
});

// Model untuk audit log
class PostAuditLog extends Model
{
    protected $table = 'post_audit_logs';
    
    protected $fillable = [
        'post_id',
        'field_name',
        'old_value',
        'new_value',
        'changed_by',
        'changed_at',
    ];
    
    public function post()
    {
        return $this->belongsTo(Post::class);
    }
    
    public function user()
    {
        return $this->belongsTo(User::class, 'changed_by');
    }
}




E. Studi Kasus: Post Publishing Flow (60 menit - PRAKTIKUM)

Complete Publishing Workflow:

1. Publishing State Machine:

php

<?php
// app/Models/Enums/PostStatus.php

namespace App\Models\Enums;

enum PostStatus: string
{
    case DRAFT = 'draft';
    case REVIEW = 'review';
    case SCHEDULED = 'scheduled';
    case PUBLISHED = 'published';
    case ARCHIVED = 'archived';
    
    public function label(): string
    {
        return match($this) {
            self::DRAFT => 'Draft',
            self::REVIEW => 'In Review',
            self::SCHEDULED => 'Scheduled',
            self::PUBLISHED => 'Published',
            self::ARCHIVED => 'Archived',
        };
    }
    
    public function color(): string
    {
        return match($this) {
            self::DRAFT => 'gray',
            self::REVIEW => 'yellow',
            self::SCHEDULED => 'blue',
            self::PUBLISHED => 'green',
            self::ARCHIVED => 'red',
        };
    }
    
    public function icon(): string
    {
        return match($this) {
            self::DRAFT => 'heroicon-o-pencil',
            self::REVIEW => 'heroicon-o-clock',
            self::SCHEDULED => 'heroicon-o-calendar',
            self::PUBLISHED => 'heroicon-o-check-circle',
            self::ARCHIVED => 'heroicon-o-archive-box',
        };
    }
    
    public function transitions(): array
    {
        return match($this) {
            self::DRAFT => [self::REVIEW, self::SCHEDULED, self::PUBLISHED],
            self::REVIEW => [self::DRAFT, self::SCHEDULED, self::PUBLISHED],
            self::SCHEDULED => [self::DRAFT, self::PUBLISHED, self::ARCHIVED],
            self::PUBLISHED => [self::DRAFT, self::ARCHIVED],
            self::ARCHIVED => [self::DRAFT, self::PUBLISHED],
        };
    }
    
    public function canTransitionTo(PostStatus $status): bool
    {
        return in_array($status, $this->transitions());
    }
}



2. Post Model dengan Publishing Workflow:

php

// app/Models/Post.php
use App\Models\Enums\PostStatus;

class Post extends Model
{
    // ...
    
    protected $casts = [
        'status' => PostStatus::class,
        'published_at' => 'datetime',
        'scheduled_for' => 'datetime',
        'reviewed_at' => 'datetime',
        'reviewed_by' => 'integer',
    ];
    
    /**
     * Transition post status
     */
    public function transitionTo(PostStatus $newStatus, ?string $notes = null): bool
    {
        if (!$this->status->canTransitionTo($newStatus)) {
            throw new \Exception("Cannot transition from {$this->status->label()} to {$newStatus->label()}");
        }
        
        $this->status = $newStatus;
        
        // Set timestamps based on status
        switch ($newStatus) {
            case PostStatus::REVIEW:
                $this->submitted_for_review_at = now();
                break;
                
            case PostStatus::SCHEDULED:
                // Scheduled for already set by user
                break;
                
            case PostStatus::PUBLISHED:
                $this->published_at = $this->scheduled_for ?? now();
                $this->scheduled_for = null;
                break;
                
            case PostStatus::ARCHIVED:
                $this->archived_at = now();
                break;
        }
        
        $saved = $this->save();
        
        if ($saved) {
            $this->logStatusChange($newStatus, $notes);
            
            // Dispatch events
            if ($newStatus === PostStatus::PUBLISHED) {
                \App\Events\PostPublished::dispatch($this);
            }
        }
        
        return $saved;
    }
    
    /**
     * Submit for review
     */
    public function submitForReview(?string $notes = null): bool
    {
        return $this->transitionTo(PostStatus::REVIEW, $notes);
    }
    
    /**
     * Schedule publication
     */
    public function scheduleForPublishing(\DateTime $publishAt, ?string $notes = null): bool
    {
        $this->scheduled_for = $publishAt;
        return $this->transitionTo(PostStatus::SCHEDULED, $notes);
    }
    
    /**
     * Publish immediately
     */
    public function publish(?string $notes = null): bool
    {
        return $this->transitionTo(PostStatus::PUBLISHED, $notes);
    }
    
    /**
     * Archive post
     */
    public function archive(?string $notes = null): bool
    {
        return $this->transitionTo(PostStatus::ARCHIVED, $notes);
    }
    
    /**
     * Return to draft
     */
    public function returnToDraft(?string $notes = null): bool
    {
        return $this->transitionTo(PostStatus::DRAFT, $notes);
    }
    
    /**
     * Log status change
     */
    private function logStatusChange(PostStatus $newStatus, ?string $notes = null): void
    {
        PostStatusLog::create([
            'post_id' => $this->id,
            'from_status' => $this->getOriginal('status'),
            'to_status' => $newStatus,
            'changed_by' => auth()->id(),
            'notes' => $notes,
        ]);
    }
    
    /**
     * Scope for scheduled posts that should be published
     */
    public function scopeShouldBePublished($query)
    {
        return $query->where('status', PostStatus::SCHEDULED)
                    ->where('scheduled_for', '<=', now());
    }
}



3. Publishing Actions di Filament:

php

// app/Filament/Resources/PostResource.php
public static function table(Table $table): Table
{
    return $table
        ->actions([
            // ... existing actions
            
            Tables\Actions\ActionGroup::make([
                Tables\Actions\Action::make('submit_for_review')
                    ->label('Submit for Review')
                    ->icon('heroicon-o-clock')
                    ->color('warning')
                    ->action(function (Post $record) {
                        $record->submitForReview();
                        Notification::make()
                            ->title('Post submitted for review')
                            ->success()
                            ->send();
                    })
                    ->requiresConfirmation()
                    ->hidden(fn (Post $record): bool => 
                        $record->status !== PostStatus::DRAFT
                    ),
                
                Tables\Actions\Action::make('approve')
                    ->label('Approve & Publish')
                    ->icon('heroicon-o-check-circle')
                    ->color('success')
                    ->form([
                        Forms\Components\DateTimePicker::make('publish_at')
                            ->label('Publish Date')
                            ->default(now())
                            ->required(),
                        
                        Forms\Components\Textarea::make('notes')
                            ->label('Review Notes')
                            ->rows(2),
                    ])
                    ->action(function (Post $record, array $data) {
                        if ($data['publish_at'] > now()) {
                            $record->scheduleForPublishing($data['publish_at'], $data['notes']);
                        } else {
                            $record->publish($data['notes']);
                        }
                        
                        Notification::make()
                            ->title('Post approved successfully')
                            ->success()
                            ->send();
                    })
                    ->hidden(fn (Post $record): bool => 
                        !in_array($record->status, [PostStatus::REVIEW, PostStatus::SCHEDULED])
                    ),
                
                Tables\Actions\Action::make('schedule')
                    ->label('Schedule Publication')
                    ->icon('heroicon-o-calendar')
                    ->color('blue')
                    ->form([
                        Forms\Components\DateTimePicker::make('scheduled_for')
                            ->label('Schedule For')
                            ->minDate(now())
                            ->required(),
                        
                        Forms\Components\Textarea::make('notes')
                            ->label('Notes')
                            ->rows(2),
                    ])
                    ->action(function (Post $record, array $data) {
                        $record->scheduleForPublishing($data['scheduled_for'], $data['notes']);
                        
                        Notification::make()
                            ->title('Post scheduled successfully')
                            ->success()
                            ->send();
                    })
                    ->hidden(fn (Post $record): bool => 
                        !in_array($record->status, [PostStatus::DRAFT, PostStatus::REVIEW])
                    ),
                
                Tables\Actions\Action::make('archive')
                    ->label('Archive')
                    ->icon('heroicon-o-archive-box')
                    ->color('danger')
                    ->action(function (Post $record) {
                        $record->archive();
                        Notification::make()
                            ->title('Post archived')
                            ->warning()
                            ->send();
                    })
                    ->requiresConfirmation()
                    ->hidden(fn (Post $record): bool => 
                        $record->status === PostStatus::ARCHIVED
                    ),
                
                Tables\Actions\Action::make('restore_draft')
                    ->label('Restore to Draft')
                    ->icon('heroicon-o-arrow-uturn-left')
                    ->color('gray')
                    ->action(function (Post $record) {
                        $record->returnToDraft();
                        Notification::make()
                            ->title('Post restored to draft')
                            ->success()
                            ->send();
                    })
                    ->requiresConfirmation()
                    ->hidden(fn (Post $record): bool => 
                        $record->status === PostStatus::DRAFT
                    ),
            ])
            ->label('Workflow')
            ->icon('heroicon-o-cog-6-tooth')
            ->color('primary')
            ->button(),
        ]);
}

// Custom page untuk publishing queue
public static function getPages(): array
{
    return [
        'index' => Pages\ListPosts::route('/'),
        'create' => Pages\CreatePost::route('/create'),
        'edit' => Pages\EditPost::route('/{record}/edit'),
        'view' => Pages\ViewPost::route('/{record}'),
        'publishing-queue' => Pages\PublishingQueue::route('/publishing-queue'),
    ];
}



4. Publishing Queue Page:

php

<?php
// app/Filament/Resources/PostResource/Pages/PublishingQueue.php

namespace App\Filament\Resources\PostResource\Pages;

use App\Filament\Resources\PostResource;
use App\Models\Post;
use App\Models\Enums\PostStatus;
use Filament\Actions;
use Filament\Resources\Pages\Page;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Contracts\HasTable;
use Illuminate\Database\Eloquent\Builder;

class PublishingQueue extends Page implements HasTable
{
    use Tables\Concerns\InteractsWithTable;
    
    protected static string $resource = PostResource::class;
    
    protected static string $view = 'filament.resources.post-resource.pages.publishing-queue';
    
    protected static ?string $navigationIcon = 'heroicon-o-queue-list';
    
    protected static ?string $navigationLabel = 'Publishing Queue';
    
    protected static ?int $navigationSort = 2;
    
    public function mount(): void
    {
        abort_unless(auth()->user()->can('manage_publishing'), 403);
    }
    
    public function table(Table $table): Table
    {
        return $table
            ->query(
                Post::query()
                    ->whereIn('status', [PostStatus::REVIEW, PostStatus::SCHEDULED])
                    ->orderBy('scheduled_for')
                    ->orderBy('submitted_for_review_at')
            )
            ->columns([
                Tables\Columns\TextColumn::make('title')
                    ->searchable()
                    ->limit(50),
                
                Tables\Columns\BadgeColumn::make('status')
                    ->colors([
                        'warning' => PostStatus::REVIEW->value,
                        'blue' => PostStatus::SCHEDULED->value,
                    ]),
                
                Tables\Columns\TextColumn::make('user.name')
                    ->label('Author'),
                
                Tables\Columns\TextColumn::make('scheduled_for')
                    ->dateTime()
                    ->placeholder('Not scheduled')
                    ->sortable(),
                
                Tables\Columns\TextColumn::make('submitted_for_review_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
                
                Tables\Columns\TextColumn::make('time_until_publish')
                    ->label('Time Until Publish')
                    ->getStateUsing(function (Post $record): string {
                        if (!$record->scheduled_for) {
                            return 'Manual publish required';
                        }
                        
                        $diff = now()->diff($record->scheduled_for);
                        
                        if ($diff->invert) {
                            return 'Should be published';
                        }
                        
                        return $diff->format('%d days, %h hours');
                    })
                    ->color(fn (Post $record): string => 
                        $record->scheduled_for && $record->scheduled_for <= now()->addHour()
                            ? 'danger' 
                            : 'gray'
                    ),
            ])
            ->filters([
                Tables\Filters\SelectFilter::make('status')
                    ->options([
                        PostStatus::REVIEW->value => 'In Review',
                        PostStatus::SCHEDULED->value => 'Scheduled',
                    ]),
                
                Tables\Filters\Filter::make('overdue')
                    ->label('Overdue for publishing')
                    ->query(fn ($query) => $query->where('scheduled_for', '<=', now())),
            ])
            ->actions([
                Tables\Actions\Action::make('publish_now')
                    ->label('Publish Now')
                    ->icon('heroicon-o-check-circle')
                    ->color('success')
                    ->action(function (Post $record) {
                        $record->publish('Published from queue');
                        Notification::make()
                            ->title('Post published')
                            ->success()
                            ->send();
                    })
                    ->hidden(fn (Post $record): bool => $record->status !== PostStatus::SCHEDULED),
                
                Tables\Actions\Action::make('reschedule')
                    ->label('Reschedule')
                    ->icon('heroicon-o-calendar')
                    ->form([
                        Forms\Components\DateTimePicker::make('scheduled_for')
                            ->label('New Schedule')
                            ->minDate(now())
                            ->required(),
                    ])
                    ->action(function (Post $record, array $data) {
                        $record->scheduleForPublishing($data['scheduled_for'], 'Rescheduled from queue');
                        Notification::make()
                            ->title('Post rescheduled')
                            ->success()
                            ->send();
                    })
                    ->hidden(fn (Post $record): bool => $record->status !== PostStatus::SCHEDULED),
                
                Tables\Actions\Action::make('approve')
                    ->label('Approve')
                    ->icon('heroicon-o-check')
                    ->color('success')
                    ->form([
                        Forms\Components\DateTimePicker::make('publish_at')
                            ->label('Publish Date')
                            ->default(now()),
                        
                        Forms\Components\Textarea::make('notes')
                            ->label('Review Notes'),
                    ])
                    ->action(function (Post $record, array $data) {
                        if ($data['publish_at'] > now()) {
                            $record->scheduleForPublishing($data['publish_at'], $data['notes']);
                        } else {
                            $record->publish($data['notes']);
                        }
                        
                        Notification::make()
                            ->title('Post approved')
                            ->success()
                            ->send();
                    })
                    ->hidden(fn (Post $record): bool => $record->status !== PostStatus::REVIEW),
                
                Tables\Actions\Action::make('reject')
                    ->label('Reject')
                    ->icon('heroicon-o-x-circle')
                    ->color('danger')
                    ->form([
                        Forms\Components\Textarea::make('reason')
                            ->label('Rejection Reason')
                            ->required(),
                    ])
                    ->action(function (Post $record, array $data) {
                        $record->returnToDraft($data['reason']);
                        
                        // Notify author
                        \App\Events\PostRejected::dispatch($record, auth()->user(), $data['reason']);
                        
                        Notification::make()
                            ->title('Post rejected and returned to draft')
                            ->warning()
                            ->send();
                    })
                    ->hidden(fn (Post $record): bool => $record->status !== PostStatus::REVIEW),
            ])
            ->bulkActions([
                Tables\Actions\BulkAction::make('bulk_publish')
                    ->label('Publish Selected')
                    ->icon('heroicon-o-check-circle')
                    ->color('success')
                    ->action(function ($records) {
                        $records->each->publish('Bulk published from queue');
                        Notification::make()
                            ->title($records->count() . ' posts published')
                            ->success()
                            ->send();
                    })
                    ->requiresConfirmation()
                    ->deselectRecordsAfterCompletion(),
                
                Tables\Actions\BulkAction::make('bulk_approve')
                    ->label('Approve Selected')
                    ->icon('heroicon-o-check')
                    ->color('success')
                    ->action(function ($records) {
                        $records->each->publish('Bulk approved from queue');
                        Notification::make()
                            ->title($records->count() . ' posts approved')
                            ->success()
                            ->send();
                    })
                    ->requiresConfirmation()
                    ->deselectRecordsAfterCompletion(),
            ]);
    }
    
    protected function getHeaderActions(): array
    {
        return [
            Actions\Action::make('process_scheduled')
                ->label('Process Scheduled Posts')
                ->icon('heroicon-o-play')
                ->color('success')
                ->action(function () {
                    $posts = Post::shouldBePublished()->get();
                    
                    $posts->each->publish('Auto-published from queue');
                    
                    Notification::make()
                        ->title($posts->count() . ' posts auto-published')
                        ->success()
                        ->send();
                })
                ->requiresConfirmation(),
        ];
    }
}



5. Scheduled Task untuk Auto-publishing:

php

// app/Console/Commands/PublishScheduledPosts.php
namespace App\Console\Commands;

use App\Models\Post;
use App\Models\Enums\PostStatus;
use Illuminate\Console\Command;

class PublishScheduledPosts extends Command
{
    protected $signature = 'posts:publish-scheduled';
    protected $description = 'Publish scheduled posts that are due';

    public function handle()
    {
        $posts = Post::shouldBePublished()->get();
        
        $this->info("Found {$posts->count()} posts to publish");
        
        foreach ($posts as $post) {
            $post->publish('Auto-published by scheduler');
            $this->line("Published: {$post->title}");
        }
        
        $this->info('Done!');
        
        // Log to monitoring
        if ($posts->count() > 0) {
            \App\Models\SystemLog::create([
                'level' => 'info',
                'message' => "Auto-published {$posts->count()} scheduled posts",
                'context' => ['post_ids' => $posts->pluck('id')->toArray()],
            ]);
        }
        
        return Command::SUCCESS;
    }
}

// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
    // Run every minute to check for scheduled posts
    $schedule->command('posts:publish-scheduled')->everyMinute();
    
    // Clean up old logs weekly
    $schedule->command('model:prune', [
        '--model' => [PostStatusLog::class, SystemLog::class],
    ])->weekly();
}




F. Praktikum: Complete Publishing System (60 menit)

Tugas: Implementasi complete publishing workflow

Langkah-langkah:

  1. Setup Post Status Enum dengan state machine logic
  2. Implement Observer untuk auto-slug dan excerpt generation
  3. Create Publishing Queue Page dengan Filament
  4. Add Database Triggers untuk audit logging
  5. Setup Scheduled Task untuk auto-publishing
  6. Test Complete Flow:
    • Author submits post for review
    • Editor reviews and schedules
    • Auto-publishing works
    • Audit logs are created
    • Notifications are sent

🎯 TUGAS AKHIR FASE 3

Buat Complete Admin Panel dengan Advanced Features:

  1. Admin Panel Features:
    • Complete CRUD for all resources with advanced forms
    • Role-based permissions with Filament Shield
    • Custom widgets for dashboard
    • Publishing workflow with review/schedule/publish
    • Tags management with merge functionality
    • Audit logging and activity feed
  2. Database Integration:
    • Database triggers for audit logging
    • Auto-slug generation with observers
    • Scheduled publishing with Laravel tasks
    • Performance optimized queries
  3. Advanced UI/UX:
    • Wizard forms for complex operations
    • Custom form components
    • Bulk actions and operations
    • Real-time notifications
    • Responsive design
  4. Documentation:
    • Admin user manual
    • API documentation (if any)
    • Deployment instructions
    • Troubleshooting guide

Delivery Requirements:

  • Complete GitHub repository
  • Video demo (5-10 minutes)
  • Live presentation
  • User acceptance testing report
  • Performance benchmark

🎯 EVALUASI FASE 3

Kriteria Penilaian:

  1. Functionality (40%):
    • Complete admin panel with all CRUD operations
    • Working publishing workflow
    • Role-based permissions
    • Advanced features implemented
  2. Code Quality (30%):
    • Clean Filament code structure
    • Proper use of observers and events
    • Database optimization
    • Error handling and validation
  3. UI/UX (20%):
    • User-friendly interface
    • Responsive design
    • Custom components
    • Good user feedback
  4. Documentation (10%):
    • Admin documentation
    • Code comments
    • Setup instructions

Kelulusan Minimum:

  • Mampu membuat admin panel dengan Filament
  • Memahami dan menerapkan complex relationships
  • Mampu implementasi business workflows
  • Siap untuk deployment dan maintenance

📚 RESOURCES TAMBAHAN FASE 3

Official Documentation:

  1. FilamentPHP Documentation
  2. Filament Shield Documentation
  3. Spatie Laravel Permission

Video Tutorials:

  1. FilamentPHP Official YouTube Channel
  2. Code With Tony - FilamentPHP Series
  3. Laravel Daily - Filament Tips

Community Resources:

  1. Filament Discord Community
  2. Laracasts Filament Forum
  3. Stack Overflow #filamentphp

Useful Packages:

  1. Filament Settings: For global settings management
  2. Filament Excel: For import/export functionality
  3. Filament Curator: For media management
  4. Filament Navigation: For advanced navigation

🚀 EXCELLENT WORK! Setelah menyelesaikan Fase 3, peserta telah memiliki:

  • Kemampuan membangun admin panel professional dengan Filament
  • Pengalaman implementasi complex business workflows
  • Skill database optimization dengan triggers dan observers
  • Portfolio project yang siap untuk production



Dilihat

209 kali

Trending

14