Phase 7 dashboard widgets, Gitea CI, and branded landing page (T7.1)
- BookingsTodayWidget, RecentBookingsTableWidget (Booking module) and RevenueChartWidget, PaymentFailureRateWidget (Payment module) dashboard widgets, auto-registered via each plugin's existing discoverWidgets(). - .gitea/workflows/tests.yml: Postgres-backed Pest run on push/PR. - Landing page (resources/views/welcome.blade.php) now shows the Famous Linnyone4 EV logo with a single admin login link, and the Filament admin panel uses the same logo as its brand logo.
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
name: PHP Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
php-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: testing
|
||||||
|
POSTGRES_USER: root
|
||||||
|
POSTGRES_PASSWORD: ''
|
||||||
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.5'
|
||||||
|
extensions: mbstring, bcmath, intl, gd, zip, pdo, pdo_pgsql, redis, pcntl
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Copy .env
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --no-progress
|
||||||
|
|
||||||
|
- name: Install npm dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build frontend assets
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Generate app key
|
||||||
|
run: php artisan key:generate
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
DB_CONNECTION: pgsql
|
||||||
|
DB_HOST: 127.0.0.1
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_DATABASE: testing
|
||||||
|
DB_USERNAME: root
|
||||||
|
DB_PASSWORD: ''
|
||||||
|
run: php artisan test --compact
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Booking\Filament\Widgets;
|
||||||
|
|
||||||
|
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
use Modules\Booking\Enums\BookingStatus;
|
||||||
|
use Modules\Booking\Models\Booking;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch-facing snapshot of today's trips — "today" means travel_date, not
|
||||||
|
* created_at, since this is what staff care about when assigning
|
||||||
|
* drivers/vehicles (domain.md §5a), not how many bookings were made today.
|
||||||
|
*/
|
||||||
|
class BookingsTodayWidget extends BaseWidget
|
||||||
|
{
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
$today = Booking::query()->whereDate('travel_date', today());
|
||||||
|
|
||||||
|
$confirmedToday = (clone $today)->where('status', BookingStatus::Confirmed)->count();
|
||||||
|
$pendingToday = (clone $today)->where('status', BookingStatus::PendingPayment)->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('Trips Today', (clone $today)->count())
|
||||||
|
->description('Bookings scheduled for today')
|
||||||
|
->color('primary'),
|
||||||
|
Stat::make('Confirmed', $confirmedToday)
|
||||||
|
->description('Paid & ready for driver assignment')
|
||||||
|
->color('success'),
|
||||||
|
Stat::make('Awaiting Payment', $pendingToday)
|
||||||
|
->description('Still pending_payment')
|
||||||
|
->color($pendingToday > 0 ? 'warning' : 'gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Booking\Filament\Widgets;
|
||||||
|
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Filament\Widgets\TableWidget as BaseWidget;
|
||||||
|
use Modules\Booking\Enums\BookingStatus;
|
||||||
|
use Modules\Booking\Models\Booking;
|
||||||
|
|
||||||
|
class RecentBookingsTableWidget extends BaseWidget
|
||||||
|
{
|
||||||
|
protected static ?int $sort = 2;
|
||||||
|
|
||||||
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->heading('Recent Bookings')
|
||||||
|
->query(
|
||||||
|
Booking::query()
|
||||||
|
->with(['route.fromDestination', 'route.toDestination'])
|
||||||
|
->latest('created_at')
|
||||||
|
->limit(10),
|
||||||
|
)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('booking_ref')
|
||||||
|
->label('Ref'),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->color(fn (BookingStatus $state) => match ($state) {
|
||||||
|
BookingStatus::PendingPayment => 'warning',
|
||||||
|
BookingStatus::Confirmed => 'success',
|
||||||
|
BookingStatus::Cancelled => 'gray',
|
||||||
|
BookingStatus::Expired => 'danger',
|
||||||
|
}),
|
||||||
|
TextColumn::make('route.fromDestination.name')
|
||||||
|
->label('From'),
|
||||||
|
TextColumn::make('route.toDestination.name')
|
||||||
|
->label('To'),
|
||||||
|
TextColumn::make('travel_date')
|
||||||
|
->date(),
|
||||||
|
TextColumn::make('price')
|
||||||
|
->numeric(2),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->dateTime()
|
||||||
|
->since(),
|
||||||
|
])
|
||||||
|
->paginated(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Modules\Booking\Enums\BookingStatus;
|
||||||
|
use Modules\Booking\Filament\Widgets\BookingsTodayWidget;
|
||||||
|
use Modules\Booking\Models\Booking;
|
||||||
|
|
||||||
|
test('it counts todays trips by status, ignoring other days', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::Confirmed]);
|
||||||
|
Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::PendingPayment]);
|
||||||
|
Booking::factory()->create(['travel_date' => today()->addDay(), 'status' => BookingStatus::Confirmed]);
|
||||||
|
|
||||||
|
Livewire::test(BookingsTodayWidget::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Trips Today')
|
||||||
|
->assertSee('2')
|
||||||
|
->assertSee('Confirmed')
|
||||||
|
->assertSee('Awaiting Payment');
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Modules\Booking\Filament\Widgets\RecentBookingsTableWidget;
|
||||||
|
use Modules\Booking\Models\Booking;
|
||||||
|
|
||||||
|
test('it lists the most recently created bookings', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
$older = Booking::factory()->create(['created_at' => now()->subDay()]);
|
||||||
|
$newer = Booking::factory()->create(['created_at' => now()]);
|
||||||
|
|
||||||
|
Livewire::test(RecentBookingsTableWidget::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertCanSeeTableRecords([$newer, $older]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Payment\Filament\Widgets;
|
||||||
|
|
||||||
|
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
use Modules\Payment\Enums\PaymentStatus;
|
||||||
|
use Modules\Payment\Models\Payment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failure rate over the trailing 30 days, among payments that reached a
|
||||||
|
* terminal state (completed or failed) — pending attempts are excluded
|
||||||
|
* since they haven't resolved either way yet.
|
||||||
|
*/
|
||||||
|
class PaymentFailureRateWidget extends BaseWidget
|
||||||
|
{
|
||||||
|
protected static ?int $sort = 3;
|
||||||
|
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
$since = today()->subDays(30);
|
||||||
|
|
||||||
|
$completed = Payment::query()
|
||||||
|
->where('status', PaymentStatus::Completed)
|
||||||
|
->where('initiated_at', '>=', $since)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$failed = Payment::query()
|
||||||
|
->where('status', PaymentStatus::Failed)
|
||||||
|
->where('initiated_at', '>=', $since)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$resolved = $completed + $failed;
|
||||||
|
$rate = $resolved > 0 ? round(($failed / $resolved) * 100, 1) : 0.0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('Payment Failure Rate', $rate.'%')
|
||||||
|
->description("{$failed} failed of {$resolved} resolved (last 30 days)")
|
||||||
|
->color($rate >= 20 ? 'danger' : ($rate > 0 ? 'warning' : 'success')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Payment\Filament\Widgets;
|
||||||
|
|
||||||
|
use Filament\Widgets\ChartWidget;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Modules\Payment\Enums\PaymentStatus;
|
||||||
|
use Modules\Payment\Models\Payment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 30-day revenue trend from completed payments. Cached per day (T7.1) —
|
||||||
|
* refreshes at most once every 5 minutes since this aggregates a whole
|
||||||
|
* month of rows on every dashboard load otherwise.
|
||||||
|
*/
|
||||||
|
class RevenueChartWidget extends ChartWidget
|
||||||
|
{
|
||||||
|
protected static ?int $sort = 1;
|
||||||
|
|
||||||
|
protected ?string $heading = 'Revenue (Last 30 Days)';
|
||||||
|
|
||||||
|
protected function getData(): array
|
||||||
|
{
|
||||||
|
$days = Cache::tags('payments')->remember(
|
||||||
|
'dashboard:revenue-chart:'.today()->toDateString(),
|
||||||
|
now()->addMinutes(5),
|
||||||
|
fn () => $this->revenueByDay(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'datasets' => [
|
||||||
|
[
|
||||||
|
'label' => 'Revenue',
|
||||||
|
'data' => $days->pluck('total')->all(),
|
||||||
|
'fill' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'labels' => $days->pluck('label')->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getType(): string
|
||||||
|
{
|
||||||
|
return 'line';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, array{label: string, total: float}>
|
||||||
|
*/
|
||||||
|
private function revenueByDay(): Collection
|
||||||
|
{
|
||||||
|
$start = today()->subDays(29);
|
||||||
|
|
||||||
|
$totals = Payment::query()
|
||||||
|
->where('status', PaymentStatus::Completed)
|
||||||
|
->whereDate('completed_at', '>=', $start)
|
||||||
|
->selectRaw('DATE(completed_at) as day, SUM(amount) as total')
|
||||||
|
->groupBy('day')
|
||||||
|
->pluck('total', 'day');
|
||||||
|
|
||||||
|
return collect(range(0, 29))
|
||||||
|
->map(function (int $offset) use ($start, $totals) {
|
||||||
|
$date = $start->copy()->addDays($offset);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => $date->format('M j'),
|
||||||
|
'total' => (float) ($totals[$date->toDateString()] ?? 0),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Modules\Payment\Filament\Widgets\PaymentFailureRateWidget;
|
||||||
|
use Modules\Payment\Models\Payment;
|
||||||
|
|
||||||
|
test('it computes the failure rate among resolved payments in the last 30 days', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
Payment::factory()->completed()->create(['initiated_at' => today()]);
|
||||||
|
Payment::factory()->completed()->create(['initiated_at' => today()]);
|
||||||
|
Payment::factory()->completed()->create(['initiated_at' => today()]);
|
||||||
|
Payment::factory()->failed()->create(['initiated_at' => today()]);
|
||||||
|
Payment::factory()->create(['initiated_at' => today()]); // pending, excluded from resolved total
|
||||||
|
Payment::factory()->failed()->create(['initiated_at' => today()->subDays(40)]); // outside window
|
||||||
|
|
||||||
|
Livewire::test(PaymentFailureRateWidget::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('25%')
|
||||||
|
->assertSee('1 failed of 4 resolved (last 30 days)');
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Modules\Payment\Filament\Widgets\RevenueChartWidget;
|
||||||
|
use Modules\Payment\Models\Payment;
|
||||||
|
|
||||||
|
test('it sums completed payment amounts per day over the last 30 days', function () {
|
||||||
|
Payment::factory()->completed()->create(['amount' => 10000, 'completed_at' => today()]);
|
||||||
|
Payment::factory()->completed()->create(['amount' => 5000, 'completed_at' => today()]);
|
||||||
|
Payment::factory()->create(['amount' => 99999, 'completed_at' => null]); // pending, excluded
|
||||||
|
Payment::factory()->completed()->create(['amount' => 77777, 'completed_at' => today()->subDays(40)]); // outside window
|
||||||
|
|
||||||
|
$widget = new RevenueChartWidget;
|
||||||
|
$getData = (new ReflectionMethod($widget, 'getData'));
|
||||||
|
$getData->setAccessible(true);
|
||||||
|
$data = $getData->invoke($widget);
|
||||||
|
|
||||||
|
expect($data['labels'])->toHaveCount(30)
|
||||||
|
->and(array_sum($data['datasets'][0]['data']))->toBe(15000.0);
|
||||||
|
});
|
||||||
@@ -37,6 +37,8 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->path('admin')
|
->path('admin')
|
||||||
->viteTheme('resources/css/filament/admin/theme.css')
|
->viteTheme('resources/css/filament/admin/theme.css')
|
||||||
->login()
|
->login()
|
||||||
|
->brandLogo(asset('images/logo.png'))
|
||||||
|
->brandLogoHeight('2.5rem')
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'primary' => Color::Amber,
|
||||||
])
|
])
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user