Tutorial Laravel 12 Filament 4 Bikin Web Point of Sales Sederhana

Tutorial lengkap membuat aplikasi Point of Sales (POS) sederhana menggunakan Laravel 12 dan Filament 4. Dalam tutorial ini, kamu akan belajar step-by-step dari setup project, membuat database schema, CRUD products dan categories, sistem transaksi, hingga dashboard dengan statistik penjualan. Cocok untuk developer yang ingin belajar Filament dan membangun portfolio project nyata.


Bagian 1: Intro & Setup Project

Halo! Saya Angga Risky, founder BuildWithAngga.

Di tutorial ini, kita akan membangun aplikasi Point of Sales (POS) sederhana menggunakan Laravel 12 dan Filament 4. Aplikasi ini cocok untuk toko kecil, warung, atau sebagai portfolio project.

Langsung saja, kita mulai.

Apa yang Akan Dibangun

FITUR APLIKASI POS:

├── Product Management
│   ├── CRUD products
│   ├── Categories
│   ├── Stock tracking
│   └── Image upload

├── Transaction System
│   ├── Create orders
│   ├── Multiple items per order
│   ├── Auto-calculate total
│   └── Stock reduction otomatis

├── POS Cashier Interface
│   ├── Product grid
│   ├── Cart system
│   └── Quick checkout

└── Dashboard & Reports
    ├── Sales statistics
    ├── Revenue charts
    ├── Low stock alerts
    └── Export reports

Requirements

Sebelum mulai, pastikan kamu sudah punya:

REQUIREMENTS:

├── PHP 8.2 atau lebih baru
├── Composer
├── Node.js & NPM
├── MySQL atau SQLite
├── Code editor (VS Code recommended)
└── Basic knowledge Laravel

Install Laravel 12

Buka terminal, jalankan command berikut:

# Create new Laravel project
composer create-project laravel/laravel pos-app

# Masuk ke directory project
cd pos-app

# Jalankan development server
php artisan serve

Buka browser, akses http://localhost:8000. Kalau muncul welcome page Laravel, instalasi berhasil.

Install Filament 4

Masih di terminal yang sama:

# Install Filament
composer require filament/filament:"^4.0"

# Install admin panel
php artisan filament:install --panels

Saat instalasi, Filament akan bertanya beberapa hal. Ikuti default saja dengan tekan Enter.

Selanjutnya, buat user admin:

php artisan make:filament-user

Masukkan name, email, dan password untuk admin. Ingat credentials ini.

Setup Database

Buka file .env di root project, edit bagian database:

Untuk MySQL:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=pos_db
DB_USERNAME=root
DB_PASSWORD=

Untuk SQLite (lebih simple untuk development):

DB_CONNECTION=sqlite
# Comment atau hapus DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD

Kalau pakai SQLite, buat file database:

touch database/database.sqlite

Jalankan migration default Laravel:

php artisan migrate

Test Filament Admin Panel

Jalankan server kalau belum:

php artisan serve

Buka browser, akses http://localhost:8000/admin. Login dengan credentials yang sudah dibuat tadi.

Kalau muncul dashboard Filament, setup berhasil! 🎉

CHECKPOINT BAGIAN 1:

✅ Laravel 12 terinstall
✅ Filament 4 terinstall
✅ Database configured
✅ Admin user created
✅ Bisa akses /admin


Bagian 2: Database Design & Migration

Sekarang kita design database untuk aplikasi POS.

ERD (Entity Relationship Diagram)

DATABASE SCHEMA:

┌─────────────────┐       ┌─────────────────┐
│   categories    │       │    products     │
├─────────────────┤       ├─────────────────┤
│ id              │───┐   │ id              │
│ name            │   │   │ category_id (FK)│───┐
│ slug            │   └──►│ name            │   │
│ description     │       │ slug            │   │
│ is_active       │       │ description     │   │
│ timestamps      │       │ price           │   │
└─────────────────┘       │ stock           │   │
                          │ image           │   │
                          │ is_active       │   │
                          │ timestamps      │   │
                          └─────────────────┘   │
                                                │
┌─────────────────┐       ┌─────────────────┐   │
│     orders      │       │   order_items   │   │
├─────────────────┤       ├─────────────────┤   │
│ id              │───┐   │ id              │   │
│ invoice_number  │   │   │ order_id (FK)   │───┘
│ customer_name   │   └──►│ product_id (FK) │────┘
│ total_amount    │       │ quantity        │
│ status          │       │ price           │
│ notes           │       │ subtotal        │
│ timestamps      │       │ timestamps      │
└─────────────────┘       └─────────────────┘

RELATIONSHIPS:
├── Category hasMany Products
├── Product belongsTo Category
├── Order hasMany OrderItems
├── OrderItem belongsTo Order
└── OrderItem belongsTo Product

Migration: Categories

Buat migration untuk tabel categories:

php artisan make:migration create_categories_table

Buka file migration yang baru dibuat di database/migrations/, edit:

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->boolean('is_active')->default(true);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};

Penjelasan:

  • name — Nama kategori (contoh: "Makanan", "Minuman")
  • slug — URL-friendly version dari name (contoh: "makanan", "minuman")
  • description — Deskripsi opsional
  • is_active — Untuk soft-disable kategori tanpa hapus

Migration: Products

php artisan make:migration create_products_table

Edit file migration:

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->foreignId('category_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->decimal('price', 12, 2);
            $table->integer('stock')->default(0);
            $table->string('image')->nullable();
            $table->boolean('is_active')->default(true);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

Penjelasan:

  • category_id — Foreign key ke tabel categories, cascade on delete
  • price — Decimal dengan 12 digit total, 2 digit desimal (max: 9,999,999,999.99)
  • stock — Jumlah stok, default 0
  • image — Path ke file gambar produk

Migration: Orders

php artisan make:migration create_orders_table

Edit file migration:

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->string('invoice_number')->unique();
            $table->string('customer_name')->nullable();
            $table->decimal('total_amount', 12, 2)->default(0);
            $table->enum('status', ['pending', 'completed', 'cancelled'])->default('pending');
            $table->text('notes')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

Penjelasan:

  • invoice_number — Nomor invoice unik (auto-generate nanti)
  • customer_name — Nama customer, nullable untuk walk-in customer
  • total_amount — Total harga, di-calculate dari order items
  • status — Status order: pending, completed, atau cancelled

Migration: Order Items

php artisan make:migration create_order_items_table

Edit file migration:

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('order_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')->constrained()->cascadeOnDelete();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->integer('quantity');
            $table->decimal('price', 12, 2);
            $table->decimal('subtotal', 12, 2);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('order_items');
    }
};

Penjelasan:

  • order_id — Foreign key ke orders
  • product_id — Foreign key ke products
  • quantity — Jumlah item yang dibeli
  • price — Harga per item saat transaksi (snapshot, tidak berubah kalau harga product berubah)
  • subtotal — quantity × price

Jalankan Semua Migration

php artisan migrate

Output yang diharapkan:

INFO  Running migrations.

2024_01_01_000001_create_categories_table .... 15ms DONE
2024_01_01_000002_create_products_table ...... 18ms DONE
2024_01_01_000003_create_orders_table ........ 12ms DONE
2024_01_01_000004_create_order_items_table ... 14ms DONE

CHECKPOINT BAGIAN 2:

✅ Migration categories created
✅ Migration products created
✅ Migration orders created
✅ Migration order_items created
✅ All migrations executed


Bagian 3: Models & Relationships

Sekarang kita buat Models dan setup relationships antar tabel.

Model Category

Buat model:

php artisan make:model Category

Edit app/Models/Category.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Support\\Str;

class Category extends Model
{
    protected $fillable = [
        'name',
        'slug',
        'description',
        'is_active',
    ];

    protected $casts = [
        'is_active' => 'boolean',
    ];

    /**
     * Auto-generate slug saat creating
     */
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($category) {
            if (empty($category->slug)) {
                $category->slug = Str::slug($category->name);
            }
        });

        static::updating(function ($category) {
            if ($category->isDirty('name') && empty($category->slug)) {
                $category->slug = Str::slug($category->name);
            }
        });
    }

    /**
     * Relationship: Category has many Products
     */
    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }

    /**
     * Scope: Only active categories
     */
    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }
}

Penjelasan:

  • $fillable — Field yang boleh mass-assigned
  • $casts — Auto-cast is_active ke boolean
  • boot() — Auto-generate slug dari name saat create
  • products() — Relationship one-to-many ke Product
  • scopeActive() — Query scope untuk filter active only

Model Product

php artisan make:model Product

Edit app/Models/Product.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
use Illuminate\\Support\\Str;

class Product extends Model
{
    protected $fillable = [
        'category_id',
        'name',
        'slug',
        'description',
        'price',
        'stock',
        'image',
        'is_active',
    ];

    protected $casts = [
        'price' => 'decimal:2',
        'is_active' => 'boolean',
    ];

    /**
     * Auto-generate slug saat creating
     */
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($product) {
            if (empty($product->slug)) {
                $product->slug = Str::slug($product->name);
            }
        });
    }

    /**
     * Relationship: Product belongs to Category
     */
    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    /**
     * Relationship: Product has many OrderItems
     */
    public function orderItems(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    /**
     * Check if product is in stock
     */
    public function inStock(): bool
    {
        return $this->stock > 0;
    }

    /**
     * Reduce stock after order
     */
    public function reduceStock(int $quantity): void
    {
        $this->decrement('stock', $quantity);
    }

    /**
     * Increase stock (for restocking or order cancellation)
     */
    public function increaseStock(int $quantity): void
    {
        $this->increment('stock', $quantity);
    }

    /**
     * Get formatted price (Rp)
     */
    public function getFormattedPriceAttribute(): string
    {
        return 'Rp ' . number_format($this->price, 0, ',', '.');
    }

    /**
     * Scope: Only active products
     */
    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }

    /**
     * Scope: Only products with stock
     */
    public function scopeInStock($query)
    {
        return $query->where('stock', '>', 0);
    }

    /**
     * Scope: Low stock products (stock <= 10)
     */
    public function scopeLowStock($query)
    {
        return $query->where('stock', '<=', 10)->where('stock', '>', 0);
    }
}

