Files
famous-ly4-ev/app-modules/reporting/src/Filament/Pages/BookingsRevenueReport.php
T
Nyan Lin Paing 0e55e36cea
PHP Tests / php-tests (push) Has been cancelled
Add Bookings & Revenue reporting module
New modules/reporting Filament page: filterable bookings table (travel
date range, status, route, channel) with CSV/Excel export. Report
columns include booking ref, route, passenger name/count, price,
best-payment status/amount, and driver info, plus a TOTAL row summing
passenger count, price, and payment amount in both export formats.

- BookingsRevenueExport backs both CSV and XLSX via maatwebsite/excel
  ^4.0 (the only version compatible with PHP 8.5; 3.1.x caps
  phpoffice/phpspreadsheet below 8.5).
- CSV export writes a UTF-8 BOM so non-Latin passenger names (Burmese)
  open correctly in Excel.
- New view_reports permission (super_admin/admin/support) gates the
  page; new indexes on bookings.travel_date/status/created_by_channel
  and payments.completed_at support the report's filters.
2026-08-23 22:32:49 +07:00

196 lines
7.7 KiB
PHP

<?php
namespace Modules\Reporting\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\Summarizers\Sum;
use Filament\Tables\Columns\Summarizers\Summarizer;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Maatwebsite\Excel\Excel as ExcelFormat;
use Maatwebsite\Excel\Facades\Excel;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Models\Payment;
use Modules\Reporting\Exports\BookingsRevenueExport;
use Modules\Routing\Models\EvRoute;
use UnitEnum;
class BookingsRevenueReport extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentChartBar;
protected static string|UnitEnum|null $navigationGroup = 'Reports';
protected static ?string $navigationLabel = 'Bookings & Revenue';
protected static ?string $title = 'Bookings & Revenue Report';
protected string $view = 'reporting::filament.pages.bookings-revenue-report';
/**
* @var array<string, mixed>|null
*/
public ?array $filters = [];
public static function canAccess(): bool
{
return auth()->user()?->can('view_reports') ?? false;
}
public function mount(): void
{
$this->filtersForm->fill();
}
public function filtersForm(Schema $schema): Schema
{
return $schema
->components([
DatePicker::make('travel_date_from')
->label('Travel date from')
->live(),
DatePicker::make('travel_date_to')
->label('Travel date to')
->afterOrEqual('travel_date_from')
->live(),
Select::make('status')
->label('Status')
->options(BookingStatus::class)
->native(false)
->placeholder('All statuses')
->live(),
Select::make('ev_route_id')
->label('Route')
->options(fn () => EvRoute::with(['fromDestination', 'toDestination'])->get()
->mapWithKeys(fn (EvRoute $route) => [$route->id => $route->name]))
->searchable()
->placeholder('All routes')
->live(),
Select::make('created_by_channel')
->label('Channel')
->options(BookingChannel::class)
->native(false)
->placeholder('All channels')
->live(),
])
->columns(3)
->statePath('filters');
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->reportQuery())
->columns([
TextColumn::make('booking_ref')
->label('Ref')
->sortable(),
TextColumn::make('travel_date')
->date()
->sortable(),
TextColumn::make('route.name')
->label('Route'),
TextColumn::make('created_by_channel')
->badge(),
TextColumn::make('status')
->badge(),
TextColumn::make('passenger_name')
->label('Passenger'),
TextColumn::make('passenger_count')
->label('Pax')
->state(fn (Booking $record) => $record->vehicleOptions->sum('passenger_count'))
->summarize(Summarizer::make()
->label('Total')
->using(fn (QueryBuilder $query) => Booking::query()
->with('vehicleOptions')
->whereIn('id', (clone $query)->pluck('id'))
->get()
->sum(fn (Booking $b) => $b->vehicleOptions->sum('passenger_count')))),
TextColumn::make('price')
->numeric(2)
->sortable()
->summarize(Sum::make()->label('Total')),
TextColumn::make('payment_status')
->label('Payment')
->state(fn (Booking $record) => $this->bestPayment($record)?->status?->value ?? '—'),
TextColumn::make('payment_amount')
->label('Paid')
->state(fn (Booking $record) => $this->bestPayment($record)?->amount)
->summarize(Summarizer::make()
->label('Total')
->using(fn (QueryBuilder $query) => Booking::query()
->with('payments')
->whereIn('id', (clone $query)->pluck('id'))
->get()
->sum(fn (Booking $b) => $this->bestPayment($b)?->amount ?? 0))),
TextColumn::make('driver_name')
->label('Driver')
->placeholder('—'),
])
->defaultSort('travel_date', 'desc')
->paginated([25, 50, 100]);
}
public function reportQuery(): Builder
{
$data = $this->filters ?? [];
return Booking::query()
->with(['route.fromDestination', 'route.toDestination', 'payments', 'vehicleOptions'])
->when($data['travel_date_from'] ?? null, fn (Builder $q, $d) => $q->whereDate('travel_date', '>=', $d))
->when($data['travel_date_to'] ?? null, fn (Builder $q, $d) => $q->whereDate('travel_date', '<=', $d))
->when($data['status'] ?? null, fn (Builder $q, $s) => $q->where('status', $s))
->when($data['ev_route_id'] ?? null, fn (Builder $q, $id) => $q->where('ev_route_id', $id))
->when($data['created_by_channel'] ?? null, fn (Builder $q, $c) => $q->where('created_by_channel', $c))
// A unique tie-breaker after travel_date — required for FromQuery's
// chunked export to paginate deterministically (see its docblock);
// the table's own defaultSort() applies on top of this for display.
->orderBy('travel_date', 'desc')
->orderBy('id');
}
protected function bestPayment(Booking $record): ?Payment
{
return $record->payments->sortByDesc(
fn (Payment $p) => $p->status === PaymentStatus::Completed ? 1 : 0
)->first();
}
protected function getHeaderActions(): array
{
return [
Action::make('exportCsv')
->label('Export CSV')
->icon(Heroicon::OutlinedArrowDownTray)
->action(fn () => Excel::download(
new BookingsRevenueExport($this->reportQuery()),
'bookings-revenue-'.now()->format('Y-m-d').'.csv',
ExcelFormat::CSV,
)),
Action::make('exportXlsx')
->label('Export Excel')
->icon(Heroicon::OutlinedArrowDownTray)
->action(fn () => Excel::download(
new BookingsRevenueExport($this->reportQuery()),
'bookings-revenue-'.now()->format('Y-m-d').'.xlsx',
ExcelFormat::XLSX,
)),
];
}
}