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 opsionalis_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 deleteprice— Decimal dengan 12 digit total, 2 digit desimal (max: 9,999,999,999.99)stock— Jumlah stok, default 0image— 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 customertotal_amount— Total harga, di-calculate dari order itemsstatus— 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 ordersproduct_id— Foreign key ke productsquantity— Jumlah item yang dibeliprice— 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 booleanboot()— Auto-generate slug dari name saat createproducts()— Relationship one-to-many ke ProductscopeActive()— 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 CategoryorderItems()— Has many OrderItem (history penjualan)reduceStock()— Kurangi stok setelah orderincreaseStock()— 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-0001items()— Has many OrderItemcalculateTotal()— Hitung total dari semua itemsmarkAsCompleted()— Update status ke completedmarkAsCancelled()— 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 savecreated— Kurangi stock product setelah item dibuatsaved— Update total order setelah item di-savedeleting— Kembalikan stock saat item dihapusdeleted— Recalculate total order saat item dihapus
order()— Belongs to Orderproduct()— 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:
- Setup Project
- Install Laravel 12
- Install Filament 4
- Setup database
- Create admin user
- Design Database
- 4 tabel: categories, products, orders, order_items
- Proper foreign keys dan relationships
- Semua migrations executed
- 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 sidebargetNavigationBadge()— Badge dengan total count
Form:
live(onBlur: true)— Trigger event saat input blurafterStateUpdated()— Auto-fill slug dari nameunique(ignoreRecord: true)— Unique validation, ignore current record saat editcolumnSpanFull()— Span 2 columns
Table:
counts('products')— Count relationshiptoggleable(isToggledHiddenByDefault: true)— Hidden by default, bisa di-toggleTernaryFilter— Filter dengan 3 state: all, true, falsestriped()— Zebra striping
Test Category CRUD
- Buka
http://localhost:8000/admin - Klik "Categories" di sidebar
- Klik "New Category"
- Isi: Name = "Makanan", lihat slug auto-generate
- Save
- 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 screencolumns(3)di form level — Total 3 columns
Select with Create:
createOptionForm()— Bisa create category langsung dari product formpreload()— Load options di awal (better UX)native(false)— Pakai Filament select, bukan native HTML
Image Upload:
imageEditor()— Built-in image editorimageEditorAspectRatios()— Pilihan crop ratiodirectory('products')— Folder penyimpanan
Table dengan Custom Actions:
ActionGroup— Dropdown menu untuk actionsadjustStockaction — Custom action untuk adjust stock- Bulk actions untuk activate/deactivate
Dynamic Badge:
getNavigationBadgeColor()— Warning jika ada low stock
Test Product CRUD
- Buka
http://localhost:8000/admin/products - Klik "New Product"
- Test semua fields, upload image
- Test adjust stock action
- 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 productscloneable()— Bisa duplicate itemcollapsible()— Bisa collapse/expanditemLabel()— Custom label untuk collapsed itemlive()— Real-time updatesafterStateUpdated()— Recalculate total saat item berubah
Table Features:
copyable()— Bisa copy invoice numberpoll('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
- Buka
http://localhost:8000/admin/orders - Create new order
- Add multiple items
- Lihat total auto-calculate
- Save dan test complete/cancel actions
- 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:
- CategoryResource
- Form dengan auto-slug
- Table dengan product count
- Filter active status
- Navigation badge
- ProductResource
- Image upload dengan editor
- Category select dengan inline create
- Stock management action
- Low stock & out of stock filters
- Bulk activate/deactivate
- 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
- Buka
/admin/pos-cashier - Filter by category
- Search product
- Klik product untuk add to cart
- Adjust quantity
- 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
- Buka
/admin/sales-report - Set date range
- Klik Apply Filter
- 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:
- Setup Laravel 12 & Filament 4
- Database design & migrations
- Models dengan relationships
- Filament Resources (CRUD)
- Custom pages (POS Cashier)
- Dashboard widgets
- 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