Penjelasan:

  • category() — Belongs to Category
  • orderItems() — Has many OrderItem (history penjualan)
  • reduceStock() — Kurangi stok setelah order
  • increaseStock() — Tambah stok (restock atau cancel order)
  • getFormattedPriceAttribute() — Accessor untuk format Rupiah
  • Scopes untuk filter active, in stock, low stock

Model Order

php artisan make:model Order

Edit app/Models/Order.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Order extends Model
{
    protected $fillable = [
        'invoice_number',
        'customer_name',
        'total_amount',
        'status',
        'notes',
    ];

    protected $casts = [
        'total_amount' => 'decimal:2',
    ];

    /**
     * Auto-generate invoice number saat creating
     */
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($order) {
            if (empty($order->invoice_number)) {
                $order->invoice_number = self::generateInvoiceNumber();
            }
        });
    }

    /**
     * Generate unique invoice number
     * Format: INV-YYYYMMDD-XXXX
     */
    public static function generateInvoiceNumber(): string
    {
        $today = now()->format('Ymd');
        $count = self::whereDate('created_at', today())->count() + 1;

        return 'INV-' . $today . '-' . str_pad($count, 4, '0', STR_PAD_LEFT);
    }

    /**
     * Relationship: Order has many OrderItems
     */
    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }

    /**
     * Calculate and update total from items
     */
    public function calculateTotal(): void
    {
        $this->total_amount = $this->items->sum('subtotal');
        $this->save();
    }

    /**
     * Get formatted total (Rp)
     */
    public function getFormattedTotalAttribute(): string
    {
        return 'Rp ' . number_format($this->total_amount, 0, ',', '.');
    }

    /**
     * Mark order as completed
     */
    public function markAsCompleted(): void
    {
        $this->update(['status' => 'completed']);
    }

    /**
     * Mark order as cancelled and restore stock
     */
    public function markAsCancelled(): void
    {
        // Restore stock for each item
        foreach ($this->items as $item) {
            $item->product->increaseStock($item->quantity);
        }

        $this->update(['status' => 'cancelled']);
    }

    /**
     * Scope: Completed orders only
     */
    public function scopeCompleted($query)
    {
        return $query->where('status', 'completed');
    }

    /**
     * Scope: Pending orders only
     */
    public function scopePending($query)
    {
        return $query->where('status', 'pending');
    }

    /**
     * Scope: Today's orders
     */
    public function scopeToday($query)
    {
        return $query->whereDate('created_at', today());
    }

    /**
     * Scope: This month's orders
     */
    public function scopeThisMonth($query)
    {
        return $query->whereMonth('created_at', now()->month)
                     ->whereYear('created_at', now()->year);
    }
}

Penjelasan:

  • generateInvoiceNumber() — Auto-generate invoice: INV-20240115-0001
  • items() — Has many OrderItem
  • calculateTotal() — Hitung total dari semua items
  • markAsCompleted() — Update status ke completed
  • markAsCancelled() — Cancel order dan restore stock
  • Scopes untuk filter by status dan tanggal

Model OrderItem

php artisan make:model OrderItem

Edit app/Models/OrderItem.php:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;

class OrderItem extends Model
{
    protected $fillable = [
        'order_id',
        'product_id',
        'quantity',
        'price',
        'subtotal',
    ];

    protected $casts = [
        'price' => 'decimal:2',
        'subtotal' => 'decimal:2',
    ];

    /**
     * Boot method untuk auto-calculate dan stock reduction
     */
    protected static function boot()
    {
        parent::boot();

        // Auto-calculate subtotal before creating
        static::creating(function ($item) {
            $item->subtotal = $item->price * $item->quantity;
        });

        // Auto-calculate subtotal before updating
        static::updating(function ($item) {
            $item->subtotal = $item->price * $item->quantity;
        });

        // Reduce product stock after item created
        static::created(function ($item) {
            if ($item->product) {
                $item->product->reduceStock($item->quantity);
            }
        });

        // Update order total after item created
        static::saved(function ($item) {
            if ($item->order) {
                $item->order->calculateTotal();
            }
        });

        // Restore stock and recalculate total when item deleted
        static::deleting(function ($item) {
            if ($item->product && $item->order->status !== 'cancelled') {
                $item->product->increaseStock($item->quantity);
            }
        });

        static::deleted(function ($item) {
            if ($item->order) {
                $item->order->calculateTotal();
            }
        });
    }

    /**
     * Relationship: OrderItem belongs to Order
     */
    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    /**
     * Relationship: OrderItem belongs to Product
     */
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    /**
     * Get formatted subtotal (Rp)
     */
    public function getFormattedSubtotalAttribute(): string
    {
        return 'Rp ' . number_format($this->subtotal, 0, ',', '.');
    }
}

Penjelasan:

  • boot() dengan event hooks:
    • creating — Auto-calculate subtotal sebelum save
    • created — Kurangi stock product setelah item dibuat
    • saved — Update total order setelah item di-save
    • deleting — Kembalikan stock saat item dihapus
    • deleted — Recalculate total order saat item dihapus
  • order() — Belongs to Order
  • product() — Belongs to Product

Verifikasi Models

Untuk memastikan semua berjalan dengan baik, kamu bisa test di Tinker:

php artisan tinker

// Test create category
$category = \\App\\Models\\Category::create(['name' => 'Makanan']);
// slug akan auto-generated: "makanan"

// Test create product
$product = \\App\\Models\\Product::create([
    'category_id' => $category->id,
    'name' => 'Nasi Goreng',
    'price' => 25000,
    'stock' => 100
]);
// slug: "nasi-goreng"

// Test relationship
$product->category->name; // "Makanan"
$category->products; // Collection of products

// Test formatted price
$product->formatted_price; // "Rp 25.000"

// Exit tinker
exit

CHECKPOINT BAGIAN 3:

✅ Model Category dengan relationships
✅ Model Product dengan helpers dan scopes
✅ Model Order dengan auto-invoice
✅ Model OrderItem dengan auto-calculate dan stock management
✅ Semua relationships configured
✅ Auto-generate slug working
✅ Stock reduction on order working


Struktur File Sejauh Ini

pos-app/
├── app/
│   └── Models/
│       ├── Category.php      ✅
│       ├── Product.php       ✅
│       ├── Order.php         ✅
│       ├── OrderItem.php     ✅
│       └── User.php          (default Laravel)
│
├── database/
│   └── migrations/
│       ├── create_users_table.php           (default)
│       ├── create_categories_table.php      ✅
│       ├── create_products_table.php        ✅
│       ├── create_orders_table.php          ✅
│       └── create_order_items_table.php     ✅
│
└── ... (other Laravel files)


Summary Bagian 1-3

Di bagian 1-3, kita sudah:

  1. Setup Project
    • Install Laravel 12
    • Install Filament 4
    • Setup database
    • Create admin user
  2. Design Database
    • 4 tabel: categories, products, orders, order_items
    • Proper foreign keys dan relationships
    • Semua migrations executed
  3. Create Models
    • Category dengan auto-slug dan scope
    • Product dengan stock management dan formatting
    • Order dengan auto-invoice dan status management
    • OrderItem dengan auto-calculate dan stock hooks

Next: Bagian 4-6

  • Filament Resource untuk Categories
  • Filament Resource untuk Products
  • Filament Resource untuk Orders dengan Repeater

Bagian 4: Filament Resource - Categories

Sekarang kita mulai bikin CRUD dengan Filament. Mulai dari yang paling simple: Categories.

Generate Resource

php artisan make:filament-resource Category --generate

Flag --generate akan auto-generate form dan table berdasarkan database columns.

Filament akan membuat beberapa file:

app/Filament/Resources/
├── CategoryResource.php
└── CategoryResource/
    └── Pages/
        ├── CreateCategory.php
        ├── EditCategory.php
        └── ListCategories.php

Edit CategoryResource

Buka app/Filament/Resources/CategoryResource.php, replace seluruh isinya:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\CategoryResource\\Pages;
use App\\Models\\Category;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Support\\Str;

class CategoryResource extends Resource
{
    protected static ?string $model = Category::class;

    protected static ?string $navigationIcon = 'heroicon-o-tag';

    protected static ?string $navigationGroup = 'Master Data';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Category Information')
                    ->description('Manage product categories')
                    ->schema([
                        Forms\\Components\\TextInput::make('name')
                            ->required()
                            ->maxLength(255)
                            ->live(onBlur: true)
                            ->afterStateUpdated(function (string $operation, $state, Forms\\Set $set) {
                                if ($operation !== 'create') {
                                    return;
                                }
                                $set('slug', Str::slug($state));
                            }),

                        Forms\\Components\\TextInput::make('slug')
                            ->required()
                            ->maxLength(255)
                            ->unique(ignoreRecord: true)
                            ->dehydrated()
                            ->helperText('Auto-generated from name, but you can customize it'),

                        Forms\\Components\\Textarea::make('description')
                            ->maxLength(500)
                            ->rows(3)
                            ->columnSpanFull(),

                        Forms\\Components\\Toggle::make('is_active')
                            ->label('Active')
                            ->default(true)
                            ->helperText('Inactive categories won\\'t show in product form'),
                    ])
                    ->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable()
                    ->weight('bold'),

                Tables\\Columns\\TextColumn::make('slug')
                    ->searchable()
                    ->color('gray')
                    ->toggleable(isToggledHiddenByDefault: true),

                Tables\\Columns\\TextColumn::make('products_count')
                    ->label('Products')
                    ->counts('products')
                    ->sortable()
                    ->badge()
                    ->color('info'),

                Tables\\Columns\\IconColumn::make('is_active')
                    ->label('Active')
                    ->boolean()
                    ->trueIcon('heroicon-o-check-circle')
                    ->falseIcon('heroicon-o-x-circle')
                    ->trueColor('success')
                    ->falseColor('danger'),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime('d M Y')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\TernaryFilter::make('is_active')
                    ->label('Active Status')
                    ->boolean()
                    ->trueLabel('Active only')
                    ->falseLabel('Inactive only')
                    ->native(false),
            ])
            ->actions([
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make()
                    ->before(function (Category $record) {
                        // Optional: Check if category has products
                        if ($record->products()->count() > 0) {
                            throw new \\Exception('Cannot delete category with products');
                        }
                    }),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ])
            ->defaultSort('name', 'asc')
            ->striped();
    }

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

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

    public static function getNavigationBadge(): ?string
    {
        return static::getModel()::count();
    }
}

