From 7872105f2f58a63dc8d434eb194bef005a49cc8a Mon Sep 17 00:00:00 2001 From: Nyan Lin Paing <117423022+LinPaing21@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:18:52 +0700 Subject: [PATCH] Add Departure Time Slot and Catalog read API (T2.4, T2.5) Adds the shared DepartureTimeSlot catalog entity with its Filament resource, and public GET /api/v1/companies + /api/v1/destinations endpoints (auth:sanctum + throttled) for the mini app/mobile/agent clients to consume. --- .../factories/DepartureTimeSlotFactory.php | 29 +++++++++ ...0507_create_departure_time_slots_table.php | 30 +++++++++ app-modules/catalog/routes/catalog-routes.php | 9 +++ .../DepartureTimeSlotResource.php | 51 +++++++++++++++ .../Pages/CreateDepartureTimeSlot.php | 11 ++++ .../Pages/EditDepartureTimeSlot.php | 19 ++++++ .../Pages/ListDepartureTimeSlots.php | 19 ++++++ .../Schemas/DepartureTimeSlotForm.php | 26 ++++++++ .../Tables/DepartureTimeSlotsTable.php | 45 +++++++++++++ .../Controllers/DestinationController.php | 18 +++++ .../Http/Controllers/EvCompanyController.php | 18 +++++ .../Http/Resources/DestinationResource.php | 29 +++++++++ .../src/Http/Resources/EvCompanyResource.php | 29 +++++++++ .../catalog/src/Models/DepartureTimeSlot.php | 37 +++++++++++ .../tests/Feature/CatalogReadApiTest.php | 39 +++++++++++ .../Feature/DepartureTimeSlotResourceTest.php | 65 +++++++++++++++++++ 16 files changed, 474 insertions(+) create mode 100644 app-modules/catalog/database/factories/DepartureTimeSlotFactory.php create mode 100644 app-modules/catalog/database/migrations/2026_08_06_130507_create_departure_time_slots_table.php create mode 100644 app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/DepartureTimeSlotResource.php create mode 100644 app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Pages/CreateDepartureTimeSlot.php create mode 100644 app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Pages/EditDepartureTimeSlot.php create mode 100644 app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Pages/ListDepartureTimeSlots.php create mode 100644 app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Schemas/DepartureTimeSlotForm.php create mode 100644 app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Tables/DepartureTimeSlotsTable.php create mode 100644 app-modules/catalog/src/Http/Controllers/DestinationController.php create mode 100644 app-modules/catalog/src/Http/Controllers/EvCompanyController.php create mode 100644 app-modules/catalog/src/Http/Resources/DestinationResource.php create mode 100644 app-modules/catalog/src/Http/Resources/EvCompanyResource.php create mode 100644 app-modules/catalog/src/Models/DepartureTimeSlot.php create mode 100644 app-modules/catalog/tests/Feature/CatalogReadApiTest.php create mode 100644 app-modules/catalog/tests/Feature/DepartureTimeSlotResourceTest.php diff --git a/app-modules/catalog/database/factories/DepartureTimeSlotFactory.php b/app-modules/catalog/database/factories/DepartureTimeSlotFactory.php new file mode 100644 index 0000000..b69d6fc --- /dev/null +++ b/app-modules/catalog/database/factories/DepartureTimeSlotFactory.php @@ -0,0 +1,29 @@ + + */ +class DepartureTimeSlotFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $time = Carbon::createFromTime(fake()->numberBetween(0, 23), fake()->randomElement([0, 15, 30, 45])); + + return [ + 'label' => $time->format('h:i A'), + 'time' => $time->format('H:i'), + 'is_active' => true, + ]; + } +} diff --git a/app-modules/catalog/database/migrations/2026_08_06_130507_create_departure_time_slots_table.php b/app-modules/catalog/database/migrations/2026_08_06_130507_create_departure_time_slots_table.php new file mode 100644 index 0000000..0f3b8a8 --- /dev/null +++ b/app-modules/catalog/database/migrations/2026_08_06_130507_create_departure_time_slots_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('label'); + $table->time('time'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('departure_time_slots'); + } +}; diff --git a/app-modules/catalog/routes/catalog-routes.php b/app-modules/catalog/routes/catalog-routes.php index b3d9bbc..76821d8 100644 --- a/app-modules/catalog/routes/catalog-routes.php +++ b/app-modules/catalog/routes/catalog-routes.php @@ -1 +1,10 @@ middleware(['api', 'auth:sanctum', 'throttle:60,1'])->group(function () { + Route::get('/companies', [EvCompanyController::class, 'index'])->name('catalog.companies.index'); + Route::get('/destinations', [DestinationController::class, 'index'])->name('catalog.destinations.index'); +}); diff --git a/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/DepartureTimeSlotResource.php b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/DepartureTimeSlotResource.php new file mode 100644 index 0000000..5ebb71c --- /dev/null +++ b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/DepartureTimeSlotResource.php @@ -0,0 +1,51 @@ + ListDepartureTimeSlots::route('/'), + 'create' => CreateDepartureTimeSlot::route('/create'), + 'edit' => EditDepartureTimeSlot::route('/{record}/edit'), + ]; + } +} diff --git a/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Pages/CreateDepartureTimeSlot.php b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Pages/CreateDepartureTimeSlot.php new file mode 100644 index 0000000..6541a9b --- /dev/null +++ b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Pages/CreateDepartureTimeSlot.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('label') + ->required() + ->maxLength(255), + TimePicker::make('time') + ->required(), + Toggle::make('is_active') + ->required() + ->default(true), + ]); + } +} diff --git a/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Tables/DepartureTimeSlotsTable.php b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Tables/DepartureTimeSlotsTable.php new file mode 100644 index 0000000..c834559 --- /dev/null +++ b/app-modules/catalog/src/Filament/Resources/DepartureTimeSlots/Tables/DepartureTimeSlotsTable.php @@ -0,0 +1,45 @@ +columns([ + TextColumn::make('label') + ->searchable() + ->sortable(), + TextColumn::make('time') + ->time('H:i') + ->sortable(), + IconColumn::make('is_active') + ->boolean(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('time') + ->filters([ + TernaryFilter::make('is_active'), + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app-modules/catalog/src/Http/Controllers/DestinationController.php b/app-modules/catalog/src/Http/Controllers/DestinationController.php new file mode 100644 index 0000000..afc9732 --- /dev/null +++ b/app-modules/catalog/src/Http/Controllers/DestinationController.php @@ -0,0 +1,18 @@ +where('is_active', true)->get() + ); + } +} diff --git a/app-modules/catalog/src/Http/Controllers/EvCompanyController.php b/app-modules/catalog/src/Http/Controllers/EvCompanyController.php new file mode 100644 index 0000000..078d340 --- /dev/null +++ b/app-modules/catalog/src/Http/Controllers/EvCompanyController.php @@ -0,0 +1,18 @@ +where('is_active', true)->get() + ); + } +} diff --git a/app-modules/catalog/src/Http/Resources/DestinationResource.php b/app-modules/catalog/src/Http/Resources/DestinationResource.php new file mode 100644 index 0000000..94cb2fd --- /dev/null +++ b/app-modules/catalog/src/Http/Resources/DestinationResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'mm_name' => $this->mm_name, + 'region' => $this->region, + 'description' => $this->description, + 'mm_description' => $this->mm_description, + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + 'popular' => $this->popular, + ]; + } +} diff --git a/app-modules/catalog/src/Http/Resources/EvCompanyResource.php b/app-modules/catalog/src/Http/Resources/EvCompanyResource.php new file mode 100644 index 0000000..31fa521 --- /dev/null +++ b/app-modules/catalog/src/Http/Resources/EvCompanyResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'mm_name' => $this->mm_name, + 'slug' => $this->slug, + 'description' => $this->description, + 'mm_description' => $this->mm_description, + 'contact' => $this->contact, + 'address' => $this->address, + 'logo' => $this->logo, + ]; + } +} diff --git a/app-modules/catalog/src/Models/DepartureTimeSlot.php b/app-modules/catalog/src/Models/DepartureTimeSlot.php new file mode 100644 index 0000000..4841c97 --- /dev/null +++ b/app-modules/catalog/src/Models/DepartureTimeSlot.php @@ -0,0 +1,37 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'label', + 'time', + 'is_active', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'time' => 'datetime:H:i', + 'is_active' => 'boolean', + ]; + } +} diff --git a/app-modules/catalog/tests/Feature/CatalogReadApiTest.php b/app-modules/catalog/tests/Feature/CatalogReadApiTest.php new file mode 100644 index 0000000..622c4d4 --- /dev/null +++ b/app-modules/catalog/tests/Feature/CatalogReadApiTest.php @@ -0,0 +1,39 @@ +token = User::factory()->create()->createToken('test-token')->plainTextToken; +}); + +test('lists active ev companies', function () { + $active = EvCompany::factory()->create(['is_active' => true]); + EvCompany::factory()->create(['is_active' => false]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/companies') + ->assertSuccessful() + ->assertJsonCount(1, 'data') + ->assertJsonFragment(['id' => $active->id]); +}); + +test('lists active destinations', function () { + $active = Destination::factory()->create(['is_active' => true]); + Destination::factory()->create(['is_active' => false]); + + $this->withHeader('Authorization', "Bearer {$this->token}") + ->getJson('/api/v1/destinations') + ->assertSuccessful() + ->assertJsonCount(1, 'data') + ->assertJsonFragment(['id' => $active->id]); +}); + +test('companies endpoint rejects unauthenticated requests', function () { + $this->getJson('/api/v1/companies')->assertUnauthorized(); +}); + +test('destinations endpoint rejects unauthenticated requests', function () { + $this->getJson('/api/v1/destinations')->assertUnauthorized(); +}); diff --git a/app-modules/catalog/tests/Feature/DepartureTimeSlotResourceTest.php b/app-modules/catalog/tests/Feature/DepartureTimeSlotResourceTest.php new file mode 100644 index 0000000..5cb76ef --- /dev/null +++ b/app-modules/catalog/tests/Feature/DepartureTimeSlotResourceTest.php @@ -0,0 +1,65 @@ +admin = User::factory()->create()->assignRole('admin'); + $this->actingAs($this->admin); +}); + +test('can list departure time slots', function () { + $timeSlots = DepartureTimeSlot::factory()->count(3)->create(); + + Livewire::test(ListDepartureTimeSlots::class) + ->assertOk() + ->assertCanSeeTableRecords($timeSlots); +}); + +test('can create a departure time slot', function () { + $timeSlot = DepartureTimeSlot::factory()->make(); + + Livewire::test(CreateDepartureTimeSlot::class) + ->fillForm([ + 'label' => $timeSlot->label, + 'time' => $timeSlot->time, + 'is_active' => true, + ]) + ->call('create') + ->assertNotified() + ->assertRedirect(); + + assertDatabaseHas(DepartureTimeSlot::class, [ + 'label' => $timeSlot->label, + ]); +}); + +test('can edit a departure time slot', function () { + $timeSlot = DepartureTimeSlot::factory()->create(); + + Livewire::test(EditDepartureTimeSlot::class, ['record' => $timeSlot->getRouteKey()]) + ->assertOk() + ->fillForm(['label' => 'Updated Label']) + ->call('save') + ->assertNotified(); + + assertDatabaseHas(DepartureTimeSlot::class, [ + 'id' => $timeSlot->id, + 'label' => 'Updated Label', + ]); +}); + +test('a departure time slot is a shared catalog entity not owned by a single route', function () { + $timeSlot = DepartureTimeSlot::factory()->create(); + + expect($timeSlot->getFillable())->not->toContain('ev_route_id'); +});