feat: Implement a new Analytic dashboard page with several data visualization widgets, add Company Status Chart, and expand Filament dashboard color options.

This commit is contained in:
Yoga Pangestu 2026-03-26 09:25:03 +07:00
parent 3dd098759c
commit c493d066f4
6 changed files with 274 additions and 93 deletions

View File

@ -0,0 +1,114 @@
<?php
namespace App\Filament\Pages;
use App\Enums\RoleEnum;
use App\Filament\Widgets\CooperationStatusChart;
use App\Filament\Widgets\IssueLocationChart;
use App\Filament\Widgets\JournalistRegistrationChart;
use App\Filament\Widgets\MediaClassificationChart;
use App\Filament\Widgets\MediaCooperationRankingChart;
use App\Filament\Widgets\MediaRegistrationChart;
use App\Filament\Widgets\MediaTypeChart;
use App\Filament\Widgets\PopularNewsChart;
use App\Filament\Widgets\TaskRealizationChart;
use App\Filament\Widgets\TopMediaJournalistChart;
use App\Filament\Widgets\VerificationPerformanceChart;
use App\Filament\Widgets\VisitorTrafficChart;
use BackedEnum;
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Pages\Dashboard\Concerns\HasFiltersForm;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
class Analytic extends BaseDashboard
{
use HasFiltersForm, HasPageShield;
protected static string $routePath = '/analytic';
protected static ?string $slug = 'analytic';
protected static ?string $title = 'Statistik';
protected static ?string $navigationLabel = 'Statistik';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
protected static ?int $navigationSort = 2;
protected static ?string $pollingInterval = null;
public function getColumns(): int|array
{
return 2;
}
public function filtersForm(Schema $schema): Schema
{
return $schema
->schema([
Fieldset::make('Filter')
->schema([
DatePicker::make('startDate')
->label('Tanggal Mulai')
->placeholder('Pilih tanggal mulai')
->native(false)
->displayFormat('l, d F Y'),
DatePicker::make('endDate')
->label('Tanggal Selesai')
->placeholder('Pilih tanggal selesai')
->native(false)
->displayFormat('l, d F Y'),
])
->columns(2)
->columnSpanFull()
->visible(fn () => auth()->user()->hasRole(RoleEnum::DEVELOPER->value) || auth()->user()->hasRole(RoleEnum::ADMINISTRATOR->value)),
]);
}
protected function getHeaderActions(): array
{
return [
Action::make('reset')
->label('Reset Filter')
->icon(Heroicon::OutlinedXMark)
->color('danger')
->action(fn () => $this->resetFilters())
->visible(fn () => ! empty($this->filters['startDate']) || ! empty($this->filters['endDate'])),
];
}
public function resetFilters(): void
{
$this->filters['startDate'] = null;
$this->filters['endDate'] = null;
if (method_exists($this, 'getFiltersSessionKey')) {
session()->forget($this->getFiltersSessionKey());
}
}
public function getWidgets(): array
{
return [
CooperationStatusChart::class,
IssueLocationChart::class,
JournalistRegistrationChart::class,
MediaClassificationChart::class,
MediaCooperationRankingChart::class,
MediaRegistrationChart::class,
MediaTypeChart::class,
PopularNewsChart::class,
TaskRealizationChart::class,
TopMediaJournalistChart::class,
VerificationPerformanceChart::class,
VisitorTrafficChart::class,
];
}
}

View File