Penjelasan:

Navigation:

  • $navigationIcon — Icon dari Heroicons
  • $navigationGroup — Grouping di sidebar
  • $navigationSort — Urutan di sidebar
  • getNavigationBadge() — Badge dengan total count

Form:

  • live(onBlur: true) — Trigger event saat input blur
  • afterStateUpdated() — Auto-fill slug dari name
  • unique(ignoreRecord: true) — Unique validation, ignore current record saat edit
  • columnSpanFull() — Span 2 columns

Table:

  • counts('products') — Count relationship
  • toggleable(isToggledHiddenByDefault: true) — Hidden by default, bisa di-toggle
  • TernaryFilter — Filter dengan 3 state: all, true, false
  • striped() — Zebra striping

Test Category CRUD

  1. Buka http://localhost:8000/admin
  2. Klik "Categories" di sidebar
  3. Klik "New Category"
  4. Isi: Name = "Makanan", lihat slug auto-generate
  5. Save
  6. Coba edit, delete
CHECKPOINT BAGIAN 4:

✅ CategoryResource created
✅ Form dengan auto-slug
✅ Table dengan product count
✅ Filter active status
✅ Navigation dengan badge


Bagian 5: Filament Resource - Products

Product lebih complex karena ada relasi ke Category dan image upload.

Generate Resource

php artisan make:filament-resource Product --generate

Setup Storage Link

Untuk image upload, kita perlu storage link:

php artisan storage:link

Edit ProductResource

Buka app/Filament/Resources/ProductResource.php, replace:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\ProductResource\\Pages;
use App\\Models\\Product;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Support\\Str;

class ProductResource extends Resource
{
    protected static ?string $model = Product::class;

    protected static ?string $navigationIcon = 'heroicon-o-cube';

    protected static ?string $navigationGroup = 'Master Data';

    protected static ?int $navigationSort = 2;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                // Left Column - Main Info
                Forms\\Components\\Group::make()
                    ->schema([
                        Forms\\Components\\Section::make('Product Information')
                            ->schema([
                                Forms\\Components\\TextInput::make('name')
                                    ->required()
                                    ->maxLength(255)
                                    ->live(onBlur: true)
                                    ->afterStateUpdated(function (string $operation, $state, Forms\\Set $set) {
                                        if ($operation !== 'create') {
                                            return;
                                        }
                                        $set('slug', Str::slug($state));
                                    }),

                                Forms\\Components\\TextInput::make('slug')
                                    ->required()
                                    ->maxLength(255)
                                    ->unique(ignoreRecord: true)
                                    ->dehydrated(),

                                Forms\\Components\\Select::make('category_id')
                                    ->relationship('category', 'name')
                                    ->required()
                                    ->searchable()
                                    ->preload()
                                    ->native(false)
                                    ->createOptionForm([
                                        Forms\\Components\\TextInput::make('name')
                                            ->required()
                                            ->maxLength(255)
                                            ->live(onBlur: true)
                                            ->afterStateUpdated(fn ($state, Forms\\Set $set) =>
                                                $set('slug', Str::slug($state))
                                            ),
                                        Forms\\Components\\TextInput::make('slug')
                                            ->required()
                                            ->maxLength(255),
                                    ]),

                                Forms\\Components\\RichEditor::make('description')
                                    ->maxLength(2000)
                                    ->columnSpanFull()
                                    ->toolbarButtons([
                                        'bold',
                                        'italic',
                                        'bulletList',
                                        'orderedList',
                                    ]),
                            ])
                            ->columns(2),

                        Forms\\Components\\Section::make('Pricing & Inventory')
                            ->schema([
                                Forms\\Components\\TextInput::make('price')
                                    ->required()
                                    ->numeric()
                                    ->prefix('Rp')
                                    ->maxValue(9999999999)
                                    ->default(0),

                                Forms\\Components\\TextInput::make('stock')
                                    ->required()
                                    ->numeric()
                                    ->minValue(0)
                                    ->default(0)
                                    ->suffixIcon('heroicon-o-archive-box'),
                            ])
                            ->columns(2),
                    ])
                    ->columnSpan(['lg' => 2]),

                // Right Column - Image & Status
                Forms\\Components\\Group::make()
                    ->schema([
                        Forms\\Components\\Section::make('Product Image')
                            ->schema([
                                Forms\\Components\\FileUpload::make('image')
                                    ->image()
                                    ->directory('products')
                                    ->imageEditor()
                                    ->imageEditorAspectRatios([
                                        '1:1',
                                        '4:3',
                                        '16:9',
                                    ])
                                    ->maxSize(2048)
                                    ->helperText('Max 2MB. Recommended: 500x500px'),
                            ]),

                        Forms\\Components\\Section::make('Status')
                            ->schema([
                                Forms\\Components\\Toggle::make('is_active')
                                    ->label('Active')
                                    ->default(true)
                                    ->helperText('Inactive products won\\'t appear in POS'),
                            ]),
                    ])
                    ->columnSpan(['lg' => 1]),
            ])
            ->columns(3);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\ImageColumn::make('image')
                    ->square()
                    ->size(50)
                    ->defaultImageUrl(fn () => '<https://via.placeholder.com/50?text=No+Image>'),

                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable()
                    ->weight('bold')
                    ->description(fn (Product $record): string =>
                        $record->category?->name ?? 'No Category'
                    ),

                Tables\\Columns\\TextColumn::make('category.name')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),

                Tables\\Columns\\TextColumn::make('price')
                    ->money('IDR')
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('stock')
                    ->sortable()
                    ->badge()
                    ->color(fn (int $state): string => match(true) {
                        $state <= 0 => 'danger',
                        $state <= 10 => 'warning',
                        default => 'success',
                    })
                    ->icon(fn (int $state): string => match(true) {
                        $state <= 0 => 'heroicon-o-x-circle',
                        $state <= 10 => 'heroicon-o-exclamation-triangle',
                        default => 'heroicon-o-check-circle',
                    }),

                Tables\\Columns\\IconColumn::make('is_active')
                    ->label('Active')
                    ->boolean(),

                Tables\\Columns\\TextColumn::make('updated_at')
                    ->label('Last Updated')
                    ->dateTime('d M Y H:i')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('category')
                    ->relationship('category', 'name')
                    ->searchable()
                    ->preload()
                    ->native(false),

                Tables\\Filters\\TernaryFilter::make('is_active')
                    ->label('Status')
                    ->boolean()
                    ->trueLabel('Active')
                    ->falseLabel('Inactive')
                    ->native(false),

                Tables\\Filters\\Filter::make('low_stock')
                    ->label('Low Stock (≤ 10)')
                    ->query(fn ($query) => $query->where('stock', '<=', 10)->where('stock', '>', 0))
                    ->toggle(),

                Tables\\Filters\\Filter::make('out_of_stock')
                    ->label('Out of Stock')
                    ->query(fn ($query) => $query->where('stock', '<=', 0))
                    ->toggle(),
            ])
            ->actions([
                Tables\\Actions\\ActionGroup::make([
                    Tables\\Actions\\ViewAction::make(),
                    Tables\\Actions\\EditAction::make(),
                    Tables\\Actions\\Action::make('adjustStock')
                        ->label('Adjust Stock')
                        ->icon('heroicon-o-archive-box-arrow-down')
                        ->color('warning')
                        ->form([
                            Forms\\Components\\Radio::make('type')
                                ->options([
                                    'add' => 'Add Stock',
                                    'subtract' => 'Subtract Stock',
                                    'set' => 'Set Stock',
                                ])
                                ->default('add')
                                ->required()
                                ->inline(),
                            Forms\\Components\\TextInput::make('quantity')
                                ->numeric()
                                ->required()
                                ->minValue(0),
                        ])
                        ->action(function (Product $record, array $data) {
                            $quantity = (int) $data['quantity'];

                            match($data['type']) {
                                'add' => $record->increment('stock', $quantity),
                                'subtract' => $record->decrement('stock', $quantity),
                                'set' => $record->update(['stock' => $quantity]),
                            };
                        }),
                    Tables\\Actions\\DeleteAction::make(),
                ]),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                    Tables\\Actions\\BulkAction::make('activate')
                        ->label('Activate')
                        ->icon('heroicon-o-check')
                        ->color('success')
                        ->action(fn ($records) => $records->each->update(['is_active' => true])),
                    Tables\\Actions\\BulkAction::make('deactivate')
                        ->label('Deactivate')
                        ->icon('heroicon-o-x-mark')
                        ->color('danger')
                        ->action(fn ($records) => $records->each->update(['is_active' => false])),
                ]),
            ])
            ->defaultSort('name', 'asc')
            ->striped();
    }

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

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

    public static function getNavigationBadge(): ?string
    {
        return static::getModel()::count();
    }

    public static function getNavigationBadgeColor(): ?string
    {
        $lowStock = static::getModel()::where('stock', '<=', 10)->count();
        return $lowStock > 0 ? 'warning' : 'primary';
    }
}

Penjelasan:

Form Layout:

  • Group::make()->columnSpan(['lg' => 2]) — 2 column di large screen
  • columns(3) di form level — Total 3 columns

Select with Create:

  • createOptionForm() — Bisa create category langsung dari product form
  • preload() — Load options di awal (better UX)
  • native(false) — Pakai Filament select, bukan native HTML

Image Upload:

  • imageEditor() — Built-in image editor
  • imageEditorAspectRatios() — Pilihan crop ratio
  • directory('products') — Folder penyimpanan

Table dengan Custom Actions:

  • ActionGroup — Dropdown menu untuk actions
  • adjustStock action — Custom action untuk adjust stock
  • Bulk actions untuk activate/deactivate

Dynamic Badge:

  • getNavigationBadgeColor() — Warning jika ada low stock

Test Product CRUD

  1. Buka http://localhost:8000/admin/products
  2. Klik "New Product"
  3. Test semua fields, upload image
  4. Test adjust stock action
  5. Test filters
CHECKPOINT BAGIAN 5:

✅ ProductResource dengan image upload
✅ Category select dengan create option
✅ Stock management dengan custom action
✅ Filters: category, status, low stock
✅ Bulk actions: activate/deactivate
✅ Navigation badge dengan warning color


