feat: phase 6 — dashboard widgets, sales & stock reports, user management, activity log
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Services\StockService;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget;
|
||||
|
||||
class CriticalStock extends TableWidget
|
||||
{
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$service = app(StockService::class);
|
||||
|
||||
$rows = collect($service->paginate(perPage: 100)->items())
|
||||
->filter(fn (array $row): bool => (int) $row['quantity'] <= (int) data_get($row, 'products.stock_minimum', 0))
|
||||
->values();
|
||||
|
||||
return $table
|
||||
->heading('Stok Kritis')
|
||||
->records(fn () => $rows)
|
||||
->columns([
|
||||
TextColumn::make('products.code')->label('Kode')->badge(),
|
||||
TextColumn::make('products.name')->label('Produk'),
|
||||
TextColumn::make('quantity')->label('Saldo')->numeric()->color('danger'),
|
||||
TextColumn::make('products.stock_minimum')->label('Min.'),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color('danger')
|
||||
->state(fn (array $record): string => (int) $record['quantity'] <= (int) data_get($record, 'products.stock_minimum', 0) ? 'kritis' : 'aman'),
|
||||
])
|
||||
->paginated(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Services\ComplaintService;
|
||||
use App\Services\InsForge\InsForgeClient;
|
||||
use App\Services\OrderService;
|
||||
use App\Services\StockService;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class DashboardStats extends StatsOverviewWidget
|
||||
{
|
||||
protected static ?int $sort = 0;
|
||||
|
||||
protected ?string $heading = 'Ringkasan Operasional';
|
||||
|
||||
public function getStats(): array
|
||||
{
|
||||
$orderService = app(OrderService::class);
|
||||
|
||||
$counts = $orderService->countByStatus();
|
||||
|
||||
$openComplaints = app(InsForgeClient::class)->countRecords('complaints', [
|
||||
'status' => ComplaintService::STATUS_OPEN,
|
||||
]);
|
||||
|
||||
$critical = collect(app(StockService::class)->paginate(perPage: 100)->items())
|
||||
->filter(fn (array $row): bool => (int) $row['quantity'] <= (int) data_get($row, 'products.stock_minimum', 0))
|
||||
->count();
|
||||
|
||||
return [
|
||||
Stat::make('Pending', (string) ($counts[OrderService::STATUS_PENDING] ?? 0))->color('gray'),
|
||||
Stat::make('Diproses', (string) ($counts[OrderService::STATUS_PROCESSING] ?? 0))->color('info'),
|
||||
Stat::make('Dikirim', (string) ($counts[OrderService::STATUS_DELIVERING] ?? 0))->color('warning'),
|
||||
Stat::make('Selesai', (string) ($counts[OrderService::STATUS_COMPLETED] ?? 0))->color('success'),
|
||||
Stat::make('Komplain Terbuka', (string) $openComplaints)->color($openComplaints > 0 ? 'danger' : 'success'),
|
||||
Stat::make('Stok Kritis', (string) $critical)->color($critical > 0 ? 'danger' : 'success'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Services\OrderService;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class RecentOrders extends TableWidget
|
||||
{
|
||||
protected static ?int $sort = 2;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$service = app(OrderService::class);
|
||||
|
||||
return $table
|
||||
->heading('Pesanan Terbaru')
|
||||
->records(fn () => new Collection($service->recent(8)))
|
||||
->columns([
|
||||
TextColumn::make('order_number')->label('No. Order')->badge(),
|
||||
TextColumn::make('customers.name')->label('Pelanggan'),
|
||||
TextColumn::make('order_date')->label('Tgl')->date('d M Y'),
|
||||
TextColumn::make('order_status')
|
||||
->label('Status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'gray',
|
||||
'processing' => 'info',
|
||||
'delivering' => 'warning',
|
||||
'completed' => 'success',
|
||||
default => 'danger',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'pending' => 'Pending',
|
||||
'processing' => 'Diproses',
|
||||
'delivering' => 'Dikirim',
|
||||
'completed' => 'Selesai',
|
||||
default => 'Batal',
|
||||
}),
|
||||
])
|
||||
->paginated(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user