This commit is contained in:
@@ -6,6 +6,8 @@ use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
@@ -15,74 +17,88 @@ class EvRouteForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Select::make('ev_company_id')
|
||||
->label('EV Company')
|
||||
->relationship('company', 'name')
|
||||
->required()
|
||||
->searchable()
|
||||
->preload(),
|
||||
Select::make('from_destination_id')
|
||||
->label('From')
|
||||
->relationship('fromDestination', 'name')
|
||||
->required()
|
||||
->searchable()
|
||||
->preload(),
|
||||
Select::make('to_destination_id')
|
||||
->label('To')
|
||||
->relationship('toDestination', 'name')
|
||||
->required()
|
||||
->searchable()
|
||||
->preload()
|
||||
->different('from_destination_id')
|
||||
->validationMessages([
|
||||
'different' => 'The destination must be different from the origin.',
|
||||
]),
|
||||
Select::make('timeSlots')
|
||||
->label('Departure Time Slots')
|
||||
->relationship('timeSlots', 'label')
|
||||
->multiple()
|
||||
->searchable()
|
||||
->preload(),
|
||||
Toggle::make('is_round_trip')
|
||||
->required()
|
||||
->default(false),
|
||||
Toggle::make('is_active')
|
||||
->required()
|
||||
->default(false)
|
||||
->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'),
|
||||
Repeater::make('pricing')
|
||||
->relationship()
|
||||
->label('Pricing')
|
||||
Section::make('Route')
|
||||
->schema([
|
||||
Select::make('vehicle_option')
|
||||
->options(array_combine(
|
||||
array_map(fn (VehicleOption $option) => $option->value, VehicleOption::cases()),
|
||||
array_map(fn (VehicleOption $option) => str($option->value)->headline()->toString(), VehicleOption::cases()),
|
||||
))
|
||||
->disabled()
|
||||
->dehydrated()
|
||||
->required(),
|
||||
TextInput::make('price')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->required(),
|
||||
Toggle::make('is_blocked')
|
||||
->label('Blocked')
|
||||
->helperText('Hidden from booking regardless of price.'),
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Select::make('ev_company_id')
|
||||
->label('EV Company')
|
||||
->relationship('company', 'name')
|
||||
->required()
|
||||
->searchable()
|
||||
->preload(),
|
||||
Select::make('from_destination_id')
|
||||
->label('From')
|
||||
->relationship('fromDestination', 'name')
|
||||
->required()
|
||||
->searchable()
|
||||
->preload(),
|
||||
Select::make('to_destination_id')
|
||||
->label('To')
|
||||
->relationship('toDestination', 'name')
|
||||
->required()
|
||||
->searchable()
|
||||
->preload()
|
||||
->different('from_destination_id')
|
||||
->validationMessages([
|
||||
'different' => 'The destination must be different from the origin.',
|
||||
]),
|
||||
Select::make('timeSlots')
|
||||
->label('Departure Time Slots')
|
||||
->relationship('timeSlots', 'label')
|
||||
->multiple()
|
||||
->searchable()
|
||||
->preload(),
|
||||
]),
|
||||
])
|
||||
->columns(3)
|
||||
->default(
|
||||
collect(VehicleOption::cases())
|
||||
->map(fn (VehicleOption $option) => [
|
||||
'vehicle_option' => $option->value,
|
||||
'price' => 0,
|
||||
'is_blocked' => false,
|
||||
->columnSpanFull(),
|
||||
Section::make('Options')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->required()
|
||||
->default(false)
|
||||
->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Pricing')
|
||||
->schema([
|
||||
Repeater::make('pricing')
|
||||
->relationship()
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
Select::make('vehicle_option')
|
||||
->options(array_combine(
|
||||
array_map(fn (VehicleOption $option) => $option->value, VehicleOption::cases()),
|
||||
array_map(fn (VehicleOption $option) => str($option->value)->headline()->toString(), VehicleOption::cases()),
|
||||
))
|
||||
->disabled()
|
||||
->dehydrated()
|
||||
->required(),
|
||||
TextInput::make('price')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->required(),
|
||||
Toggle::make('is_blocked')
|
||||
->label('Blocked')
|
||||
->helperText('Hidden from booking regardless of price.'),
|
||||
])
|
||||
->all()
|
||||
)
|
||||
->addable(false)
|
||||
->deletable(false)
|
||||
->reorderable(false)
|
||||
->columns(3)
|
||||
->default(
|
||||
collect(VehicleOption::cases())
|
||||
->map(fn (VehicleOption $option) => [
|
||||
'vehicle_option' => $option->value,
|
||||
'price' => 0,
|
||||
'is_blocked' => false,
|
||||
])
|
||||
->all()
|
||||
)
|
||||
->addable(false)
|
||||
->deletable(false)
|
||||
->reorderable(false),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace Modules\Routing\Filament\Resources\EvRoutes\Tables;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
@@ -40,8 +42,6 @@ class EvRoutesTable
|
||||
.($pricing->is_blocked ? 'Blocked' : number_format($pricing->price, 0)))
|
||||
->all())
|
||||
->listWithLineBreaks(),
|
||||
IconColumn::make('is_round_trip')
|
||||
->boolean(),
|
||||
IconColumn::make('is_active')
|
||||
->boolean(),
|
||||
TextColumn::make('created_at')
|
||||
@@ -50,9 +50,25 @@ class EvRoutesTable
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('ev_company_id')
|
||||
->label('Company')
|
||||
->relationship('company', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
SelectFilter::make('from_destination_id')
|
||||
->label('From')
|
||||
->relationship('fromDestination', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
SelectFilter::make('to_destination_id')
|
||||
->label('To')
|
||||
->relationship('toDestination', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
TernaryFilter::make('is_active'),
|
||||
TernaryFilter::make('is_round_trip'),
|
||||
])
|
||||
->filtersFormColumns(2)
|
||||
->filtersFormWidth(Width::Large)
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
namespace Modules\Routing\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
use Modules\Routing\Http\Requests\SearchRoutesRequest;
|
||||
use Modules\Routing\Http\Resources\EvRouteResource;
|
||||
use Modules\Routing\Http\Resources\RoutePricingResource;
|
||||
use Modules\Routing\Http\Resources\RouteTimeSlotResource;
|
||||
@@ -22,26 +28,111 @@ class EvRouteController extends Controller
|
||||
|
||||
private const CACHE_TTL_MINUTES = 5;
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
/**
|
||||
* POST, not GET: round_trip=true returns two independent result sets
|
||||
* (routes + return_routes) in one response, which doesn't fit a plain
|
||||
* GET-with-query-params search shape as cleanly (domain.md §2b).
|
||||
*/
|
||||
public function search(SearchRoutesRequest $request): JsonResponse
|
||||
{
|
||||
$filters = $request->only(['company', 'from', 'to', 'date']);
|
||||
$page = $request->integer('page', 1);
|
||||
$filters = $request->only(['company', 'from', 'to', 'date', 'time_slot']);
|
||||
$isRoundTrip = $request->boolean('round_trip');
|
||||
|
||||
$routes = Cache::tags(self::CACHE_TAG)->remember(
|
||||
'routes:index:'.md5(json_encode($filters + ['page' => $page])),
|
||||
// Separate page params: routes and return_routes almost always have
|
||||
// different totals, so paging one must never slice the other at the
|
||||
// same offset (e.g. return_routes with only 3 rows would come back
|
||||
// empty on page=2 while routes still has real data there).
|
||||
$routes = $this->searchRoutes($filters, fromKey: 'from', toKey: 'to', pageName: 'page', page: $request->integer('page', 1));
|
||||
|
||||
$returnRoutes = $isRoundTrip
|
||||
? $this->searchRoutes($filters, fromKey: 'to', toKey: 'from', pageName: 'return_page', page: $request->integer('return_page', 1))
|
||||
: new LengthAwarePaginator([], 0, 15);
|
||||
|
||||
return response()->json([
|
||||
'routes' => EvRouteResource::collection($routes)->response()->getData(true),
|
||||
'return_routes' => EvRouteResource::collection($returnRoutes)->response()->getData(true),
|
||||
// The company/time_slot options actually available for this
|
||||
// from->to pair — computed from from/to alone, ignoring any
|
||||
// company/time_slot already applied, so the client can offer
|
||||
// switching between them rather than guessing a static list.
|
||||
'filters' => $this->filterOptions($filters['from'] ?? null, $filters['to'] ?? null),
|
||||
'return_filters' => $isRoundTrip
|
||||
? $this->filterOptions($filters['to'] ?? null, $filters['from'] ?? null)
|
||||
: ['companies' => [], 'time_slots' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{companies: array<int, array<string, mixed>>, time_slots: array<int, array<string, mixed>>}
|
||||
*/
|
||||
private function filterOptions(mixed $from, mixed $to): array
|
||||
{
|
||||
if (blank($from) || blank($to)) {
|
||||
return ['companies' => [], 'time_slots' => []];
|
||||
}
|
||||
|
||||
$cacheKey = "routes:filter-options:{$from}:{$to}";
|
||||
|
||||
return Cache::tags(self::CACHE_TAG)->remember(
|
||||
$cacheKey,
|
||||
now()->addMinutes(self::CACHE_TTL_MINUTES),
|
||||
function () use ($from, $to) {
|
||||
$routes = EvRoute::query()
|
||||
->where('is_active', true)
|
||||
->where('from_destination_id', $from)
|
||||
->where('to_destination_id', $to)
|
||||
->with(['company', 'timeSlots' => fn (BelongsToMany $query) => $query->wherePivot('is_active', true)])
|
||||
->get();
|
||||
|
||||
$companies = $routes->pluck('company')->filter()->unique('id')->sortBy('name')->values();
|
||||
$timeSlots = $routes->flatMap(fn (EvRoute $route) => $route->timeSlots)->unique('id')->sortBy('time')->values();
|
||||
|
||||
return [
|
||||
// Facet purposes only — not the full EvCompanyResource
|
||||
// (no slug/description/contact/logo needed just to
|
||||
// populate a filter option).
|
||||
'companies' => $companies->map(fn (EvCompany $company) => [
|
||||
'id' => $company->id,
|
||||
'name' => $company->name,
|
||||
'mm_name' => $company->mm_name,
|
||||
])->all(),
|
||||
'time_slots' => $timeSlots->map(fn ($slot) => [
|
||||
'id' => $slot->id,
|
||||
'label' => $slot->label,
|
||||
'time' => $slot->time?->format('H:i'),
|
||||
])->all(),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters Keyed by 'from'/'to' regardless of
|
||||
* $fromKey/$toKey — swapped for the return leg of a round trip.
|
||||
*/
|
||||
private function searchRoutes(array $filters, string $fromKey, string $toKey, string $pageName, int $page): LengthAwarePaginator
|
||||
{
|
||||
$cacheKey = 'routes:search:'.md5(json_encode($filters + ['fromKey' => $fromKey, 'page' => $page]));
|
||||
|
||||
return Cache::tags(self::CACHE_TAG)->remember(
|
||||
$cacheKey,
|
||||
now()->addMinutes(self::CACHE_TTL_MINUTES),
|
||||
fn () => EvRoute::query()
|
||||
->where('is_active', true)
|
||||
->when($request->filled('company'), fn ($query) => $query->where('ev_company_id', $request->integer('company')))
|
||||
->when($request->filled('from'), fn ($query) => $query->where('from_destination_id', $request->integer('from')))
|
||||
->when($request->filled('to'), fn ($query) => $query->where('to_destination_id', $request->integer('to')))
|
||||
->when(filled($filters['company'] ?? null), fn (Builder $query) => $query->where('ev_company_id', $filters['company']))
|
||||
->when(filled($filters[$fromKey] ?? null), fn (Builder $query) => $query->where('from_destination_id', $filters[$fromKey]))
|
||||
->when(filled($filters[$toKey] ?? null), fn (Builder $query) => $query->where('to_destination_id', $filters[$toKey]))
|
||||
->when(filled($filters['time_slot'] ?? null), fn (Builder $query) => $query->whereHas(
|
||||
'timeSlots',
|
||||
fn (Builder $timeSlotQuery) => $timeSlotQuery
|
||||
->where('departure_time_slots.time', Carbon::createFromFormat('H:i', $filters['time_slot'])->format('H:i:s'))
|
||||
->where('ev_route_time_slots.is_active', true),
|
||||
))
|
||||
// `date` is accepted for forward-compatibility with future per-date capacity
|
||||
// checks (domain.md §7), but v1 has no route-level calendar to filter against.
|
||||
->with(self::EAGER_LOADS)
|
||||
->paginate(),
|
||||
->paginate(perPage: 15, pageName: $pageName, page: $page),
|
||||
);
|
||||
|
||||
return EvRouteResource::collection($routes);
|
||||
}
|
||||
|
||||
public function show(EvRoute $route): EvRouteResource
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Routing\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Shape validation only for the routes search endpoint. round_trip=true
|
||||
* requires both from and to — "return" only means something for a specific
|
||||
* origin/destination pair, not an unfiltered route list.
|
||||
*/
|
||||
class SearchRoutesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'company' => ['nullable', 'integer', 'exists:ev_companies,id'],
|
||||
'from' => ['nullable', 'integer', 'exists:destinations,id', 'required_if:round_trip,true'],
|
||||
'to' => ['nullable', 'integer', 'exists:destinations,id', 'different:from', 'required_if:round_trip,true'],
|
||||
'date' => ['nullable', 'date'],
|
||||
// The catalog's shared time value (e.g. "06:00"), not a
|
||||
// DepartureTimeSlot id — matches how customers think about
|
||||
// departure times (domain.md §1).
|
||||
'time_slot' => ['nullable', 'date_format:H:i'],
|
||||
'round_trip' => ['sometimes', 'boolean'],
|
||||
// Independent page cursors — routes and return_routes almost
|
||||
// always have different totals, so they can't share one `page`
|
||||
// without one side silently paginating the other's offset.
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'return_page' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ class EvRouteResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'is_round_trip' => $this->is_round_trip,
|
||||
'is_active' => $this->is_active,
|
||||
'company' => new EvCompanyResource($this->whenLoaded('company')),
|
||||
'from_destination' => new DestinationResource($this->whenLoaded('fromDestination')),
|
||||
|
||||
@@ -42,7 +42,6 @@ class EvRoute extends Model
|
||||
'ev_company_id',
|
||||
'from_destination_id',
|
||||
'to_destination_id',
|
||||
'is_round_trip',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
@@ -52,7 +51,6 @@ class EvRoute extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_round_trip' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -99,4 +97,16 @@ class EvRoute extends Model
|
||||
get: fn (): string => $this->fromDestination->name.' → '.$this->toDestination->name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this route is the exact reverse direction of $other (from
|
||||
* and to swapped) — used to validate a booking's `return_ev_route_id`
|
||||
* is genuinely the return leg of its outbound route, not an unrelated
|
||||
* pair (domain.md §2b).
|
||||
*/
|
||||
public function isReverseOf(EvRoute $other): bool
|
||||
{
|
||||
return $this->from_destination_id === $other->to_destination_id
|
||||
&& $this->to_destination_id === $other->from_destination_id;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user