Bagian 6: Filament Resource - Orders

Order adalah yang paling complex karena ada Repeater untuk order items dan auto-calculation.

Generate Resource

php artisan make:filament-resource Order --generate

Edit OrderResource

Buka app/Filament/Resources/OrderResource.php, replace:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\OrderResource\\Pages;
use App\\Models\\Order;
use App\\Models\\Product;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Forms\\Get;
use Filament\\Forms\\Set;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Notifications\\Notification;
use Illuminate\\Database\\Eloquent\\Builder;

class OrderResource extends Resource
{
    protected static ?string $model = Order::class;

    protected static ?string $navigationIcon = 'heroicon-o-shopping-cart';

    protected static ?string $navigationGroup = 'Transactions';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                // Left Column - Order Info & Items
                Forms\\Components\\Group::make()
                    ->schema([
                        Forms\\Components\\Section::make('Order Information')
                            ->schema([
                                Forms\\Components\\TextInput::make('invoice_number')
                                    ->default(fn () => Order::generateInvoiceNumber())
                                    ->disabled()
                                    ->dehydrated()
                                    ->unique(ignoreRecord: true),

                                Forms\\Components\\TextInput::make('customer_name')
                                    ->maxLength(255)
                                    ->placeholder('Walk-in Customer')
                                    ->helperText('Leave empty for walk-in customer'),

                                Forms\\Components\\Select::make('status')
                                    ->options([
                                        'pending' => 'Pending',
                                        'completed' => 'Completed',
                                        'cancelled' => 'Cancelled',
                                    ])
                                    ->default('pending')
                                    ->required()
                                    ->native(false)
                                    ->selectablePlaceholder(false),

                                Forms\\Components\\Textarea::make('notes')
                                    ->maxLength(500)
                                    ->rows(2)
                                    ->columnSpanFull()
                                    ->placeholder('Additional notes...'),
                            ])
                            ->columns(2),

                        Forms\\Components\\Section::make('Order Items')
                            ->schema([
                                Forms\\Components\\Repeater::make('items')
                                    ->relationship()
                                    ->schema([
                                        Forms\\Components\\Select::make('product_id')
                                            ->label('Product')
                                            ->options(function () {
                                                return Product::query()
                                                    ->where('is_active', true)
                                                    ->where('stock', '>', 0)
                                                    ->get()
                                                    ->mapWithKeys(fn ($product) => [
                                                        $product->id => "{$product->name} (Stock: {$product->stock}) - {$product->formatted_price}"
                                                    ]);
                                            })
                                            ->required()
                                            ->searchable()
                                            ->native(false)
                                            ->reactive()
                                            ->afterStateUpdated(function ($state, Set $set, Get $get) {
                                                if ($state) {
                                                    $product = Product::find($state);
                                                    if ($product) {
                                                        $set('price', $product->price);
                                                        $quantity = $get('quantity') ?? 1;
                                                        $set('subtotal', $product->price * $quantity);
                                                    }
                                                }
                                            })
                                            ->disableOptionsWhenSelectedInSiblingRepeaterItems()
                                            ->columnSpan(3),

                                        Forms\\Components\\TextInput::make('quantity')
                                            ->numeric()
                                            ->default(1)
                                            ->minValue(1)
                                            ->required()
                                            ->reactive()
                                            ->afterStateUpdated(function ($state, Set $set, Get $get) {
                                                $price = $get('price') ?? 0;
                                                $set('subtotal', $price * ($state ?? 1));
                                            })
                                            ->columnSpan(1),

                                        Forms\\Components\\TextInput::make('price')
                                            ->numeric()
                                            ->prefix('Rp')
                                            ->disabled()
                                            ->dehydrated()
                                            ->columnSpan(2),

                                        Forms\\Components\\TextInput::make('subtotal')
                                            ->numeric()
                                            ->prefix('Rp')
                                            ->disabled()
                                            ->dehydrated()
                                            ->columnSpan(2),
                                    ])
                                    ->columns(8)
                                    ->addActionLabel('+ Add Item')
                                    ->reorderable(false)
                                    ->collapsible()
                                    ->cloneable()
                                    ->live()
                                    ->afterStateUpdated(function (Get $get, Set $set) {
                                        self::updateTotalAmount($get, $set);
                                    })
                                    ->deleteAction(
                                        fn ($action) => $action->after(fn (Get $get, Set $set) =>
                                            self::updateTotalAmount($get, $set)
                                        )
                                    )
                                    ->itemLabel(fn (array $state): ?string =>
                                        $state['product_id']
                                            ? Product::find($state['product_id'])?->name
                                            : null
                                    ),
                            ]),
                    ])
                    ->columnSpan(['lg' => 2]),

                // Right Column - Summary
                Forms\\Components\\Group::make()
                    ->schema([
                        Forms\\Components\\Section::make('Order Summary')
                            ->schema([
                                Forms\\Components\\Placeholder::make('items_count')
                                    ->label('Total Items')
                                    ->content(function (Get $get): string {
                                        $items = $get('items') ?? [];
                                        $totalQty = collect($items)->sum('quantity');
                                        return $totalQty . ' item(s)';
                                    }),

                                Forms\\Components\\Placeholder::make('total_display')
                                    ->label('Grand Total')
                                    ->content(function (Get $get): string {
                                        $items = $get('items') ?? [];
                                        $total = collect($items)->sum('subtotal');
                                        return 'Rp ' . number_format($total, 0, ',', '.');
                                    })
                                    ->extraAttributes(['class' => 'text-xl font-bold text-primary-600']),

                                Forms\\Components\\Hidden::make('total_amount')
                                    ->default(0),
                            ]),

                        Forms\\Components\\Section::make('Quick Info')
                            ->schema([
                                Forms\\Components\\Placeholder::make('created_info')
                                    ->label('Created')
                                    ->content(fn (?Order $record): string =>
                                        $record ? $record->created_at->format('d M Y H:i') : 'New Order'
                                    )
                                    ->visible(fn (?Order $record): bool => $record !== null),
                            ])
                            ->visible(fn (?Order $record): bool => $record !== null),
                    ])
                    ->columnSpan(['lg' => 1]),
            ])
            ->columns(3);
    }

    public static function updateTotalAmount(Get $get, Set $set): void
    {
        $items = $get('items') ?? [];
        $total = collect($items)->sum('subtotal');
        $set('total_amount', $total);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('invoice_number')
                    ->searchable()
                    ->sortable()
                    ->weight('bold')
                    ->copyable()
                    ->copyMessage('Invoice copied!')
                    ->icon('heroicon-o-document-text'),

                Tables\\Columns\\TextColumn::make('customer_name')
                    ->default('Walk-in Customer')
                    ->searchable()
                    ->icon('heroicon-o-user'),

                Tables\\Columns\\TextColumn::make('items_count')
                    ->label('Items')
                    ->counts('items')
                    ->badge()
                    ->color('info'),

                Tables\\Columns\\TextColumn::make('total_amount')
                    ->money('IDR')
                    ->sortable()
                    ->weight('bold')
                    ->color('success'),

                Tables\\Columns\\TextColumn::make('status')
                    ->badge()
                    ->color(fn (string $state): string => match($state) {
                        'pending' => 'warning',
                        'completed' => 'success',
                        'cancelled' => 'danger',
                        default => 'gray',
                    })
                    ->icon(fn (string $state): string => match($state) {
                        'pending' => 'heroicon-o-clock',
                        'completed' => 'heroicon-o-check-circle',
                        'cancelled' => 'heroicon-o-x-circle',
                        default => 'heroicon-o-question-mark-circle',
                    }),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Date')
                    ->dateTime('d M Y H:i')
                    ->sortable()
                    ->description(fn (Order $record): string =>
                        $record->created_at->diffForHumans()
                    ),
            ])
            ->defaultSort('created_at', 'desc')
            ->filters([
                Tables\\Filters\\SelectFilter::make('status')
                    ->options([
                        'pending' => 'Pending',
                        'completed' => 'Completed',
                        'cancelled' => 'Cancelled',
                    ])
                    ->native(false),

                Tables\\Filters\\Filter::make('created_at')
                    ->form([
                        Forms\\Components\\DatePicker::make('from')
                            ->label('From Date'),
                        Forms\\Components\\DatePicker::make('until')
                            ->label('Until Date'),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when(
                                $data['from'],
                                fn (Builder $q, $date): Builder => $q->whereDate('created_at', '>=', $date)
                            )
                            ->when(
                                $data['until'],
                                fn (Builder $q, $date): Builder => $q->whereDate('created_at', '<=', $date)
                            );
                    })
                    ->indicateUsing(function (array $data): array {
                        $indicators = [];
                        if ($data['from'] ?? null) {
                            $indicators['from'] = 'From: ' . \\Carbon\\Carbon::parse($data['from'])->format('d M Y');
                        }
                        if ($data['until'] ?? null) {
                            $indicators['until'] = 'Until: ' . \\Carbon\\Carbon::parse($data['until'])->format('d M Y');
                        }
                        return $indicators;
                    }),

                Tables\\Filters\\Filter::make('today')
                    ->label('Today')
                    ->query(fn (Builder $query): Builder => $query->whereDate('created_at', today()))
                    ->toggle(),
            ])
            ->actions([
                Tables\\Actions\\ActionGroup::make([
                    Tables\\Actions\\ViewAction::make(),
                    Tables\\Actions\\EditAction::make()
                        ->visible(fn (Order $record): bool => $record->status === 'pending'),

                    Tables\\Actions\\Action::make('complete')
                        ->label('Mark Complete')
                        ->icon('heroicon-o-check')
                        ->color('success')
                        ->requiresConfirmation()
                        ->modalHeading('Complete Order')
                        ->modalDescription('Are you sure you want to mark this order as completed?')
                        ->visible(fn (Order $record): bool => $record->status === 'pending')
                        ->action(function (Order $record) {
                            $record->update(['status' => 'completed']);

                            Notification::make()
                                ->title('Order Completed')
                                ->body("Order {$record->invoice_number} has been completed.")
                                ->success()
                                ->send();
                        }),

                    Tables\\Actions\\Action::make('cancel')
                        ->label('Cancel Order')
                        ->icon('heroicon-o-x-mark')
                        ->color('danger')
                        ->requiresConfirmation()
                        ->modalHeading('Cancel Order')
                        ->modalDescription('Are you sure? Stock will be restored.')
                        ->visible(fn (Order $record): bool => $record->status === 'pending')
                        ->action(function (Order $record) {
                            $record->markAsCancelled();

                            Notification::make()
                                ->title('Order Cancelled')
                                ->body("Order {$record->invoice_number} has been cancelled. Stock restored.")
                                ->warning()
                                ->send();
                        }),

                    Tables\\Actions\\Action::make('print')
                        ->label('Print Invoice')
                        ->icon('heroicon-o-printer')
                        ->color('gray')
                        ->url(fn (Order $record): string => route('invoice.print', $record))
                        ->openUrlInNewTab()
                        ->visible(false), // Enable when route is created

                    Tables\\Actions\\DeleteAction::make()
                        ->visible(fn (Order $record): bool => $record->status === 'cancelled'),
                ]),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\BulkAction::make('complete_all')
                        ->label('Complete Selected')
                        ->icon('heroicon-o-check')
                        ->color('success')
                        ->requiresConfirmation()
                        ->action(fn ($records) => $records->each(
                            fn ($record) => $record->status === 'pending' && $record->update(['status' => 'completed'])
                        ))
                        ->deselectRecordsAfterCompletion(),
                ]),
            ])
            ->striped()
            ->poll('30s'); // Auto-refresh every 30 seconds
    }

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

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListOrders::route('/'),
            'create' => Pages\\CreateOrder::route('/create'),
            'view' => Pages\\ViewOrder::route('/{record}'),
            'edit' => Pages\\EditOrder::route('/{record}/edit'),
        ];
    }

    public static function getNavigationBadge(): ?string
    {
        return static::getModel()::where('status', 'pending')->count() ?: null;
    }

    public static function getNavigationBadgeColor(): ?string
    {
        return 'warning';
    }
}