@ -2,68 +2,27 @@
namespace App\Filament\Pages;
use App\Enums\RoleEnum;
use App\Filament\Widgets\CompanyStatusChart;
use App\Filament\Widgets\OverviewStats;
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Pages\Dashboard\Concerns\HasFiltersForm;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
class Dashboard extends BaseDashboard
{
use HasFiltersForm, HasPageShield;
use HasPageShield;
protected static ?string $pollingInterval = null;
public function getColumns(): int|array
{
return 2;
}
public function filtersForm(Schema $schema): Schema
{
return $schema
->schema([
Fieldset::make('Filter')
->schema([
DatePicker::make('startDate')
->label('Tanggal Mulai')
->placeholder('Pilih tanggal mulai')
->native(false)
->displayFormat('l, d F Y'),
DatePicker::make('endDate')
->label('Tanggal Selesai')
->placeholder('Pilih tanggal selesai')
->native(false)
->displayFormat('l, d F Y'),
])
->columns(2)
->columnSpanFull()
->visible(fn () => auth()->user()->hasRole(RoleEnum::DEVELOPER->value) || auth()->user()->hasRole(RoleEnum::ADMINISTRATOR->value)),
]);
}
protected function getHeaderActions(): array
public function getWidgets(): array
{
return [
Action::make('reset')
->label('Reset Filter')
->icon(Heroicon::OutlinedXMark)
->color('danger')
->action(fn () => $this->resetFilters())
->visible(fn () => ! empty($this->filters['startDate']) || ! empty($this->filters['endDate'])),
OverviewStats::class,
CompanyStatusChart::class,
];
}
public function resetFilters(): void
{
$this->filters['startDate'] = null;
$this->filters['endDate'] = null;
if (method_exists($this, 'getFiltersSessionKey')) {
session()->forget($this->getFiltersSessionKey());
}
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Filament\Widgets;
use App\Enums\VerificationStatus;
use App\Models\Company;
use App\Models\VerificationRequest;
use BezhanSalleh\FilamentShield\Traits\HasWidgetShield;
use Filament\Widgets\ChartWidget;
class CompanyStatusChart extends ChartWidget
{
use HasWidgetShield;
protected ?string $heading = 'Status Verifikasi Perusahaan';
protected static ?int $sort = 2;
protected int|string|array $columnSpan = 1;
protected function getData(): array
{
$data = VerificationRequest::query()
->selectRaw('status, count(*) as total')
->groupBy('status')
->get();
$notSubmittedCount = Company::doesntHave('verificationRequest')->count();
$labels = ['Belum Mengajukan'];
$counts = [$notSubmittedCount];
$colors = ['#94a3b8']; // Slate 400
foreach (VerificationStatus::cases() as $status) {
$labels[] = $status->getLabel();
$counts[] = $data->where('status', $status)->first()?->total ?? 0;
$colors[] = match ($status) {
VerificationStatus::PENDING => '#f59e0b', // Amber 500
VerificationStatus::APPROVED => '#10b981', // Emerald 500
VerificationStatus::NEED_REVISION => '#0ea5e9', // Sky 500
VerificationStatus::REJECTED => '#f43f5e', // Rose 500
};
}
return [
'datasets' => [
[
'label' => 'Jumlah Perusahaan',
'data' => $counts,
'backgroundColor' => $colors,
],
],
'labels' => $labels,
];
}
protected function getType(): string
{
return 'pie';
}
}

View File

@ -2,93 +2,111 @@
namespace App\Filament\Widgets;
use App\Models\Company;
use App\Enums\IsActive;
use App\Models\Classification;
use App\Models\DataChangeRequest;
use App\Models\Department;
use App\Models\Journalist;
use App\Models\Location;
use App\Models\PartnerMedia;
use App\Models\Theme;
use App\Models\VerificationRequest;
use App\Models\Visitor;
use BezhanSalleh\FilamentShield\Traits\HasWidgetShield;
use Filament\Support\Icons\Heroicon;
use Filament\Widgets\Concerns\InteractsWithPageFilters;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class OverviewStats extends BaseWidget
{
use HasWidgetShield, InteractsWithPageFilters;
use HasWidgetShield;
protected static ?int $sort = 1;
protected int|string|array $columnSpan = 'full';
protected ?string $pollingInterval = null;
protected function getStats(): array
{
$startDate = $this->filters['startDate'] ?? null;
$endDate = $this->filters['endDate'] ?? null;
$totalJournalists = Journalist::when($startDate, fn ($q) => $q->whereDate('created_at', '>=', $startDate))
->when($endDate, fn ($q) => $q->whereDate('created_at', '<=', $endDate))
->count();
$totalMedia = PartnerMedia::when($startDate, fn ($q) => $q->whereDate('created_at', '>=', $startDate))
->when($endDate, fn ($q) => $q->whereDate('created_at', '<=', $endDate))
->count();
$totalJournalists = Journalist::count();
$totalMedia = PartnerMedia::count();
$average = $totalMedia > 0 ? round($totalJournalists / $totalMedia, 1) : 0;
return [
Stat::make('Total Perusahaan', Company::when($startDate, fn ($q) => $q->whereDate('created_at', '>=', $startDate))
->when($endDate, fn ($q) => $q->whereDate('created_at', '<=', $endDate))
->count())
->description('Perusahaan terdaftar')
->descriptionIcon(Heroicon::OutlinedBuildingOffice2)
->color('primary'),
Stat::make('Total Klasifikasi', Classification::active()->count())
->description('Klasifikasi aktif')
->descriptionIcon(Heroicon::OutlinedBookOpen)
->color($this->getRandomColor()),
Stat::make('Total Lokus', Location::active()->count())
->description('Lokus aktif')
->descriptionIcon(Heroicon::OutlinedMapPin)
->color($this->getRandomColor()),
Stat::make('Total OPD', Department::where('is_active', IsActive::ACTIVE)->count())
->description('OPD aktif')
->descriptionIcon(Heroicon::OutlinedBuildingLibrary)
->color($this->getRandomColor()),
Stat::make('Total Tema', Theme::active()->count())
->description('Tema aktif')
->descriptionIcon(Heroicon::OutlinedPuzzlePiece)
->color($this->getRandomColor()),
Stat::make('Media Terverifikasi', PartnerMedia::whereHas('company.verificationRequest', function ($q) {
$q->approved();
})
->when($startDate, fn ($q) => $q->whereDate('created_at', '>=', $startDate))
->when($endDate, fn ($q) => $q->whereDate('created_at', '<=', $endDate))
->count())
})->count())
->description('Media aktif')
->descriptionIcon(Heroicon::OutlinedCheckBadge)
->color('success'),
->color($this->getRandomColor()),
Stat::make('Total Jurnalis', Journalist::when($startDate, fn ($q) => $q->whereDate('created_at', '>=', $startDate))
->when($endDate, fn ($q) => $q->whereDate('created_at', '<=', $endDate))
->count())
Stat::make('Total Jurnalis', $totalJournalists)
->description('Jurnalis terdaftar')
->descriptionIcon(Heroicon::OutlinedIdentification)
->color('info'),
->color($this->getRandomColor()),
Stat::make('Antrean Verifikasi', VerificationRequest::pending()
->when($startDate, fn ($q) => $q->whereDate('created_at', '>=', $startDate))
->when($endDate, fn ($q) => $q->whereDate('created_at', '<=', $endDate))
->count())
Stat::make('Antrean Verifikasi', VerificationRequest::pending()->count())
->description('Butuh tindakan')
->descriptionIcon(Heroicon::OutlinedPencilSquare)
->color('warning'),
->color($this->getRandomColor()),
Stat::make('Antrean Perubahan', DataChangeRequest::pending()
->when($startDate, fn ($q) => $q->whereDate('created_at', '>=', $startDate))
->when($endDate, fn ($q) => $q->whereDate('created_at', '<=', $endDate))
->count())
Stat::make('Antrean Perubahan', DataChangeRequest::pending()->count())
->description('Perubahan data')
->descriptionIcon(Heroicon::OutlinedDocumentText)
->color('warning'),
->color($this->getRandomColor()),
Stat::make('Total Pengunjung', Visitor::when($startDate, fn ($q) => $q->whereDate('date', '>=', $startDate))
->when($endDate, fn ($q) => $q->whereDate('date', '<=', $endDate))
->count())
Stat::make('Total Pengunjung', Visitor::count())
->description('Total traffic')
->descriptionIcon(Heroicon::OutlinedChartBar)
->color('info'),
->color($this->getRandomColor()),
Stat::make('Rata-rata Jurnalis / Media', $average)
->description('Rasio sebaran jurnalis')
->descriptionIcon(Heroicon::OutlinedUsers)
->color('primary'),
->color($this->getRandomColor()),
];
}
private array $availableColors = [
'primary',
'success',
'warning',
'danger',
'info',
'fuchsia',
'indigo',
'violet',
'orange',
'cyan',
'emerald',
'amber',
'rose',
'lime',
];
private function getRandomColor(): string
{
return $this->availableColors[array_rand($this->availableColors)];
}
}

