feat: phase 6 — dashboard widgets, sales & stock reports, user management, activity log

This commit is contained in:
sean
2026-08-25 19:25:40 +07:00
parent 28937a786e
commit feba1ec839
18 changed files with 1011 additions and 5 deletions
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Filament\Pages;
use App\Filament\Widgets\CriticalStock;
use App\Filament\Widgets\DashboardStats;
use App\Filament\Widgets\RecentOrders;
use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Widgets\Widget;
class Dashboard extends BaseDashboard
{
public function getColumns(): int|array
{
return 3;
}
/** @return list<class-string<Widget>> */
public function getWidgets(): array
{
return [
DashboardStats::class,
CriticalStock::class,
RecentOrders::class,
];
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Filament\Pages\Reports;
use App\Services\OrderService;
use BackedEnum;
use Filament\Forms\Components\DatePicker;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class SalesReport extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-chart-bar';
protected static string|UnitEnum|null $navigationGroup = 'Laporan';
protected static ?int $navigationSort = 0;
protected static ?string $navigationLabel = 'Penjualan';
protected static ?string $slug = 'reports/sales';
protected string $view = 'filament.pages.blank';
public function getTitle(): string|Htmlable
{
return 'Laporan Penjualan';
}
public static function canAccess(): bool
{
return Gate::check('view-reports');
}
public function table(Table $table): Table
{
$service = app(OrderService::class);
return $table
->records(function (?array $filters) use ($service) {
$from = Arr::get($filters, 'period.date_from') ?? now()->startOfMonth()->toDateString();
$to = Arr::get($filters, 'period.date_to') ?? now()->toDateString();
return new Collection($service->salesPerProduct($from, $to));
})
->columns([
TextColumn::make('code')->label('Kode Produk')->badge(),
TextColumn::make('name')->label('Nama Produk'),
TextColumn::make('qty')->label('Qty Terjual')->numeric(),
TextColumn::make('revenue')->label('Omzet')->numeric()->prefix('Rp '),
])
->filters([
Filter::make('period')
->schema([
DatePicker::make('date_from')->label('Dari')->default(now()->startOfMonth()->toDateString()),
DatePicker::make('date_to')->label('Sampai')->default(now()->toDateString()),
]),
])
->paginated(false);
}
public function content(Schema $schema): Schema
{
return $schema
->components([
EmbeddedTable::make(),
]);
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Filament\Pages\Reports;
use App\Services\StockService;
use BackedEnum;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class StockReport extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cube-transparent';
protected static string|UnitEnum|null $navigationGroup = 'Laporan';
protected static ?int $navigationSort = 1;
protected static ?string $navigationLabel = 'Posisi Stok';
protected static ?string $slug = 'reports/stock';
protected string $view = 'filament.pages.blank';
public function getTitle(): string|Htmlable
{
return 'Laporan Posisi Stok';
}
public static function canAccess(): bool
{
return Gate::check('view-reports');
}
public function table(Table $table): Table
{
$service = app(StockService::class);
return $table
->records(fn () => new Collection($service->paginate(perPage: 100)->items()))
->columns([
TextColumn::make('products.code')->label('Kode')->badge(),
TextColumn::make('products.name')->label('Produk'),
TextColumn::make('quantity')->label('Saldo')->numeric(),
TextColumn::make('products.stock_minimum')->label('Min.'),
TextColumn::make('status')
->label('Status')
->badge()
->color(fn (string $state): string => $state === 'kritis' ? 'danger' : 'success')
->state(fn (array $record): string => (int) $record['quantity'] <= (int) data_get($record, 'products.stock_minimum', 0) ? 'kritis' : 'aman'),
TextColumn::make('nilai')
->label('Nilai Stok')
->numeric()->prefix('Rp ')
->state(fn (array $record): int => (int) $record['quantity'] * (int) ($record['products']['cost_price'] ?? 0)),
])
->paginated(false);
}
public function content(Schema $schema): Schema
{
return $schema
->components([
EmbeddedTable::make(),
]);
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Filament\Pages\Settings;
use App\Services\ActivityLogService;
use BackedEnum;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Schema;
use Filament\Support\Enums\FontWeight;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class ActivityLogs extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
protected static string|UnitEnum|null $navigationGroup = 'Pengaturan';
protected static ?int $navigationSort = 1;
protected static ?string $navigationLabel = 'Log Aktivitas';
protected static ?string $slug = 'settings/activity-logs';
protected string $view = 'filament.pages.blank';
public function getTitle(): string|Htmlable
{
return 'Log Aktivitas';
}
public static function canAccess(): bool
{
return Gate::check('view-activity-logs');
}
public function table(Table $table): Table
{
/** @var ActivityLogService $service */
$service = app(ActivityLogService::class);
return $table
->records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage) => $service->paginate(
search: $search,
sortColumn: $sortColumn,
sortDirection: $sortDirection,
page: (int) $page,
perPage: (int) $recordsPerPage,
))
->columns([
TextColumn::make('created_at')->label('Waktu')->dateTime('d M Y H:i:s', 'Asia/Jakarta')->sortable(),
TextColumn::make('user_name')->label('User')->state(fn (array $record): string => data_get($record, 'users.name', substr((string) ($record['user_id'] ?? ''), 0, 8) ?: 'sistem')),
TextColumn::make('action')->label('Aksi')->badge()->color('info'),
TextColumn::make('entity_type')->label('Entitas')->badge(),
TextColumn::make('description')->label('Deskripsi')->weight(FontWeight::Medium),
])
->paginated([10, 25, 50]);
}
public function content(Schema $schema): Schema
{
return $schema
->components([
EmbeddedTable::make(),
]);
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace App\Filament\Pages\Settings;
use App\Services\UserAdminService;
use App\Services\UserService;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Schema;
use Filament\Support\Enums\FontWeight;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class Users extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static string|UnitEnum|null $navigationGroup = 'Pengaturan';
protected static ?int $navigationSort = 0;
protected static ?string $navigationLabel = 'Pengguna';
protected static ?string $slug = 'settings/users';
protected string $view = 'filament.pages.blank';
public function getTitle(): string|Htmlable
{
return 'Manajemen Pengguna';
}
public static function canAccess(): bool
{
return Gate::check('manage-users');
}
public function table(Table $table): Table
{
$service = app(UserService::class);
return $table
->records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage) => $service->paginate(
search: $search,
sortColumn: $sortColumn,
sortDirection: $sortDirection,
page: (int) $page,
perPage: (int) $recordsPerPage,
))
->columns([
TextColumn::make('name')->label('Nama')->weight(FontWeight::SemiBold),
TextColumn::make('email')->label('Email'),
TextColumn::make('role')
->label('Role')
->badge()
->color(fn (string $state): string => $state === 'admin' ? 'danger' : 'info'),
IconColumn::make('is_active')->label('Aktif')->boolean(),
])
->recordActions([
Action::make('role')
->label('Ubah Role')
->schema(fn (Schema $schema): Schema => $schema->components([
Select::make('role')->label('Role')->options(['admin' => 'Admin', 'operator' => 'Operator'])->required(),
]))
->fillForm(fn (array $record): array => ['role' => $record['role']])
->action(function (array $data, array $record): void {
app(UserAdminService::class)->setRole($record['id'], $data['role'], auth()->user());
Notification::make()->title('Role diperbarui.')->success()->send();
}),
Action::make('toggle_aktif')
->label(fn (array $record): string => ($record['is_active'] ?? false) ? 'Nonaktifkan' : 'Aktifkan')
->color(fn (array $record): bool|string => ($record['is_active'] ?? false) ? 'danger' : 'success')
->requiresConfirmation()
->action(function (array $record): void {
app(UserAdminService::class)->setActive($record['id'], ! ($record['is_active'] ?? false), auth()->user());
Notification::make()->title('Status user diperbarui.')->success()->send();
}),
])
->headerActions([
CreateAction::make()
->label('Tambah User')
->schema(fn (Schema $schema): Schema => $schema->components([
TextInput::make('name')->label('Nama')->required(),
TextInput::make('email')->label('Email')->email()->required(),
TextInput::make('password')->label('Password')->password()->minLength(8)->required(),
Select::make('role')->label('Role')->options(['operator' => 'Operator', 'admin' => 'Admin'])->default('operator')->required(),
]))
->using(function (array $data): void {
app(UserAdminService::class)->createUser($data, auth()->user());
Notification::make()->title('User dibuat & terverifikasi.')->success()->send();
}),
])
->paginated([10, 25]);
}
public function content(Schema $schema): Schema
{
return $schema
->components([
EmbeddedTable::make(),
]);
}
}