Create View Page

php artisan make:filament-page ViewOrder --resource=OrderResource --type=ViewRecord

Edit app/Filament/Resources/OrderResource/Pages/ViewOrder.php:

<?php

namespace App\\Filament\\Resources\\OrderResource\\Pages;

use App\\Filament\\Resources\\OrderResource;
use Filament\\Actions;
use Filament\\Resources\\Pages\\ViewRecord;
use Filament\\Infolists;
use Filament\\Infolists\\Infolist;

class ViewOrder extends ViewRecord
{
    protected static string $resource = OrderResource::class;

    protected function getHeaderActions(): array
    {
        return [
            Actions\\EditAction::make()
                ->visible(fn () => $this->record->status === 'pending'),
        ];
    }

    public function infolist(Infolist $infolist): Infolist
    {
        return $infolist
            ->schema([
                Infolists\\Components\\Section::make('Order Details')
                    ->schema([
                        Infolists\\Components\\TextEntry::make('invoice_number')
                            ->weight('bold')
                            ->copyable(),
                        Infolists\\Components\\TextEntry::make('customer_name')
                            ->default('Walk-in Customer'),
                        Infolists\\Components\\TextEntry::make('status')
                            ->badge()
                            ->color(fn (string $state): string => match($state) {
                                'pending' => 'warning',
                                'completed' => 'success',
                                'cancelled' => 'danger',
                                default => 'gray',
                            }),
                        Infolists\\Components\\TextEntry::make('created_at')
                            ->dateTime('d M Y H:i'),
                    ])
                    ->columns(4),

                Infolists\\Components\\Section::make('Order Items')
                    ->schema([
                        Infolists\\Components\\RepeatableEntry::make('items')
                            ->schema([
                                Infolists\\Components\\TextEntry::make('product.name')
                                    ->label('Product'),
                                Infolists\\Components\\TextEntry::make('quantity'),
                                Infolists\\Components\\TextEntry::make('price')
                                    ->money('IDR'),
                                Infolists\\Components\\TextEntry::make('subtotal')
                                    ->money('IDR')
                                    ->weight('bold'),
                            ])
                            ->columns(4),
                    ]),

                Infolists\\Components\\Section::make('Summary')
                    ->schema([
                        Infolists\\Components\\TextEntry::make('total_amount')
                            ->label('Grand Total')
                            ->money('IDR')
                            ->size('lg')
                            ->weight('bold')
                            ->color('success'),
                        Infolists\\Components\\TextEntry::make('notes')
                            ->columnSpanFull()
                            ->visible(fn ($record) => filled($record->notes)),
                    ])
                    ->columns(2),
            ]);
    }
}

Penjelasan:

Repeater Features:

  • relationship() — Auto-bind ke relationship items()
  • disableOptionsWhenSelectedInSiblingRepeaterItems() — Prevent duplicate products
  • cloneable() — Bisa duplicate item
  • collapsible() — Bisa collapse/expand
  • itemLabel() — Custom label untuk collapsed item
  • live() — Real-time updates
  • afterStateUpdated() — Recalculate total saat item berubah

Table Features:

  • copyable() — Bisa copy invoice number
  • poll('30s') — Auto-refresh setiap 30 detik
  • Custom actions: complete, cancel, print
  • Date range filter dengan indicators

View Page:

  • Infolist untuk display-only view
  • RepeatableEntry untuk items
  • Formatted dengan proper styling

Test Order CRUD

  1. Buka http://localhost:8000/admin/orders
  2. Create new order
  3. Add multiple items
  4. Lihat total auto-calculate
  5. Save dan test complete/cancel actions
  6. Cek stock berkurang setelah order
CHECKPOINT BAGIAN 6:

✅ OrderResource dengan Repeater items
✅ Auto-calculate total
✅ Product selection dengan stock info
✅ Prevent duplicate products dalam satu order
✅ Complete/Cancel actions dengan stock management
✅ View page dengan Infolist
✅ Date range filter
✅ Auto-refresh table


Struktur File Sejauh Ini

pos-app/
├── app/
│   ├── Filament/
│   │   └── Resources/
│   │       ├── CategoryResource.php          ✅
│   │       ├── CategoryResource/
│   │       │   └── Pages/
│   │       │       ├── CreateCategory.php
│   │       │       ├── EditCategory.php
│   │       │       └── ListCategories.php
│   │       ├── ProductResource.php           ✅
│   │       ├── ProductResource/
│   │       │   └── Pages/
│   │       │       ├── CreateProduct.php
│   │       │       ├── EditProduct.php
│   │       │       └── ListProducts.php
│   │       ├── OrderResource.php             ✅
│   │       └── OrderResource/
│   │           └── Pages/
│   │               ├── CreateOrder.php
│   │               ├── EditOrder.php
│   │               ├── ListOrders.php
│   │               └── ViewOrder.php         ✅
│   │
│   └── Models/
│       ├── Category.php
│       ├── Product.php
│       ├── Order.php
│       └── OrderItem.php
│
└── ... (other files)


Summary Bagian 4-6

Di bagian 4-6, kita sudah:

  1. CategoryResource
    • Form dengan auto-slug
    • Table dengan product count
    • Filter active status
    • Navigation badge
  2. ProductResource
    • Image upload dengan editor
    • Category select dengan inline create
    • Stock management action
    • Low stock & out of stock filters
    • Bulk activate/deactivate
  3. OrderResource
    • Repeater untuk order items
    • Auto-calculate total
    • Product selection dengan stock info
    • Complete/Cancel actions
    • Stock restoration on cancel
    • View page dengan Infolist
    • Date range filter
    • Auto-refresh table

Next: Bagian 7-10

  • Custom POS Cashier page
  • Dashboard widgets
  • Sales reports
  • Export to Excel

Bagian 7: Custom Page — POS Cashier

Sekarang kita buat interface kasir yang lebih user-friendly — grid produk, cart, dan quick checkout.

Create Custom Page

php artisan make:filament-page PosCashier

Edit PosCashier Page

Buka app/Filament/Pages/PosCashier.php:

<?php

namespace App\\Filament\\Pages;

use App\\Models\\Order;
use App\\Models\\OrderItem;
use App\\Models\\Product;
use App\\Models\\Category;
use Filament\\Forms\\Concerns\\InteractsWithForms;
use Filament\\Forms\\Contracts\\HasForms;
use Filament\\Notifications\\Notification;
use Filament\\Pages\\Page;
use Illuminate\\Support\\Collection;
use Livewire\\Attributes\\Computed;

class PosCashier extends Page implements HasForms
{
    use InteractsWithForms;

    protected static ?string $navigationIcon = 'heroicon-o-computer-desktop';

    protected static ?string $navigationLabel = 'POS Cashier';

    protected static ?string $title = 'Point of Sale';

    protected static ?int $navigationSort = 0;

    protected static string $view = 'filament.pages.pos-cashier';

    // Properties
    public Collection $cart;
    public string $customerName = '';
    public string $searchProduct = '';
    public ?int $selectedCategory = null;

    public function mount(): void
    {
        $this->cart = collect();
    }

    #[Computed]
    public function categories(): Collection
    {
        return Category::where('is_active', true)
            ->withCount('products')
            ->orderBy('name')
            ->get();
    }

    #[Computed]
    public function products(): Collection
    {
        return Product::query()
            ->where('is_active', true)
            ->where('stock', '>', 0)
            ->when($this->searchProduct, function ($query) {
                $query->where('name', 'like', '%' . $this->searchProduct . '%');
            })
            ->when($this->selectedCategory, function ($query) {
                $query->where('category_id', $this->selectedCategory);
            })
            ->with('category')
            ->orderBy('name')
            ->get();
    }

    #[Computed]
    public function cartTotal(): float
    {
        return $this->cart->sum(fn ($item) => $item['price'] * $item['quantity']);
    }

    #[Computed]
    public function cartItemsCount(): int
    {
        return $this->cart->sum('quantity');
    }

    public function selectCategory(?int $categoryId): void
    {
        $this->selectedCategory = $categoryId === $this->selectedCategory ? null : $categoryId;
    }