View File

@ -60,7 +60,20 @@ public function panel(Panel $panel): Panel
->emailVerification(EmailVerification::class)
->colors([
'primary' => Color::Blue,
'success' => Color::Emerald,
'warning' => Color::Amber,
'danger' => Color::Rose,
'info' => Color::Sky,
'slate' => Color::Slate,
'fuchsia' => Color::Fuchsia,
'indigo' => Color::Indigo,
'violet' => Color::Violet,
'orange' => Color::Orange,
'cyan' => Color::Cyan,
'emerald' => Color::Emerald,
'amber' => Color::Amber,
'rose' => Color::Rose,
'lime' => Color::Lime,
])
->breadcrumbs(false)
->maxContentWidth(Width::Full)

View File

@ -18,6 +18,8 @@ public function run(): void
"name": "Developer",
"guard_name": "web",
"permissions": [
"View:Analytic",
"View:AdminVerification",
"ViewAny:Announcement",
@ -44,6 +46,8 @@ public function run(): void
"Replicate:Category",
"Reorder:Category",
"View:CompanyStatusChart",
"ViewAny:Classification",
"View:Classification",
"Create:Classification",
@ -277,6 +281,8 @@ public function run(): void
"name": "Administrator",
"guard_name": "web",
"permissions": [
"View:Analytic",
"View:AdminVerification",
"ViewAny:Announcement",
@ -303,6 +309,8 @@ public function run(): void
"Replicate:Category",
"Reorder:Category",
"View:CompanyStatusChart",
"ViewAny:Classification",
"View:Classification",
"Create:Classification",
@ -522,6 +530,8 @@ public function run(): void
"name": "Admin Monitoring",
"guard_name": "web",
"permissions": [
"View:Analytic",
"ViewAny:Classification",
"View:Classification",
"Create:Classification",
@ -625,6 +635,8 @@ public function run(): void
"name": "Admin Konten",
"guard_name": "web",
"permissions": [
"View:Analytic",
"ViewAny:Category",
"View:Category",
@ -681,6 +693,8 @@ public function run(): void
"name": "Admin Keuangan",
"guard_name": "web",
"permissions": [
"View:Analytic",
"ViewAny:Cooperation",
"View:Cooperation",
@ -691,6 +705,8 @@ public function run(): void
"name": "Perusahaan",
"guard_name": "web",
"permissions": [
"View:Analytic",
"ViewAny:Cooperation",
"View:Cooperation",