Add Booking module: model, create/read/cancel API, Filament resource (T4.1-T4.7)
- Booking model with booking_vehicle_options line items (supports mixing vehicle options like front_seat + back_seat in one booking), price snapshot, status machine, and driver/car assignment fields - BookingService: front-seat max, disabled-option toggles, duplicate-option and whole-vehicle-exclusivity guards - CreateBookingAction, CancelBookingAction, AssignDriverAction - BookingRefGenerator: sequential EVB-AAAAA1-style refs via row lock - POST/GET/cancel booking API endpoints (Sanctum, ownership + admin policy) - BookingPlugin + Filament BookingResource: list, detail view, Cancel and Assign Driver actions (shared between table and detail page) - domain.md updated for multi-vehicle-option bookings (§2) and driver/ vehicle assignment (§5a)
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Modules\Booking\Actions\AssignDriverAction;
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Shared between BookingsTable (row action) and ViewBooking (header action)
|
||||
* so both surfaces stay in sync — one definition, not two.
|
||||
*/
|
||||
class AssignDriverTableAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('assignDriver')
|
||||
->label('Assign Driver')
|
||||
->icon(Heroicon::OutlinedTruck)
|
||||
->color('primary')
|
||||
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
|
||||
&& (auth()->user()?->can('manage_bookings') ?? false))
|
||||
->schema([
|
||||
TextInput::make('driver_name')->required(),
|
||||
TextInput::make('driver_phone')->required(),
|
||||
TextInput::make('car_plate_number')->required(),
|
||||
TextInput::make('car_model'),
|
||||
])
|
||||
->fillForm(fn (Booking $record): array => [
|
||||
'driver_name' => $record->driver_name,
|
||||
'driver_phone' => $record->driver_phone,
|
||||
'car_plate_number' => $record->car_plate_number,
|
||||
'car_model' => $record->car_model,
|
||||
])
|
||||
->action(function (array $data, Booking $record, AssignDriverAction $assignDriverAction) {
|
||||
try {
|
||||
$assignDriverAction->handle($record, new AssignDriverData(
|
||||
driverName: $data['driver_name'],
|
||||
driverPhone: $data['driver_phone'],
|
||||
carPlateNumber: $data['car_plate_number'],
|
||||
carModel: $data['car_model'] ?: null,
|
||||
));
|
||||
|
||||
Notification::make()
|
||||
->title('Driver assigned')
|
||||
->success()
|
||||
->send();
|
||||
} catch (DriverAssignmentNotAllowedException $exception) {
|
||||
Notification::make()
|
||||
->title('Cannot assign driver')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Actions\CancelBookingAction;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Shared between BookingsTable (row action) and ViewBooking (header action)
|
||||
* so both surfaces stay in sync — one definition, not two.
|
||||
*/
|
||||
class CancelBookingTableAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->icon(Heroicon::OutlinedXCircle)
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Booking $record): bool => Gate::allows('cancel', $record))
|
||||
->disabled(fn (Booking $record): bool => $record->status !== BookingStatus::PendingPayment)
|
||||
->action(function (Booking $record, CancelBookingAction $cancelBookingAction) {
|
||||
try {
|
||||
$cancelBookingAction->handle($record);
|
||||
|
||||
Notification::make()
|
||||
->title('Booking cancelled')
|
||||
->success()
|
||||
->send();
|
||||
} catch (BookingCannotBeCancelledException $exception) {
|
||||
Notification::make()
|
||||
->title('Cannot cancel booking')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Schemas\BookingInfolist;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Tables\BookingsTable;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Read-mostly by design: bookings are created through the API (T4.4), not
|
||||
* hand-entered in the admin — so this resource has no create/edit form, just
|
||||
* a list with filters and a status-gated Cancel action (T4.6).
|
||||
*/
|
||||
class BookingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Booking::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTicket;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Operations';
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return BookingsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return BookingInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListBookings::route('/'),
|
||||
'view' => ViewBooking::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
|
||||
class ListBookings extends ListRecords
|
||||
{
|
||||
protected static string $resource = BookingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — bookings are created through the API (T4.4), not
|
||||
// hand-entered here.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
|
||||
class ViewBooking extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BookingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
AssignDriverTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
|
||||
class BookingInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Booking')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('booking_ref')->label('Ref'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (BookingStatus $state) => match ($state) {
|
||||
BookingStatus::PendingPayment => 'warning',
|
||||
BookingStatus::Confirmed => 'success',
|
||||
BookingStatus::Cancelled => 'gray',
|
||||
BookingStatus::Expired => 'danger',
|
||||
}),
|
||||
TextEntry::make('created_by_channel')->badge(),
|
||||
TextEntry::make('created_at')->dateTime(),
|
||||
]),
|
||||
]),
|
||||
Section::make('Trip')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextEntry::make('route.company.name')->label('Company'),
|
||||
TextEntry::make('route.fromDestination.name')->label('From'),
|
||||
TextEntry::make('route.toDestination.name')->label('To'),
|
||||
TextEntry::make('timeSlot.label')->label('Time Slot'),
|
||||
TextEntry::make('travel_date')->date(),
|
||||
TextEntry::make('is_round_trip')->label('Round Trip')->badge(),
|
||||
TextEntry::make('return_travel_date')->date()
|
||||
->visible(fn ($record) => $record->is_round_trip),
|
||||
]),
|
||||
]),
|
||||
Section::make('Vehicle Options')
|
||||
->schema([
|
||||
RepeatableEntry::make('vehicleOptions')
|
||||
->label('')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('vehicle_option')->badge(),
|
||||
TextEntry::make('passenger_count'),
|
||||
TextEntry::make('unit_price')->numeric(2),
|
||||
TextEntry::make('line_total')->numeric(2),
|
||||
]),
|
||||
]),
|
||||
TextEntry::make('price')->label('Total Price')->numeric(2),
|
||||
]),
|
||||
Section::make('Passenger')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('passenger_name'),
|
||||
TextEntry::make('passenger_phone'),
|
||||
]),
|
||||
]),
|
||||
Section::make('Pickup & Dropoff')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextEntry::make('pickup_address'),
|
||||
TextEntry::make('dropoff_address'),
|
||||
TextEntry::make('pickup_lat')->label('Pickup Lat')->placeholder('—'),
|
||||
TextEntry::make('dropoff_lat')->label('Dropoff Lat')->placeholder('—'),
|
||||
TextEntry::make('pickup_lng')->label('Pickup Lng')->placeholder('—'),
|
||||
TextEntry::make('dropoff_lng')->label('Dropoff Lng')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
Section::make('Driver & Vehicle')
|
||||
->description('Filled in by staff once the booking is confirmed — see the Assign Driver action.')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('driver_name')->label('Driver')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('driver_phone')->label('Driver Phone')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('car_plate_number')->label('Car Plate')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('car_model')->label('Car Model')->placeholder('—'),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Tables;
|
||||
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
|
||||
class BookingsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with([
|
||||
'route.company', 'route.fromDestination', 'route.toDestination', 'timeSlot', 'vehicleOptions',
|
||||
]))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('booking_ref')
|
||||
->label('Ref')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
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.company.name')
|
||||
->label('Company')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('route.fromDestination.name')
|
||||
->label('From'),
|
||||
TextColumn::make('route.toDestination.name')
|
||||
->label('To'),
|
||||
TextColumn::make('travel_date')
|
||||
->date()
|
||||
->sortable(),
|
||||
TextColumn::make('timeSlot.label')
|
||||
->label('Time Slot'),
|
||||
TextColumn::make('vehicleOptions')
|
||||
->label('Vehicle Options')
|
||||
->state(fn (Booking $record) => $record->vehicleOptions
|
||||
->map(fn ($line) => str($line->vehicle_option->value)->headline().' x'.$line->passenger_count)
|
||||
->all())
|
||||
->listWithLineBreaks(),
|
||||
TextColumn::make('price')
|
||||
->numeric(2)
|
||||
->sortable(),
|
||||
TextColumn::make('passenger_name')
|
||||
->label('Passenger')
|
||||
->description(fn (Booking $record) => $record->passenger_phone)
|
||||
->searchable(['passenger_name', 'passenger_phone'])
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('created_by_channel')
|
||||
->badge()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('driver_name')
|
||||
->label('Driver')
|
||||
->placeholder('Not yet assigned')
|
||||
->description(fn (Booking $record) => collect([$record->driver_phone, $record->car_plate_number, $record->car_model])
|
||||
->filter()
|
||||
->join(' • ') ?: null)
|
||||
->searchable(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])
|
||||
->toggleable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options(array_combine(
|
||||
array_map(fn (BookingStatus $status) => $status->value, BookingStatus::cases()),
|
||||
array_map(fn (BookingStatus $status) => str($status->value)->headline()->toString(), BookingStatus::cases()),
|
||||
)),
|
||||
Filter::make('travel_date')
|
||||
->schema([
|
||||
DatePicker::make('travel_date'),
|
||||
])
|
||||
->query(fn (Builder $query, array $data) => $query->when(
|
||||
$data['travel_date'] ?? null,
|
||||
fn (Builder $q, $date) => $q->whereDate('travel_date', $date),
|
||||
)),
|
||||
SelectFilter::make('ev_route_id')
|
||||
->label('Route')
|
||||
->options(fn () => EvRoute::with(['fromDestination', 'toDestination'])->get()
|
||||
->mapWithKeys(fn (EvRoute $route) => [
|
||||
$route->id => "{$route->fromDestination?->name} → {$route->toDestination?->name}",
|
||||
]))
|
||||
->searchable(),
|
||||
SelectFilter::make('company')
|
||||
->options(fn () => EvCompany::pluck('name', 'id'))
|
||||
->searchable()
|
||||
->query(fn (Builder $query, array $data) => $query->when(
|
||||
$data['value'] ?? null,
|
||||
fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)),
|
||||
)),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
AssignDriverTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user