    public function addToCart(int $productId): void
    {
        $product = Product::find($productId);

        if (!$product || $product->stock <= 0) {
            Notification::make()
                ->title('Product out of stock!')
                ->danger()
                ->send();
            return;
        }

        $existingIndex = $this->cart->search(fn ($item) => $item['product_id'] === $productId);

        if ($existingIndex !== false) {
            $currentQty = $this->cart[$existingIndex]['quantity'];

            if ($currentQty >= $product->stock) {
                Notification::make()
                    ->title('Not enough stock!')
                    ->body("Available: {$product->stock}")
                    ->danger()
                    ->send();
                return;
            }

            $cart = $this->cart->toArray();
            $cart[$existingIndex]['quantity']++;
            $this->cart = collect($cart);
        } else {
            $this->cart->push([
                'product_id' => $product->id,
                'name' => $product->name,
                'price' => $product->price,
                'quantity' => 1,
                'stock' => $product->stock,
                'image' => $product->image,
            ]);
        }

        Notification::make()
            ->title('Added to cart')
            ->success()
            ->duration(1000)
            ->send();
    }

    public function removeFromCart(int $index): void
    {
        $cart = $this->cart->toArray();
        unset($cart[$index]);
        $this->cart = collect(array_values($cart));
    }

    public function updateQuantity(int $index, int $quantity): void
    {
        if ($quantity <= 0) {
            $this->removeFromCart($index);
            return;
        }

        $cart = $this->cart->toArray();
        $item = $cart[$index];

        if ($quantity > $item['stock']) {
            Notification::make()
                ->title('Not enough stock!')
                ->body("Available: {$item['stock']}")
                ->danger()
                ->send();
            return;
        }

        $cart[$index]['quantity'] = $quantity;
        $this->cart = collect($cart);
    }

    public function incrementQty(int $index): void
    {
        $cart = $this->cart->toArray();
        $newQty = $cart[$index]['quantity'] + 1;
        $this->updateQuantity($index, $newQty);
    }

    public function decrementQty(int $index): void
    {
        $cart = $this->cart->toArray();
        $newQty = $cart[$index]['quantity'] - 1;
        $this->updateQuantity($index, $newQty);
    }

    public function checkout(): void
    {
        if ($this->cart->isEmpty()) {
            Notification::make()
                ->title('Cart is empty!')
                ->danger()
                ->send();
            return;
        }

        // Create order
        $order = Order::create([
            'customer_name' => $this->customerName ?: null,
            'total_amount' => $this->cartTotal,
            'status' => 'completed',
        ]);

        // Create order items (stock will be reduced via model events)
        foreach ($this->cart as $item) {
            OrderItem::create([
                'order_id' => $order->id,
                'product_id' => $item['product_id'],
                'quantity' => $item['quantity'],
                'price' => $item['price'],
                'subtotal' => $item['price'] * $item['quantity'],
            ]);
        }

        // Clear cart
        $this->cart = collect();
        $this->customerName = '';

        Notification::make()
            ->title('Order completed!')
            ->body("Invoice: {$order->invoice_number}")
            ->success()
            ->send();
    }

    public function clearCart(): void
    {
        $this->cart = collect();
        $this->customerName = '';

        Notification::make()
            ->title('Cart cleared')
            ->send();
    }
}

Create View

Buat file resources/views/filament/pages/pos-cashier.blade.php:

<x-filament-panels::page>
    <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">

        {{-- Products Section (Left/Center) --}}
        <div class="lg:col-span-2 space-y-4">

            {{-- Search & Filter --}}
            <div class="bg-white dark:bg-gray-800 rounded-xl shadow p-4">
                <div class="flex flex-col sm:flex-row gap-4">
                    {{-- Search --}}
                    <div class="flex-1">
                        <input
                            type="text"
                            wire:model.live.debounce.300ms="searchProduct"
                            placeholder="Search products..."
                            class="w-full rounded-lg border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
                        >
                    </div>
                </div>

                {{-- Category Filter --}}
                <div class="flex flex-wrap gap-2 mt-4">
                    <button
                        wire:click="selectCategory(null)"
                        class="px-3 py-1.5 rounded-full text-sm font-medium transition
                            {{ !$this->selectedCategory ? 'bg-primary-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200' }}"
                    >
                        All
                    </button>
                    @foreach($this->categories as $category)
                        <button
                            wire:click="selectCategory({{ $category->id }})"
                            class="px-3 py-1.5 rounded-full text-sm font-medium transition
                                {{ $this->selectedCategory === $category->id ? 'bg-primary-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200' }}"
                        >
                            {{ $category->name }} ({{ $category->products_count }})
                        </button>
                    @endforeach
                </div>
            </div>

            {{-- Product Grid --}}
            <div class="bg-white dark:bg-gray-800 rounded-xl shadow p-4">
                <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
                    @forelse($this->products as $product)
                        <div
                            wire:click="addToCart({{ $product->id }})"
                            wire:key="product-{{ $product->id }}"
                            class="cursor-pointer bg-gray-50 dark:bg-gray-700 rounded-xl p-3 hover:ring-2 hover:ring-primary-500 transition group"
                        >
                            {{-- Image --}}
                            <div class="aspect-square rounded-lg overflow-hidden mb-3 bg-gray-200 dark:bg-gray-600">
                                @if($product->image)
                                    <img
                                        src="{{ Storage::url($product->image) }}"
                                        alt="{{ $product->name }}"
                                        class="w-full h-full object-cover group-hover:scale-105 transition"
                                    >
                                @else
                                    <div class="w-full h-full flex items-center justify-center">
                                        <x-heroicon-o-photo class="w-10 h-10 text-gray-400" />
                                    </div>
                                @endif
                            </div>

                            {{-- Info --}}
                            <h4 class="font-medium text-sm text-gray-900 dark:text-white truncate">
                                {{ $product->name }}
                            </h4>
                            <p class="text-xs text-gray-500 dark:text-gray-400 mb-1">
                                {{ $product->category?->name }}
                            </p>
                            <p class="text-primary-600 dark:text-primary-400 font-bold">
                                Rp {{ number_format($product->price, 0, ',', '.') }}
                            </p>
                            <p class="text-xs {{ $product->stock <= 10 ? 'text-orange-500' : 'text-gray-500' }}">
                                Stock: {{ $product->stock }}
                            </p>
                        </div>
                    @empty
                        <div class="col-span-full py-12 text-center text-gray-500">
                            <x-heroicon-o-cube class="w-12 h-12 mx-auto mb-3 opacity-50" />
                            <p>No products found</p>
                        </div>
                    @endforelse
                </div>
            </div>
        </div>

        {{-- Cart Section (Right) --}}
        <div class="lg:col-span-1">
            <div class="bg-white dark:bg-gray-800 rounded-xl shadow p-4 lg:sticky lg:top-4">
                <div class="flex items-center justify-between mb-4">
                    <h3 class="text-lg font-bold text-gray-900 dark:text-white">
                        Shopping Cart
                    </h3>
                    <span class="bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200 text-sm font-medium px-2.5 py-0.5 rounded-full">
                        {{ $this->cartItemsCount }} items
                    </span>
                </div>

                {{-- Customer Name --}}
                <div class="mb-4">
                    <input
                        type="text"
                        wire:model="customerName"
                        placeholder="Customer name (optional)"
                        class="w-full rounded-lg border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white text-sm"
                    >
                </div>

                {{-- Cart Items --}}
                <div class="space-y-3 mb-4 max-h-[400px] overflow-y-auto">
                    @forelse($this->cart as $index => $item)
                        <div
                            wire:key="cart-{{ $index }}"
                            class="flex gap-3 bg-gray-50 dark:bg-gray-700 rounded-lg p-3"
                        >
                            {{-- Product Info --}}
                            <div class="flex-1 min-w-0">
                                <p class="font-medium text-sm text-gray-900 dark:text-white truncate">
                                    {{ $item['name'] }}
                                </p>
                                <p class="text-xs text-gray-500 dark:text-gray-400">
                                    Rp {{ number_format($item['price'], 0, ',', '.') }}
                                </p>
                                <p class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-1">
                                    Rp {{ number_format($item['price'] * $item['quantity'], 0, ',', '.') }}
                                </p>
                            </div>

                            {{-- Quantity Controls --}}
                            <div class="flex items-center gap-2">
                                <button
                                    wire:click="decrementQty({{ $index }})"
                                    class="w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-600 flex items-center justify-center hover:bg-gray-300 transition"
                                >
                                    <x-heroicon-o-minus class="w-4 h-4" />
                                </button>
                                <span class="w-8 text-center text-sm font-medium">
                                    {{ $item['quantity'] }}
                                </span>
                                <button
                                    wire:click="incrementQty({{ $index }})"
                                    class="w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-600 flex items-center justify-center hover:bg-gray-300 transition"
                                >
                                    <x-heroicon-o-plus class="w-4 h-4" />
                                </button>
                            </div>

                            {{-- Remove --}}
                            <button
                                wire:click="removeFromCart({{ $index }})"
                                class="text-red-500 hover:text-red-700 transition"
                            >
                                <x-heroicon-o-trash class="w-5 h-5" />
                            </button>
                        </div>
                    @empty
                        <div class="py-8 text-center text-gray-500">
                            <x-heroicon-o-shopping-cart class="w-12 h-12 mx-auto mb-3 opacity-50" />
                            <p>Cart is empty</p>
                            <p class="text-xs mt-1">Click products to add</p>
                        </div>
                    @endforelse
                </div>

                {{-- Total --}}
                <div class="border-t dark:border-gray-700 pt-4 mb-4">
                    <div class="flex justify-between items-center">
                        <span class="text-gray-600 dark:text-gray-400">Total</span>
                        <span class="text-2xl font-bold text-primary-600 dark:text-primary-400">
                            Rp {{ number_format($this->cartTotal, 0, ',', '.') }}
                        </span>
                    </div>
                </div>

                {{-- Actions --}}
                <div class="space-y-2">
                    <button
                        wire:click="checkout"
                        wire:loading.attr="disabled"
                        @if($this->cart->isEmpty()) disabled @endif
                        class="w-full py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition flex items-center justify-center gap-2"
                    >
                        <wire:loading.remove wire:target="checkout">
                            <x-heroicon-o-check class="w-5 h-5" />
                            Complete Order
                        </wire:loading.remove>
                        <wire:loading wire:target="checkout">
                            Processing...
                        </wire:loading>
                    </button>

                    <button
                        wire:click="clearCart"
                        @if($this->cart->isEmpty()) disabled @endif
                        class="w-full py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition"
                    >
                        Clear Cart
                    </button>
                </div>
            </div>
        </div>
    </div>
