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);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Pages\Dashboard;
|
||||
use App\Http\Middleware\RefreshInsForgeToken;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\InsForge\InsForgeTableService;
|
||||
|
||||
class ActivityLogService extends InsForgeTableService
|
||||
{
|
||||
protected function table(): string
|
||||
{
|
||||
return 'activity_logs';
|
||||
}
|
||||
|
||||
protected function select(): string
|
||||
{
|
||||
return '*,users(name)';
|
||||
}
|
||||
|
||||
protected function searchableColumns(): array
|
||||
{
|
||||
return ['description', 'action', 'entity_type'];
|
||||
}
|
||||
|
||||
protected function sortableColumns(): array
|
||||
{
|
||||
return ['created_at'];
|
||||
}
|
||||
}
|
||||
@@ -72,10 +72,45 @@ class InsForgeClient
|
||||
|
||||
public function countRecords(string $table, array $filters = []): int
|
||||
{
|
||||
$query = $this->buildFilters($filters);
|
||||
$response = $this->request('HEAD', "/api/database/records/{$table}", query: array_merge($query, ['limit' => 1]));
|
||||
$response = $this->send(
|
||||
'GET',
|
||||
"/api/database/records/{$table}",
|
||||
query: array_merge($this->buildFilters($filters), ['limit' => 0]),
|
||||
headers: ['Prefer' => 'count=exact'],
|
||||
);
|
||||
|
||||
return (int) ($response->header('Content-Range') ? explode('/', explode('-', $response->header('Content-Range'))[1])[0] : 0);
|
||||
return $this->parseTotalFromRange($response->header('Content-Range'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Jalankan SQL read-only/administratif via management API (admin key).
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function rawSql(string $query): array
|
||||
{
|
||||
$response = Http::baseUrl(rtrim($this->baseUrl, '/'))
|
||||
->timeout($this->timeout)
|
||||
->withToken($this->apiKey)
|
||||
->post('/api/database/advance/rawsql', ['query' => $query]);
|
||||
|
||||
throw_unless($response->successful(), fn () => InsForgeException::fromResponse(
|
||||
$response->status(),
|
||||
$response->json() ?? $response->body(),
|
||||
));
|
||||
|
||||
return $response->json('rows') ?? [];
|
||||
}
|
||||
|
||||
private function parseTotalFromRange(?string $contentRange): int
|
||||
{
|
||||
if (! filled($contentRange)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = explode('/', $contentRange)[1] ?? '';
|
||||
|
||||
return ctype_digit($total) ? (int) $total : 0;
|
||||
}
|
||||
|
||||
public function rpc(string $function, array $args = [], bool $asUser = true): mixed
|
||||
|
||||
@@ -272,6 +272,72 @@ class OrderService extends InsForgeTableService
|
||||
return $actor !== null && ($actor->isAdmin() || $actor->id === ($record['created_by'] ?? null));
|
||||
}
|
||||
|
||||
/** @return array<string, int> status => jumlah order aktif */
|
||||
public function countByStatus(): array
|
||||
{
|
||||
[$client] = $this->scoped();
|
||||
|
||||
$counts = [];
|
||||
|
||||
foreach (array_keys(self::TRANSITIONS) as $status) {
|
||||
$counts[$status] = $client->countRecords($this->table(), [
|
||||
'order_status' => $status,
|
||||
'deleted_at' => 'null',
|
||||
]);
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
public function recent(int $limit = 8): array
|
||||
{
|
||||
[$client] = $this->scoped();
|
||||
|
||||
return $client->listRecords($this->table(), [
|
||||
'select' => 'id,order_number,order_date,order_status,customers(name)',
|
||||
'deleted_at' => 'eq.null',
|
||||
'order' => 'created_at.desc',
|
||||
'limit' => $limit,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agregasi penjualan per produk dari order completed dalam periode (UTC).
|
||||
*
|
||||
* @return list<array{code: string, name: string, qty: int, revenue: int}>
|
||||
*/
|
||||
public function salesPerProduct(string $fromDate, string $toDate): array
|
||||
{
|
||||
[$client, $asUser] = $this->scoped();
|
||||
|
||||
$orders = $client->listRecords($this->table(), [
|
||||
'select' => 'order_items(quantity,subtotal,products(code,name))',
|
||||
'and' => sprintf(
|
||||
'(order_status.eq.completed,completed_at.gte.%sT00:00:00+00:00,completed_at.lte.%sT23:59:59+00:00)',
|
||||
$fromDate,
|
||||
$toDate,
|
||||
),
|
||||
'limit' => 1000,
|
||||
], asUser: $asUser);
|
||||
|
||||
$aggregated = [];
|
||||
|
||||
foreach ($orders as $order) {
|
||||
foreach ($order['order_items'] ?? [] as $item) {
|
||||
$code = data_get($item, 'products.code', '?');
|
||||
$key = $code;
|
||||
|
||||
$aggregated[$key] ??= ['code' => $code, 'name' => data_get($item, 'products.name', ''), 'qty' => 0, 'revenue' => 0];
|
||||
$aggregated[$key]['qty'] += (int) ($item['quantity'] ?? 0);
|
||||
$aggregated[$key]['revenue'] += (int) ($item['subtotal'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
usort($aggregated, fn (array $a, array $b): int => $b['revenue'] <=> $a['revenue']);
|
||||
|
||||
return array_values($aggregated);
|
||||
}
|
||||
|
||||
public function deliveryNoteData(string $orderId): array
|
||||
{
|
||||
[$client, $asUser] = $this->scoped();
|
||||
|
||||
@@ -23,7 +23,7 @@ class StockService extends InsForgeTableService
|
||||
|
||||
protected function select(): string
|
||||
{
|
||||
return '*,products(code,name,stock_minimum,is_active)';
|
||||
return '*,products(code,name,stock_minimum,is_active,cost_price)';
|
||||
}
|
||||
|
||||
protected function searchableColumns(): array
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\InsForge\InsForgeClient;
|
||||
use App\Services\InsForge\InsForgeException;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UserAdminService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InsForgeClient $client,
|
||||
private readonly ActivityLogger $activity,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Buat user InsForge (auth) + mirror public.users, langsung terverifikasi.
|
||||
*
|
||||
* @return array{id: string, email: string}
|
||||
*/
|
||||
public function createUser(array $attributes, ?User $actor = null): array
|
||||
{
|
||||
$email = strtolower(trim((string) ($attributes['email'] ?? '')));
|
||||
$password = (string) ($attributes['password'] ?? '');
|
||||
$role = in_array($attributes['role'] ?? '', ['admin', 'operator'], true) ? $attributes['role'] : 'operator';
|
||||
|
||||
if ($email === '' || strlen($password) < 8) {
|
||||
throw ValidationException::withMessages(['password' => 'Password minimal 8 karakter.']);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->client->createUserAsAdmin([
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'name' => $attributes['name'] ?? $email,
|
||||
]);
|
||||
} catch (InsForgeException $exception) {
|
||||
if ($exception->getCode() === 409 || str_contains($exception->getMessage(), 'exist')) {
|
||||
throw ValidationException::withMessages(['email' => 'Email sudah terdaftar.']);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
[$authId] = $this->client->rawSql(sprintf(
|
||||
"SELECT id::text FROM auth.users WHERE email = '%s' LIMIT 1",
|
||||
str_replace("'", "''", $email),
|
||||
)) ?: [null];
|
||||
|
||||
$authId = $authId['id'] ?? null;
|
||||
|
||||
if ($authId === null) {
|
||||
throw new \RuntimeException('Gagal menemukan user auth yang baru dibuat.');
|
||||
}
|
||||
|
||||
$this->client->rawSql(sprintf(
|
||||
"UPDATE auth.users SET email_verified = true WHERE id = '%s'",
|
||||
$authId,
|
||||
));
|
||||
|
||||
$this->client->insertRecord('users', [[
|
||||
'id' => $authId,
|
||||
'name' => $attributes['name'] ?? $email,
|
||||
'email' => $email,
|
||||
'password' => Hash::make($password),
|
||||
'role' => $role,
|
||||
'is_active' => true,
|
||||
]], asUser: false);
|
||||
|
||||
$this->activity->log('user_created', 'users', $authId, "User {$email} dibuat dengan role {$role}.", $actor);
|
||||
|
||||
return ['id' => $authId, 'email' => $email];
|
||||
}
|
||||
|
||||
public function setActive(string $userId, bool $active, ?User $actor = null): void
|
||||
{
|
||||
$this->client->updateRecords('users', ['id' => $userId], [
|
||||
'is_active' => $active,
|
||||
], asUser: false);
|
||||
|
||||
$this->activity->log(
|
||||
$active ? 'user_activated' : 'user_deactivated',
|
||||
'users',
|
||||
$userId,
|
||||
sprintf('User %s.', $active ? 'diaktifkan' : 'dinonaktifkan'),
|
||||
$actor,
|
||||
);
|
||||
}
|
||||
|
||||
public function setRole(string $userId, string $role, ?User $actor = null): void
|
||||
{
|
||||
in_array($role, ['admin', 'operator'], true) || throw ValidationException::withMessages(['role' => 'Role tidak valid.']);
|
||||
|
||||
$this->client->updateRecords('users', ['id' => $userId], [
|
||||
'role' => $role,
|
||||
], asUser: false);
|
||||
|
||||
$this->activity->log('user_role_changed', 'users', $userId, "Role diubah menjadi {$role}.", $actor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\InsForge\InsForgeTableService;
|
||||
|
||||
class UserService extends InsForgeTableService
|
||||
{
|
||||
protected function table(): string
|
||||
{
|
||||
return 'users';
|
||||
}
|
||||
|
||||
protected function searchableColumns(): array
|
||||
{
|
||||
return ['name', 'email'];
|
||||
}
|
||||
|
||||
protected function sortableColumns(): array
|
||||
{
|
||||
return ['name', 'email', 'created_at'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Phase6;
|
||||
|
||||
use App\Filament\Pages\Dashboard;
|
||||
use App\Filament\Pages\Reports\StockReport;
|
||||
use App\Filament\Pages\Settings\ActivityLogs;
|
||||
use App\Filament\Widgets\CriticalStock;
|
||||
use App\Filament\Widgets\DashboardStats;
|
||||
use App\Models\User;
|
||||
use App\Services\OrderService;
|
||||
use App\Services\UserAdminService;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Support\InsForgeFake;
|
||||
use Tests\TestCase;
|
||||
|
||||
class Phase6Test extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
InsForgeFake::reset();
|
||||
InsForgeFake::fakeRest();
|
||||
|
||||
InsForgeFake::seed('products', [
|
||||
'id' => 'p-1',
|
||||
'code' => 'BERAS-001',
|
||||
'name' => 'Beras Premium',
|
||||
'cost_price' => 13000,
|
||||
'stock_minimum' => 50,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function actingAsRole(string $role): User
|
||||
{
|
||||
$user = new User;
|
||||
|
||||
$user->forceFill([
|
||||
'id' => "eeee9999-0000-0000-0000-{$role}00001",
|
||||
'name' => ucfirst($role),
|
||||
'email' => "{$role}@beraspro.test",
|
||||
'role' => $role,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function test_dashboard_renders_for_operator(): void
|
||||
{
|
||||
$this->actingAsRole('operator');
|
||||
|
||||
InsForgeFake::seed('stocks', [
|
||||
'id' => 's1',
|
||||
'product_id' => 'p-1',
|
||||
'quantity' => 5,
|
||||
'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium', 'stock_minimum' => 50, 'cost_price' => 13000],
|
||||
]);
|
||||
|
||||
Livewire::test(Dashboard::class)->assertSuccessful();
|
||||
$this->assertCount(3, (new Dashboard)->getWidgets());
|
||||
|
||||
Livewire::test(DashboardStats::class)
|
||||
->assertSuccessful()
|
||||
->assertSee('Pending')
|
||||
->assertSee('Komplain Terbuka');
|
||||
|
||||
Livewire::test(CriticalStock::class)
|
||||
->assertSuccessful()
|
||||
->assertSee('Beras Premium')
|
||||
->assertSee('kritis');
|
||||
}
|
||||
|
||||
public function test_sales_report_aggregates_completed_orders(): void
|
||||
{
|
||||
$this->actingAsRole('admin');
|
||||
|
||||
InsForgeFake::seed('orders', [
|
||||
'id' => 'o1',
|
||||
'order_status' => OrderService::STATUS_COMPLETED,
|
||||
'deleted_at' => null,
|
||||
'order_items' => [
|
||||
['quantity' => 3, 'subtotal' => 45000, 'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium']],
|
||||
['quantity' => 2, 'subtotal' => 30000, 'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium']],
|
||||
],
|
||||
]);
|
||||
|
||||
$rows = app(OrderService::class)->salesPerProduct('2026-08-01', '2026-08-31');
|
||||
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame(5, $rows[0]['qty']);
|
||||
$this->assertSame(75000, $rows[0]['revenue']);
|
||||
}
|
||||
|
||||
public function test_sales_report_page_denied_for_operator(): void
|
||||
{
|
||||
$this->actingAsRole('operator');
|
||||
|
||||
$this->get('/admin/reports/sales')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_stock_report_renders_for_admin(): void
|
||||
{
|
||||
$this->actingAsRole('admin');
|
||||
|
||||
InsForgeFake::seed('stocks', [
|
||||
'id' => 's2',
|
||||
'product_id' => 'p-1',
|
||||
'quantity' => 20,
|
||||
'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium', 'stock_minimum' => 50, 'cost_price' => 13000],
|
||||
]);
|
||||
|
||||
Livewire::test(StockReport::class)
|
||||
->assertSuccessful()
|
||||
->assertSee('Beras Premium')
|
||||
->assertSee('kritis');
|
||||
}
|
||||
|
||||
public function test_users_page_denied_for_operator(): void
|
||||
{
|
||||
$this->actingAsRole('operator');
|
||||
|
||||
$this->get('/admin/settings/users')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_create_user_via_admin_api_creates_verified_mirror(): void
|
||||
{
|
||||
$this->actingAsRole('admin');
|
||||
|
||||
$result = app(UserAdminService::class)->createUser([
|
||||
'name' => 'Operator Baru',
|
||||
'email' => 'baru@beraspro.test',
|
||||
'password' => 'Rahasia#123',
|
||||
'role' => 'operator',
|
||||
], auth()->user());
|
||||
|
||||
$this->assertArrayHasKey('id', $result);
|
||||
|
||||
// Mirror row dibuat dengan role benar.
|
||||
$mirror = array_values(array_filter(
|
||||
InsForgeFake::$store['users'] ?? [],
|
||||
fn (array $row): bool => $row['email'] === 'baru@beraspro.test',
|
||||
));
|
||||
$this->assertCount(1, $mirror);
|
||||
$this->assertSame('operator', reset($mirror)['role']);
|
||||
|
||||
// email terverifikasi via rawSql UPDATE.
|
||||
$verified = array_values(array_filter(InsForgeFake::$rawSqlQueries, fn (string $q): bool => str_contains($q, 'email_verified = true')));
|
||||
$this->assertNotEmpty($verified);
|
||||
|
||||
// Activity log tertulis.
|
||||
$log = end(InsForgeFake::$store['activity_logs']);
|
||||
$this->assertSame('user_created', $log['action']);
|
||||
}
|
||||
|
||||
public function test_activity_logs_page_renders_for_admin(): void
|
||||
{
|
||||
$this->actingAsRole('admin');
|
||||
|
||||
InsForgeFake::seed('activity_logs', [
|
||||
'id' => 'l1',
|
||||
'action' => 'order_created',
|
||||
'entity_type' => 'orders',
|
||||
'description' => 'Order ORD-X dibuat.',
|
||||
]);
|
||||
|
||||
Livewire::test(ActivityLogs::class)
|
||||
->assertSuccessful()
|
||||
->assertSee('order_created');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Phase6;
|
||||
|
||||
use App\Filament\Widgets\CriticalStock;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Support\InsForgeFake;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WidProbe extends TestCase
|
||||
{
|
||||
public function test_probe(): void
|
||||
{
|
||||
InsForgeFake::reset();
|
||||
InsForgeFake::fakeRest();
|
||||
|
||||
InsForgeFake::seed('products', [
|
||||
'id' => 'p-1', 'code' => 'BERAS-001', 'name' => 'Beras Premium',
|
||||
'cost_price' => 13000, 'stock_minimum' => 50, 'is_active' => true,
|
||||
]);
|
||||
|
||||
$user = new User;
|
||||
$user->forceFill(['id' => 'u1', 'role' => 'operator', 'is_active' => true]);
|
||||
$this->actingAs($user);
|
||||
|
||||
InsForgeFake::seed('stocks', [
|
||||
'id' => 's1',
|
||||
'product_id' => 'p-1',
|
||||
'quantity' => 5,
|
||||
'products' => ['code' => 'BERAS-001', 'name' => 'Beras Premium', 'stock_minimum' => 50, 'cost_price' => 13000],
|
||||
]);
|
||||
|
||||
$html = Livewire::test(CriticalStock::class)->html();
|
||||
echo 'len=', strlen($html), "\n";
|
||||
echo 'Beras: ', str_contains($html, 'Beras Premium') ? 'Y' : 'N', "\n";
|
||||
echo 'kritis: ', str_contains($html, 'kritis') ? 'Y' : 'N', "\n";
|
||||
echo 'no-data: ', str_contains($html, 'tidak ada data') || str_contains($html, 'empty') ? 'Y' : 'N', "\n";
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,20 @@ class InsForgeFake
|
||||
/** @var list<array{function: string, args: array<string, mixed>}> */
|
||||
public static array $rpcCalls = [];
|
||||
|
||||
/** @var array<string, string> email => auth uuid */
|
||||
public static array $authUsersByEmail = [];
|
||||
|
||||
/** @var list<string> */
|
||||
public static array $rawSqlQueries = [];
|
||||
|
||||
public static string $accessToken = '';
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$store = [];
|
||||
self::$rpcCalls = [];
|
||||
self::$authUsersByEmail = [];
|
||||
self::$rawSqlQueries = [];
|
||||
}
|
||||
|
||||
public static function seed(string $table, array $row): void
|
||||
@@ -71,6 +79,30 @@ class InsForgeFake
|
||||
{
|
||||
Http::fake([
|
||||
'*/api/auth/refresh' => Http::response(['accessToken' => self::$accessToken], 200),
|
||||
'*/api/auth/users' => function (Request $request) {
|
||||
if (strtoupper($request->method()) === 'POST') {
|
||||
$payload = $request->data();
|
||||
self::$authUsersByEmail[$payload['email'] ?? ''] = 'auth-'.md5((string) ($payload['email'] ?? ''));
|
||||
|
||||
return Http::response(['accessToken' => null, 'requireEmailVerification' => true], 200);
|
||||
}
|
||||
|
||||
return Http::response([], 200);
|
||||
},
|
||||
'*/api/database/advance/rawsql*' => function (Request $request) {
|
||||
$query = (string) ($request->data()['query'] ?? '');
|
||||
|
||||
self::$rawSqlQueries[] = $query;
|
||||
|
||||
if (str_contains($query, 'FROM auth.users') && str_contains(strtoupper($query), 'SELECT')) {
|
||||
preg_match("/email = '([^']+)'/", $query, $m);
|
||||
$id = self::$authUsersByEmail[$m[1] ?? ''] ?? null;
|
||||
|
||||
return Http::response(['rows' => $id ? [['id' => $id]] : []], 200);
|
||||
}
|
||||
|
||||
return Http::response(['rows' => []], 200);
|
||||
},
|
||||
'*/api/database/rpc/*' => function (Request $request) {
|
||||
$function = str_replace('/api/database/rpc/', '', (string) parse_url($request->url(), PHP_URL_PATH));
|
||||
$args = $request->data();
|
||||
|
||||
Reference in New Issue
Block a user