diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..bcefa3d --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -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 diff --git a/app-modules/booking/src/Filament/Widgets/BookingsTodayWidget.php b/app-modules/booking/src/Filament/Widgets/BookingsTodayWidget.php new file mode 100644 index 0000000..3a3cef5 --- /dev/null +++ b/app-modules/booking/src/Filament/Widgets/BookingsTodayWidget.php @@ -0,0 +1,36 @@ +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'), + ]; + } +} diff --git a/app-modules/booking/src/Filament/Widgets/RecentBookingsTableWidget.php b/app-modules/booking/src/Filament/Widgets/RecentBookingsTableWidget.php new file mode 100644 index 0000000..f15aeea --- /dev/null +++ b/app-modules/booking/src/Filament/Widgets/RecentBookingsTableWidget.php @@ -0,0 +1,52 @@ +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); + } +} diff --git a/app-modules/booking/tests/Feature/BookingsTodayWidgetTest.php b/app-modules/booking/tests/Feature/BookingsTodayWidgetTest.php new file mode 100644 index 0000000..ff161b3 --- /dev/null +++ b/app-modules/booking/tests/Feature/BookingsTodayWidgetTest.php @@ -0,0 +1,22 @@ +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'); +}); diff --git a/app-modules/booking/tests/Feature/RecentBookingsTableWidgetTest.php b/app-modules/booking/tests/Feature/RecentBookingsTableWidgetTest.php new file mode 100644 index 0000000..15467b4 --- /dev/null +++ b/app-modules/booking/tests/Feature/RecentBookingsTableWidgetTest.php @@ -0,0 +1,17 @@ +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]); +}); diff --git a/app-modules/payment/src/Filament/Widgets/PaymentFailureRateWidget.php b/app-modules/payment/src/Filament/Widgets/PaymentFailureRateWidget.php new file mode 100644 index 0000000..82eb1ca --- /dev/null +++ b/app-modules/payment/src/Filament/Widgets/PaymentFailureRateWidget.php @@ -0,0 +1,42 @@ +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')), + ]; + } +} diff --git a/app-modules/payment/src/Filament/Widgets/RevenueChartWidget.php b/app-modules/payment/src/Filament/Widgets/RevenueChartWidget.php new file mode 100644 index 0000000..444a13e --- /dev/null +++ b/app-modules/payment/src/Filament/Widgets/RevenueChartWidget.php @@ -0,0 +1,71 @@ +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 + */ + 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), + ]; + }); + } +} diff --git a/app-modules/payment/tests/Feature/PaymentFailureRateWidgetTest.php b/app-modules/payment/tests/Feature/PaymentFailureRateWidgetTest.php new file mode 100644 index 0000000..f37e357 --- /dev/null +++ b/app-modules/payment/tests/Feature/PaymentFailureRateWidgetTest.php @@ -0,0 +1,22 @@ +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)'); +}); diff --git a/app-modules/payment/tests/Feature/RevenueChartWidgetTest.php b/app-modules/payment/tests/Feature/RevenueChartWidgetTest.php new file mode 100644 index 0000000..98cbb2d --- /dev/null +++ b/app-modules/payment/tests/Feature/RevenueChartWidgetTest.php @@ -0,0 +1,19 @@ +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); +}); diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 71f9e9f..19c8794 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -37,6 +37,8 @@ class AdminPanelProvider extends PanelProvider ->path('admin') ->viteTheme('resources/css/filament/admin/theme.css') ->login() + ->brandLogo(asset('images/logo.png')) + ->brandLogoHeight('2.5rem') ->colors([ 'primary' => Color::Amber, ]) diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..5be5353 Binary files /dev/null and b/public/images/logo.png differ diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index b7355d7..45d62e0 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -4,274 +4,40 @@ - {{ config('app.name', 'Laravel') }} + {{ config('app.name') }} - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif + @vite(['resources/css/app.css', 'resources/js/app.js']) - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

- - -
-
- {{-- Laravel Logo --}} - - - - - - - - - + + Admin Login + + - {{-- Light Mode 12 SVG --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ {{ config('app.name') }} - {{-- Dark Mode 12 SVG --}} - -
-
+

+ {{ config('app.name') }} +

+

+ Door-to-door EV route booking & dispatch. +

-
- @if (Route::has('login')) - - @endif + +