</x-filament-panels::page>

Test POS Cashier

  1. Buka /admin/pos-cashier
  2. Filter by category
  3. Search product
  4. Klik product untuk add to cart
  5. Adjust quantity
  6. Complete order
CHECKPOINT BAGIAN 7:

✅ POS Cashier custom page
✅ Category filter
✅ Product search
✅ Product grid dengan image
✅ Cart dengan quantity controls
✅ Auto-calculate total
✅ Checkout dengan stock reduction
✅ Responsive design


Bagian 8: Dashboard & Widgets

Sekarang kita buat dashboard dengan statistics dan charts.

Install Trend Package

composer require flowframe/laravel-trend

Create Stats Widget

php artisan make:filament-widget StatsOverview --stats-overview

Edit app/Filament/Widgets/StatsOverview.php:

<?php

namespace App\\Filament\\Widgets;

use App\\Models\\Order;
use App\\Models\\Product;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;

class StatsOverview extends BaseWidget
{
    protected static ?int $sort = 1;

    protected function getStats(): array
    {
        // Today's stats
        $todayRevenue = Order::completed()->whereDate('created_at', today())->sum('total_amount');
        $todayOrders = Order::whereDate('created_at', today())->count();

        // Month stats
        $monthRevenue = Order::completed()
            ->whereMonth('created_at', now()->month)
            ->whereYear('created_at', now()->year)
            ->sum('total_amount');

        // Yesterday comparison
        $yesterdayRevenue = Order::completed()->whereDate('created_at', today()->subDay())->sum('total_amount');
        $revenueChange = $yesterdayRevenue > 0
            ? round((($todayRevenue - $yesterdayRevenue) / $yesterdayRevenue) * 100, 1)
            : 0;

        // Low stock alert
        $lowStock = Product::where('is_active', true)
            ->where('stock', '<=', 10)
            ->where('stock', '>', 0)
            ->count();

        $outOfStock = Product::where('is_active', true)
            ->where('stock', '<=', 0)
            ->count();

        return [
            Stat::make('Today\\'s Revenue', 'Rp ' . number_format($todayRevenue, 0, ',', '.'))
                ->description($revenueChange >= 0 ? "+{$revenueChange}% from yesterday" : "{$revenueChange}% from yesterday")
                ->descriptionIcon($revenueChange >= 0 ? 'heroicon-m-arrow-trending-up' : 'heroicon-m-arrow-trending-down')
                ->color($revenueChange >= 0 ? 'success' : 'danger')
                ->chart([7, 4, 6, 8, 5, 9, $todayOrders > 0 ? 10 : 3]),

            Stat::make('Today\\'s Orders', $todayOrders)
                ->description('Transactions today')
                ->descriptionIcon('heroicon-m-shopping-cart')
                ->color('info'),

            Stat::make('Monthly Revenue', 'Rp ' . number_format($monthRevenue, 0, ',', '.'))
                ->description(now()->format('F Y'))
                ->descriptionIcon('heroicon-m-calendar')
                ->color('primary'),

            Stat::make('Stock Alerts', $lowStock + $outOfStock)
                ->description("{$lowStock} low, {$outOfStock} out")
                ->descriptionIcon('heroicon-m-exclamation-triangle')
                ->color($outOfStock > 0 ? 'danger' : ($lowStock > 0 ? 'warning' : 'success')),
        ];
    }
}

Create Sales Chart Widget

php artisan make:filament-widget SalesChart --chart

Edit app/Filament/Widgets/SalesChart.php:

<?php

namespace App\\Filament\\Widgets;

use App\\Models\\Order;
use Filament\\Widgets\\ChartWidget;
use Flowframe\\Trend\\Trend;
use Flowframe\\Trend\\TrendValue;

class SalesChart extends ChartWidget
{
    protected static ?string $heading = 'Sales Last 7 Days';

    protected static ?int $sort = 2;

    protected function getData(): array
    {
        $data = Trend::model(Order::class)
            ->between(
                start: now()->subDays(6)->startOfDay(),
                end: now()->endOfDay(),
            )
            ->perDay()
            ->sum('total_amount');

        return [
            'datasets' => [
                [
                    'label' => 'Revenue',
                    'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
                    'backgroundColor' => 'rgba(59, 130, 246, 0.5)',
                    'borderColor' => 'rgb(59, 130, 246)',
                    'tension' => 0.3,
                ],
            ],
            'labels' => $data->map(fn (TrendValue $value) =>
                \\Carbon\\Carbon::parse($value->date)->format('d M')
            ),
        ];
    }

    protected function getType(): string
    {
        return 'line';
    }
}

Create Orders Chart Widget

php artisan make:filament-widget OrdersChart --chart

Edit app/Filament/Widgets/OrdersChart.php:

<?php

namespace App\\Filament\\Widgets;

use App\\Models\\Order;
use Filament\\Widgets\\ChartWidget;
use Flowframe\\Trend\\Trend;
use Flowframe\\Trend\\TrendValue;

class OrdersChart extends ChartWidget
{
    protected static ?string $heading = 'Orders Last 7 Days';

    protected static ?int $sort = 3;

    protected function getData(): array
    {
        $data = Trend::model(Order::class)
            ->between(
                start: now()->subDays(6)->startOfDay(),
                end: now()->endOfDay(),
            )
            ->perDay()
            ->count();

        return [
            'datasets' => [
                [
                    'label' => 'Orders',
                    'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
                    'backgroundColor' => 'rgba(16, 185, 129, 0.5)',
                    'borderColor' => 'rgb(16, 185, 129)',
                ],
            ],
            'labels' => $data->map(fn (TrendValue $value) =>
                \\Carbon\\Carbon::parse($value->date)->format('d M')
            ),
        ];
    }

    protected function getType(): string
    {
        return 'bar';
    }
}

Create Recent Orders Widget

php artisan make:filament-widget RecentOrders --table

Edit app/Filament/Widgets/RecentOrders.php:

<?php

namespace App\\Filament\\Widgets;

use App\\Models\\Order;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Widgets\\TableWidget as BaseWidget;

class RecentOrders extends BaseWidget
{
    protected static ?int $sort = 4;

    protected int | string | array $columnSpan = 'full';

    protected static ?string $heading = 'Recent Orders';

    public function table(Table $table): Table
    {
        return $table
            ->query(
                Order::query()->latest()->limit(10)
            )
            ->columns([
                Tables\\Columns\\TextColumn::make('invoice_number')
                    ->label('Invoice')
                    ->searchable()
                    ->weight('bold'),

                Tables\\Columns\\TextColumn::make('customer_name')
                    ->default('Walk-in')
                    ->searchable(),

                Tables\\Columns\\TextColumn::make('items_count')
                    ->label('Items')
                    ->counts('items')
                    ->badge(),

                Tables\\Columns\\TextColumn::make('total_amount')
                    ->label('Total')
                    ->money('IDR'),

                Tables\\Columns\\TextColumn::make('status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'pending' => 'warning',
                        'completed' => 'success',
                        'cancelled' => 'danger',
                        default => 'gray',
                    }),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Time')
                    ->since(),
            ])
            ->actions([
                Tables\\Actions\\Action::make('view')
                    ->url(fn (Order $record) => route('filament.admin.resources.orders.view', $record))
                    ->icon('heroicon-o-eye'),
            ])
            ->paginated(false);
    }
}

Create Low Stock Widget

php artisan make:filament-widget LowStockAlert --table

Edit app/Filament/Widgets/LowStockAlert.php:

<?php

namespace App\\Filament\\Widgets;

use App\\Models\\Product;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Widgets\\TableWidget as BaseWidget;

class LowStockAlert extends BaseWidget
{
    protected static ?int $sort = 5;

    protected int | string | array $columnSpan = 'full';

    protected static ?string $heading = 'Low Stock Alert';

    public function table(Table $table): Table
    {
        return $table
            ->query(
                Product::query()
                    ->where('is_active', true)
                    ->where('stock', '<=', 10)
                    ->orderBy('stock', 'asc')
            )
            ->columns([
                Tables\\Columns\\ImageColumn::make('image')
                    ->square()
                    ->size(40),

                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->description(fn (Product $record) => $record->category?->name),

                Tables\\Columns\\TextColumn::make('stock')
                    ->badge()
                    ->color(fn (int $state): string => $state <= 0 ? 'danger' : 'warning'),

                Tables\\Columns\\TextColumn::make('price')
                    ->money('IDR'),
            ])
            ->actions([
                Tables\\Actions\\Action::make('restock')
                    ->label('Restock')
                    ->icon('heroicon-o-plus')
                    ->url(fn (Product $record) => route('filament.admin.resources.products.edit', $record)),
            ])
            ->paginated(false)
            ->emptyStateHeading('All products are well-stocked!')
            ->emptyStateIcon('heroicon-o-check-circle');
    }
}

Test Dashboard

Buka /admin — sekarang dashboard menampilkan:

  • Stats overview (revenue, orders, alerts)
  • Sales chart (line)
  • Orders chart (bar)
  • Recent orders table
  • Low stock alerts table
CHECKPOINT BAGIAN 8:

✅ Stats Overview widget
✅ Sales Chart (line)
✅ Orders Chart (bar)
✅ Recent Orders table
✅ Low Stock Alert table


Bagian 9: Reports & Export

Install Excel Package

composer require maatwebsite/excel

Create Sales Report Page

php artisan make:filament-page SalesReport

Edit app/Filament/Pages/SalesReport.php:

<?php

namespace App\\Filament\\Pages;

use App\\Models\\Order;
use App\\Exports\\SalesExport;
use Filament\\Forms\\Components\\DatePicker;
use Filament\\Forms\\Components\\Section;
use Filament\\Forms\\Concerns\\InteractsWithForms;
use Filament\\Forms\\Contracts\\HasForms;
use Filament\\Forms\\Form;
use Filament\\Pages\\Page;
use Filament\\Tables\\Concerns\\InteractsWithTable;
use Filament\\Tables\\Contracts\\HasTable;
use Filament\\Tables\\Table;
use Filament\\Tables;
use Illuminate\\Database\\Eloquent\\Builder;
use Maatwebsite\\Excel\\Facades\\Excel;

class SalesReport extends Page implements HasForms, HasTable
{
    use InteractsWithForms;
    use InteractsWithTable;

    protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar';

    protected static ?string $navigationLabel = 'Sales Report';

    protected static ?string $navigationGroup = 'Reports';

    protected static ?int $navigationSort = 10;

    protected static string $view = 'filament.pages.sales-report';

    public ?string $dateFrom = null;
    public ?string $dateTo = null;

    public function mount(): void
    {
        $this->dateFrom = now()->startOfMonth()->format('Y-m-d');
        $this->dateTo = now()->format('Y-m-d');
    }

    public function form(Form $form): Form
    {
        return $form
            ->schema([
                Section::make('Filter')
                    ->schema([
                        DatePicker::make('dateFrom')
                            ->label('From Date')
                            ->default(now()->startOfMonth())
                            ->reactive(),
                        DatePicker::make('dateTo')
                            ->label('To Date')
                            ->default(now())
                            ->reactive(),
                    ])
                    ->columns(2),
            ]);
    }

    public function table(Table $table): Table
    {
        return $table
            ->query(
                Order::query()
                    ->where('status', 'completed')
                    ->when($this->dateFrom, fn ($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
                    ->when($this->dateTo, fn ($q) => $q->whereDate('created_at', '<=', $this->dateTo))
            )
            ->columns([
                Tables\\Columns\\TextColumn::make('invoice_number')
                    ->label('Invoice')
                    ->searchable(),
                Tables\\Columns\\TextColumn::make('customer_name')
                    ->default('Walk-in'),
                Tables\\Columns\\TextColumn::make('items_count')
                    ->label('Items')
                    ->counts('items'),
                Tables\\Columns\\TextColumn::make('total_amount')
                    ->label('Total')
                    ->money('IDR')
                    ->summarize(Tables\\Columns\\Summarizers\\Sum::make()->money('IDR')),
                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Date')
                    ->dateTime('d M Y H:i'),
            ])
            ->defaultSort('created_at', 'desc');
    }

    public function getSummary(): array
    {
        $query = Order::where('status', 'completed')
            ->when($this->dateFrom, fn ($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
            ->when($this->dateTo, fn ($q) => $q->whereDate('created_at', '<=', $this->dateTo));

        return [
            'total_orders' => $query->count(),
            'total_revenue' => $query->sum('total_amount'),
            'average_order' => $query->avg('total_amount') ?? 0,
        ];
    }

    public function export()
    {
        return Excel::download(
            new SalesExport($this->dateFrom, $this->dateTo),
            'sales-report-' . now()->format('Y-m-d') . '.xlsx'
        );
    }
}

Create Export Class

Buat app/Exports/SalesExport.php:

<?php

namespace App\\Exports;

use App\\Models\\Order;
use Maatwebsite\\Excel\\Concerns\\FromQuery;
use Maatwebsite\\Excel\\Concerns\\WithHeadings;
use Maatwebsite\\Excel\\Concerns\\WithMapping;
use Maatwebsite\\Excel\\Concerns\\WithStyles;
use PhpOffice\\PhpSpreadsheet\\Worksheet\\Worksheet;

class SalesExport implements FromQuery, WithHeadings, WithMapping, WithStyles
{
    public function __construct(
        protected ?string $dateFrom,
        protected ?string $dateTo
    ) {}

    public function query()
    {
        return Order::query()
            ->where('status', 'completed')
            ->when($this->dateFrom, fn ($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
            ->when($this->dateTo, fn ($q) => $q->whereDate('created_at', '<=', $this->dateTo))
            ->orderBy('created_at', 'desc');
    }

    public function headings(): array
    {
        return [
            'Invoice',
            'Customer',
            'Items',
            'Total',
            'Status',
            'Date',
        ];
    }

    public function map($order): array
    {
        return [
            $order->invoice_number,
            $order->customer_name ?? 'Walk-in',
            $order->items->count(),
            $order->total_amount,
            ucfirst($order->status),
            $order->created_at->format('d M Y H:i'),
        ];
    }

    public function styles(Worksheet $sheet)
    {
        return [
            1 => ['font' => ['bold' => true]],
        ];
    }
}

Create Report View

Buat resources/views/filament/pages/sales-report.blade.php:

<x-filament-panels::page>
    {{-- Filter --}}
    <div class="mb-6">
        <form wire:submit.prevent="$refresh">
            {{ $this->form }}

            <div class="flex gap-2 mt-4">
                <x-filament::button type="submit">
                    Apply Filter
                </x-filament::button>

                <x-filament::button wire:click="export" color="success">
                    <x-heroicon-o-arrow-down-tray class="w-4 h-4 mr-2" />
                    Export Excel
                </x-filament::button>
            </div>
        </form>
    </div>

    {{-- Summary Cards --}}
    @php $summary = $this->getSummary(); @endphp
    <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
        <x-filament::section>
            <div class="text-center">
                <p class="text-sm text-gray-500 dark:text-gray-400">Total Orders</p>
                <p class="text-3xl font-bold text-gray-900 dark:text-white">
                    {{ number_format($summary['total_orders']) }}
                </p>
            </div>
        </x-filament::section>

        <x-filament::section>
            <div class="text-center">
                <p class="text-sm text-gray-500 dark:text-gray-400">Total Revenue</p>
                <p class="text-3xl font-bold text-green-600 dark:text-green-400">
                    Rp {{ number_format($summary['total_revenue'], 0, ',', '.') }}
                </p>
            </div>
        </x-filament::section>

        <x-filament::section>
            <div class="text-center">
                <p class="text-sm text-gray-500 dark:text-gray-400">Average Order</p>
                <p class="text-3xl font-bold text-blue-600 dark:text-blue-400">
                    Rp {{ number_format($summary['average_order'], 0, ',', '.') }}
                </p>
            </div>
        </x-filament::section>
    </div>

    {{-- Table --}}
    {{ $this->table }}
</x-filament-panels::page>

Test Report

  1. Buka /admin/sales-report
  2. Set date range
  3. Klik Apply Filter
  4. Export to Excel
CHECKPOINT BAGIAN 9:

✅ Sales Report page
✅ Date range filter
✅ Summary statistics
✅ Orders table dengan total sum
✅ Export to Excel


Bagian 10: Finishing & Next Steps

Review Aplikasi

Selamat! Kamu sudah membangun aplikasi POS lengkap:

FITUR YANG SUDAH SELESAI:

✅ DATABASE & MODELS
├── Categories, Products, Orders, OrderItems
├── Relationships configured
├── Auto-generate invoice number
└── Stock management (reduce/increase)

✅ FILAMENT RESOURCES
├── Category CRUD dengan products count
├── Product CRUD dengan image upload
├── Order CRUD dengan repeater items
└── ViewOrder dengan Infolist

✅ POS CASHIER
├── Product grid dengan category filter
├── Search functionality
├── Cart dengan quantity controls
├── Auto-calculate total
└── Quick checkout

✅ DASHBOARD
├── Stats overview (revenue, orders, alerts)
├── Sales chart (7 days)
├── Orders chart (7 days)
├── Recent orders widget
└── Low stock alert widget

✅ REPORTS
├── Sales report dengan date filter
├── Summary statistics
└── Export to Excel

Ideas untuk Pengembangan

FITUR YANG BISA DITAMBAHKAN:

TRANSACTION:
├── Multiple payment methods (cash, card, e-wallet)
├── Discount/promo codes
├── Print receipt (thermal printer)
├── Refund/return system
├── Hold orders

INVENTORY:
├── Stock opname
├── Supplier management
├── Purchase orders
├── Stock history log
├── Auto-reorder alerts

USER MANAGEMENT:
├── Roles (admin, cashier, manager)
├── Cashier shift management
├── Per-user sales report
├── Activity logs

REPORTING:
├── Profit margin report
├── Best selling products
├── Sales by category
├── Sales by time (hourly)
├── PDF reports

INTEGRATION:
├── Barcode scanner
├── Thermal receipt printer
├── WhatsApp notification
├── Payment gateway
└── Accounting software

Tips Production

SEBELUM DEPLOY:

SECURITY:
├── Ganti URL admin (/admin → /dashboard atau custom)
├── Set strong passwords
├── Enable HTTPS
├── Update .env APP_DEBUG=false

PERFORMANCE:
├── php artisan config:cache
├── php artisan route:cache
├── php artisan view:cache
├── Optimize images
├── Use CDN untuk assets

BACKUP:
├── Setup automated database backup
├── Backup uploaded files
├── Test restore process

Kelas Laravel Gratis di BuildWithAngga

Mau belajar Laravel lebih dalam? BuildWithAngga punya kelas gratis:

KELAS GRATIS DI BUILDWITHANGGA.COM:

📚 LARAVEL FUNDAMENTAL
   Belajar dasar Laravel dari nol

📚 LARAVEL WEB DEVELOPMENT
   Build aplikasi web lengkap

📚 LARAVEL API DEVELOPMENT
   Build REST API professional

📚 LARAVEL FILAMENT
   Admin panel seperti tutorial ini

📚 LARAVEL LIVEWIRE
   Real-time interfaces

CARA AKSES:
1. Kunjungi buildwithangga.com
2. Buat akun gratis
3. Browse kelas Laravel
4. Mulai belajar!

BENEFIT:
├── 100% gratis untuk kelas tertentu
├── Video HD berkualitas
├── Source code included
├── Certificate of completion
├── Lifetime access
└── Community support


Penutup

Tutorial selesai! 🎉

Dalam 10 bagian ini, kamu sudah belajar:

  1. Setup Laravel 12 & Filament 4
  2. Database design & migrations
  3. Models dengan relationships
  4. Filament Resources (CRUD)
  5. Custom pages (POS Cashier)
  6. Dashboard widgets
  7. Reports dengan export

Aplikasi POS ini adalah foundation yang solid — kamu bisa kembangkan sesuai kebutuhan bisnis.

Source code dari tutorial ini bisa dijadikan:

  • Portfolio project
  • Starter template untuk client projects
  • Bahan belajar lebih lanjut

Kalau ada pertanyaan, tinggalkan komentar. Dan jangan lupa cek kelas Laravel gratis di BuildWithAngga!

Happy coding! 🚀


Tutorial by Angga Risky Setiawan Founder, BuildWithAngga